Skip to content

Scope document upload and download to the session they name - #105

Merged
MaximumTrainer merged 1 commit into
mainfrom
fix/document-endpoint-ownership
Sep 13, 2026
Merged

MaximumTrainer merged 1 commit into
mainfrom
fix/document-endpoint-ownership

Conversation

@MaximumTrainer

Copy link
Copy Markdown
Owner

Closes #104

Fixes the ownership defect in the document endpoints, written test-first.

The defect

UploadDocuments and GetDocument were the only session endpoints carrying [Authorize(Policy = "ApplicantOrOperator")] without a resource-based ownership check. That policy asserts the caller holds some applicant or operator credential; it says nothing about which session that credential names — the whole point of the per-session token.

Upload trusted the route's sessionId, so any applicant token could write documents into any session, landing a Submission row that reads in the operator console as the real applicant's upload.

Download was worse than an absent check. sessionId and nodeId were in the route but never reached the service, which resolved the file id straight from blob storage:

public Task<(Stream, StoredFileInfo)> Handle(GetDocumentQuery query, …)
    => documentStorageService.GetStreamAsync(query.FileId, cancellationToken);

Any authenticated caller could fetch any stored document given its id, with the path segments that appeared to scope the request doing nothing at all. These are identity and address documents, and the ids are not secrets — they are returned to the browser on upload, stored in Submission.DataJson, and shown in the operator console.

The fix

  • Both endpoints now run the ownership check every other session endpoint uses: Write for upload — so the terminal-status rule from Applicant session tokens cannot be refreshed or revoked #101 applies too — and Read for download.
  • Ownership alone would not have closed the read, since the owner of session A could still request session B's file through A's own route. The lookup is now scoped: GetDocumentAsync takes the session and node and resolves a file only if an upload recorded against them carries the id.
  • The port signature changed rather than a check being added beside it, so the unscoped path cannot be called at all and a future caller cannot reintroduce the defect.
  • A file that exists but belongs elsewhere returns 404, not 403 — a refusal would confirm the id is real.
  • The session-to-file link already existed; UploadDocumentsCommandHandler records each upload as a Submission carrying the StoredFileInfo[]. The read path simply never consulted it. No schema change.

Verification

Written test-first. Against the unfixed code the new suite ran 7 failed / 4 passed — the 4 passing being the legitimate-access cases, which correctly worked all along. After the fix: 11/11.

The two tests that matter most:

  • Download_OfAnotherSessionsFile_ThroughMyOwnSessionRoute_IsRefused — the case an authorization check alone would miss, since ownership legitimately passes.
  • Download_NeverReachesStorage_WhenTheFileIsNotRecordedAgainstTheSession — asserts the bytes are never fetched, not merely withheld after the fact.
Backend 354 passed, 3 skipped (broker tests; Docker was down locally — CI runs them)
Frontend vitest / lint / build 175 passed, clean
Solution build 0 errors

No API shape change: routes, request and response bodies are identical, so the frontend needed no change. StepRenderer already sends the applicant token on upload, which Upload_ToMyOwnSession_Succeeds pins.

🤖 Generated with Claude Code

Closes #104

These were the only session endpoints carrying ApplicantOrOperator without
a resource-based ownership check. The policy asserts the caller holds some
applicant or operator credential; it says nothing about which session that
credential names, which is the whole point of the per-session token.

Upload trusted the route's sessionId, so any applicant token could write
documents into any session - landing a Submission row that reads in the
operator console as the real applicant's upload.

Download was worse than an absent check. sessionId and nodeId were in the
route but never reached the service, which resolved the file id straight
from blob storage: any authenticated caller could fetch any stored
document given its id, with the path segments that appeared to scope the
request doing nothing. These are identity and address documents, and the
ids are not secrets - they are returned to the browser, stored in
Submission.DataJson and shown in the operator console.

Both now run the ownership check the other endpoints use: Write for
upload, so the terminal-status rule applies too, and Read for download.

Ownership alone would not close the read, since the owner of one session
could still request another's file through their own route. The lookup is
now scoped: GetDocumentAsync takes the session and node, and resolves a
file only if an upload recorded against them carries the id. The port
signature changed rather than a check being added beside it, so the
unscoped path cannot be called at all. A file belonging elsewhere returns
404, not 403, since a refusal would confirm the id is real.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@MaximumTrainer
MaximumTrainer merged commit 7a821d4 into main Sep 13, 2026
8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Document upload and download endpoints have no ownership check, so any applicant token reaches any session's documents

1 participant