fix(backend): reject foreign documentIds on submit and draft-sync (WP-68 F1)

submit and draft-sync took document ids straight from the request body with no
ownership check: a caller who knew a foreign document's id could attach another
citizen's upload to their own aanvraag (surfacing on the behandelaar's beoordeling
screen, POSTed to OpenZaak as their zaakinformatieobject) and permanently block the
victim's own delete by flipping Linked=true. ADR-0001 holds the FE has no authority;
this trusted it anyway.

Adds DocumentStore.ForeignIds(ids, owner) and calls it from both write paths before
any write, 400 ProblemDetails on a mismatch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
eho
2026-08-05 15:24:32 +02:00
co-authored by Claude Opus 5
parent 6a4a0ad435
commit a394950a1d
3 changed files with 81 additions and 2 deletions
@@ -96,6 +96,22 @@ public static class DocumentStore
}
}
/// <summary>Which of the given ids do NOT resolve to a document owned by <paramref name="owner"/>
/// (unknown id or owned by someone else) — named for what it returns (the offending ids), so a
/// caller can 400 with the specific ids rather than a bare boolean. Guards submit/draft-sync
/// against a citizen attaching another citizen's upload to their own aanvraag.</summary>
public static IReadOnlyList<string> ForeignIds(IEnumerable<string> documentIds, string owner)
{
var ids = documentIds.ToList();
lock (_gate)
{
using var db = Db.Create();
var owned = db.Documents.Where(d => ids.Contains(d.DocumentId) && d.Owner == owner)
.Select(d => d.DocumentId).ToHashSet();
return ids.Where(id => !owned.Contains(id)).ToList();
}
}
/// <summary>Persist the DRC url an OpenZaak upload (WP-51) registered for a document.</summary>
public static void SetDrcUrl(string documentId, string drcUrl)
{
+20 -2
View File
@@ -314,9 +314,19 @@ api.MapPost("/applications", (CreateApplicationRequest req, HttpContext ctx) =>
// Draft sync per step — idempotent; keep it debounced on the client (it is chatty).
api.MapPut("/applications/{id}", (string id, DraftSyncRequest req, HttpContext ctx) =>
ApplicationStore.SyncDraft(id, ctx.Zorgverlener().Bsn, req.Draft, req.StepIndex, req.StepCount, req.DocumentIds)
? Results.NoContent() : Results.NotFound())
{
var owner = ctx.Zorgverlener().Bsn;
// A citizen may only reference their own uploads in a draft — reject before the sync
// writes a foreign document id into the aanvraag (ADR-0001: the FE holds no authority).
if (req.DocumentIds is { } ids && DocumentStore.ForeignIds(ids, owner) is { Count: > 0 } foreign)
return Results.Problem(
detail: $"Onbekend of niet-eigen document(en): {string.Join(", ", foreign)}.",
statusCode: StatusCodes.Status400BadRequest);
return ApplicationStore.SyncDraft(id, owner, req.Draft, req.StepIndex, req.StepCount, req.DocumentIds)
? Results.NoContent() : Results.NotFound();
})
.Produces(StatusCodes.Status204NoContent)
.ProducesProblem(StatusCodes.Status400BadRequest)
.Produces(StatusCodes.Status404NotFound);
// Cancel a Concept (cascades to its unlinked documents). Submitted aanvragen cannot
@@ -353,6 +363,13 @@ api.MapPost("/applications/{id}/submit", (string id, SubmitApplicationRequest re
var docs = req.Documents;
var documentIds = docs?.Where(d => d.Channel == "digital" && d.DocumentId is not null).Select(d => d.DocumentId!).ToList();
// A citizen may only submit their own uploads — reject before the submit writes a
// foreign document id onto the aanvraag (ADR-0001: the FE holds no authority).
if (documentIds is { Count: > 0 } && DocumentStore.ForeignIds(documentIds, ctx.Zorgverlener().Bsn) is { Count: > 0 } foreignIds)
return Results.Problem(
detail: $"Onbekend of niet-eigen document(en): {string.Join(", ", foreignIds)}.",
statusCode: StatusCodes.Status400BadRequest);
var submitted = ApplicationStore.Submit(id, ctx.Zorgverlener().Bsn, reject, autoApprovable, documentIds);
if (submitted is null) return Results.Conflict();
@@ -401,6 +418,7 @@ api.MapPost("/applications/{id}/submit", (string id, SubmitApplicationRequest re
return Results.Ok(new SubmitApplicationResponse(referentie, status));
})
.Produces<SubmitApplicationResponse>()
.ProducesProblem(StatusCodes.Status400BadRequest)
.ProducesProblem(StatusCodes.Status409Conflict)
.Produces(StatusCodes.Status404NotFound);
@@ -172,6 +172,51 @@ public class ApplicationTests(TestWebApplicationFactory factory) : IClassFixture
}
}
// --- WP-68 (F1): a citizen may only reference their own uploads — submit/draft-sync must
// reject a foreign documentId rather than silently attaching it. ---
private static async Task<UploadResponse> UploadAs(HttpClient client, string owner, string localId)
{
var content = new MultipartFormDataContent();
var file = new ByteArrayContent(new byte[] { 1, 2, 3 });
file.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/pdf");
content.Add(file, "file", "d.pdf");
content.Add(new StringContent("diploma"), "categoryId");
content.Add(new StringContent(localId), "localId");
content.Add(new StringContent("registratie"), "wizardId");
var req = new HttpRequestMessage(HttpMethod.Post, "/api/v1/uploads") { Content = content, Headers = { { "X-Subject", owner } } };
var res = await client.SendAsync(req);
Assert.Equal(HttpStatusCode.Created, res.StatusCode);
return (await res.Content.ReadFromJsonAsync<UploadResponse>())!;
}
[Fact]
public async Task Submitting_a_foreign_documentId_is_rejected_and_leaves_it_deletable_by_its_owner()
{
var foreignDoc = await UploadAs(_client, "999888777", Guid.NewGuid().ToString());
var a = await Create("registratie");
var res = await _client.PostAsJsonAsync($"/api/v1/applications/{a.Id}/submit",
new { diplomaHerkomst = "duo", documents = new[] { new { categoryId = "diploma", channel = "digital", documentId = foreignDoc.DocumentId } } });
Assert.Equal(HttpStatusCode.BadRequest, res.StatusCode);
// The rejected submit must not have flipped the foreign document's Linked flag — its
// owner can still delete it.
var deleteReq = new HttpRequestMessage(HttpMethod.Delete, $"/api/v1/uploads/{foreignDoc.DocumentId}") { Headers = { { "X-Subject", "999888777" } } };
Assert.Equal(HttpStatusCode.NoContent, (await _client.SendAsync(deleteReq)).StatusCode);
}
[Fact]
public async Task Draft_sync_with_a_foreign_documentId_is_rejected()
{
var foreignDoc = await UploadAs(_client, "999888777", Guid.NewGuid().ToString());
var a = await Create("registratie");
var res = await _client.PutAsJsonAsync($"/api/v1/applications/{a.Id}",
new { draft = new { }, stepIndex = 0, stepCount = 1, documentIds = new[] { foreignDoc.DocumentId } });
Assert.Equal(HttpStatusCode.BadRequest, res.StatusCode);
}
// --- Auto-approval is computed on read: exercise the window boundary without waiting. ---
private static Aanvraag Accepted(bool autoApprovable) => new()