Files
clean-architecture-backend-…/docs/fileserver/security.md
T
DongHyeonkaandClaude Opus 5 5f10b791d3 chore: record pre-existing uncommitted repository state
Snapshot of the in-flight state that already existed, identically, in both
this worktree and the main checkout before this session began: the initial
HTTP Client platform implementation (previously untracked), the redis-lab
removal, and the JPA / object-storage / notification integration work.

Kept separate from this session's HTTP Client review response, which lands
in the following commit, so the two bodies of work stay reviewable apart.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 16:48:43 +09:00

75 lines
3.6 KiB
Markdown

# Fileserver security model
## The rule everything else follows
Uploaded content is attacker-controlled. Every guard below exists because some part of the request
— the filename, the declared media type, the range, the offset — is a value the caller chose.
## Path safety
A client value never becomes a path. The physical key is server-generated, and `ContentKey`'s
character class excludes `.` entirely, so no traversal or extension-shaped segment survives
validation. `DefaultPhysicalPathResolver` is the only place an identifier becomes a `Path`, and it
normalizes and re-checks containment after construction rather than trusting the input.
Symlink refusal happens at open time, not only at construction. A parent directory can be replaced
between the two, so a check that ran only at path-building time would be a race, not a guard.
## Filename handling
`OriginalFilenamePolicy` strips path separators, NUL, quoting characters, and the colon — the last
because on Windows it opens both a drive reference and an NTFS alternate data stream, so a name that
keeps it is still path-shaped after the slashes are gone. Control characters and bidirectional
overrides are removed, dot runs collapsed, reserved device names guarded, and the result is bounded
in UTF-8 bytes.
The sanitized name is display data. It is never used to build a key, and it reaches a header only
through `ContentDispositionFactory`, which restricts the ASCII form and percent-encodes the UTF-8
form.
## Content type
The client's `Content-Type` is stored as a claim. The verified type comes from the verification
pipeline, and only the verified type is served. A claimed type that contradicts the content is
quarantined rather than corrected.
Scriptable types are never served inline, whatever the caller asked for: serving stored HTML or SVG
inline from an upload origin is a stored cross-site scripting primitive. Every download also carries
`X-Content-Type-Options: nosniff`.
## Verification precedence
`REJECT > QUARANTINE > RETRY > ACCEPT`. A verifier that times out or throws is `RETRY`, never a
silent pass, and an empty verifier chain answers `RETRY` rather than accepting. A file becomes
publicly readable only after an `ACCEPT`.
## Range safety
The range budget is enforced before content is opened, so a request naming many ranges is rejected
without amplifying into storage work. An unsatisfiable range answers `416` with the real length and
opens nothing.
## Authorization
Every public operation calls the injected `FileAccessPolicy` before any quota reservation or storage
mutation, so a denial leaves no record, no reservation, and no staging object. Startup refuses to
run a production profile with an allow-all policy.
## Delegation
`X-Accel-Redirect` is emitted only after authorization and the READY gate, and only for a full,
unconditional response. The internal prefix must be an `internal` Nginx location; the front proxy
also strips any client-supplied delegation header so a caller cannot name an internal object.
## Telemetry
No metric label, span attribute, or audit record carries a file id, upload id, filename, path, or
user id. Where correlation is needed the value is a keyed HMAC fingerprint — keyed because the
identifier space is enumerable and an unkeyed digest of it is reversible by brute force.
## Ambiguous failures
A failure whose operation may already have taken effect is reported as ambiguous and is never
retryable. On a network filesystem a lost response is indistinguishable from a rejection at the
socket level, so anything not provably safe is treated as ambiguous and sent to reconciliation.