Skip to content

fix(core): result-not-source dangerous-type forcing for attachment downloads (#66)#95

Merged
cuibonobo merged 2 commits into
mainfrom
claude/issue-66-design-review-12400f
Jul 15, 2026
Merged

fix(core): result-not-source dangerous-type forcing for attachment downloads (#66)#95
cuibonobo merged 2 commits into
mainfrom
claude/issue-66-design-review-12400f

Conversation

@cuibonobo

Copy link
Copy Markdown
Member

Summary

Closes #66.

Stack/ScopedStack.getAttachment() never took ?contentType/?filename query params — the three-source Content-Type resolution this issue describes (?contentType, extension inference from ?filename, stored _attachment@1 mimeType) is entirely an HTTP-server concern that doesn't exist anywhere in this repo today. So instead of a Stack method change (like #65), this adds a shared pure implementation of the policy in @haverstack/core for haverstack/server (and any other server implementation) to consume, rather than each re-deriving a security-relevant safe-list independently — the same "written once, not a per-server convention" principle already applied to other invariants in this issue set (#67, #68).

  • packages/core/src/attachment-download.ts (new): resolveAttachmentDownloadContentType() computes the candidate type (?contentType → extension inference from ?filename → stored mimeTypeapplication/octet-stream) and applies a safe-list to that result, never to the source — so a server can't leave a hole by routing a dangerous type through whichever source forcing used to skip. Also exports isSafeAttachmentContentType(), inferContentTypeFromFilename(), FORCED_CONTENT_TYPE, and the NOSNIFF_HEADER_NAME/NOSNIFF_HEADER_VALUE constants. Safe-list (per the issue's recommendation over an ad hoc deny-list): image/* (except image/svg+xml), video/*, audio/*, application/pdf, text/plain, application/octet-stream — checked against the MIME type's base, case-insensitively, so parameter tricks (text/html; charset=...) or casing can't bypass it. Everything else forces Content-Type: application/octet-stream + Content-Disposition: attachment.
  • docs/spec.md: rewrote the Download section with the normative result-not-source forcing rule, the safe-list, and the X-Content-Type-Options: nosniff requirement on every attachment response (forced or not).
  • packages/conformance-fixtures: added attachmentDownloadFixtures — dangerous type × each of the three sources → forced; safe type × each source → passes through; nosniff present on every response. Kept as a separate fixture type from ConformanceFixture since GET /attachments/:fileId has no JSON body — what's being pinned is response headers, not a body shape. Not wired into adapter-api's existing fixture loops, since APIAdapter.getAttachment() has no query-param/header surface to test against them (a plain bytes fetch); these are for haverstack/server's own test suite (and for our in-repo unit tests against the pure function directly).

Test plan

  • pnpm -r run test — 620 tests passing across all packages, including 32 new tests in packages/core/tests/attachment-download.test.ts covering safe-list membership (incl. the image/svg+xml exclusion, case-insensitivity, parameter stripping), extension inference (incl. dangerous extensions inferring their real type, since forcing applies downstream), source precedence, and forcing per source
  • pnpm --filter @haverstack/core run build/typecheck/lint
  • pnpm --filter @haverstack/conformance-fixtures run build/typecheck/lint

Generated by Claude Code

claude added 2 commits July 14, 2026 19:54
…wnloads (#66)

The download Content-Type policy previously only forced the ?contentType
param; extension inference from ?filename and the stored _attachment@1
mimeType were unforced spec gaps, making the hardened path the one an
attacker doesn't have to use. Adds resolveAttachmentDownloadContentType(),
which computes the candidate type from whichever source wins and applies
a safe-list to that result regardless of source, plus
isSafeAttachmentContentType()/inferContentTypeFromFilename() and the
nosniff header constants, all exported from @haverstack/core so server
implementations share one policy instead of each re-deriving it.

Updates docs/spec.md's Download section with the normative
result-not-source rule, the safe-list, and the nosniff requirement.
#66)

Adds attachmentDownloadFixtures: dangerous type x each of the three
Content-Type sources -> forced (application/octet-stream +
Content-Disposition: attachment), safe type x each source -> passes
through, nosniff present on every response. Kept as a separate fixture
type from ConformanceFixture since GET /attachments/:fileId has no JSON
body -- what's being pinned is response headers, not a body shape.
@cuibonobo cuibonobo merged commit 05f39f5 into main Jul 15, 2026
5 checks passed
@cuibonobo cuibonobo deleted the claude/issue-66-design-review-12400f branch July 15, 2026 01:06
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.

Dangerous-type forcing must cover all three Content-Type sources on attachment download

2 participants