feat(feedback): magic-byte MIME validation + dim bomb cap + proxy hardening #112

Merged
YousifShkara merged 1 commit from feat/feedback-attachment-security-hardening into main 2026-06-11 06:03:41 +02:00
Owner

Closes BUNYIP-90. Stacks on BUNYIP-86.

BUNYIP-86 shipped attachment uploads with MIME validation that trusted the browser-sent Content-Type field. An attacker could upload evil.exe with Content-Type: image/png and the server stored it as image/png. Several adjacent gaps made the surface larger than necessary: cross-type substitution (real GIF claiming image/png), decompression bombs (5 MB "PNG" that decodes to a billion pixels and explodes the admin browser), filename injection (control characters that break CSV export and logging), and browser MIME sniffing on the admin proxy response.

Server-side hardening in bunyip-api/src/handlers/feedback.rs:

  • New deps: infer = "0.16" (zero-dep magic-byte sniffer) and imagesize = "0.13" (header-only image dimension reader; does NOT decode pixels). Both tiny.
  • derive_canonical_mime(bytes, declared): sniffs the magic bytes via infer::get. If recognised, the sniffed MIME must be in the allowlist and OVERRIDES whatever the browser sent (browser claim ignored). If unrecognised (no magic bytes), the only accepted shape is text/plain claimed + bytes valid as UTF-8; everything else rejects. A binary masquerading as text/plain fails the UTF-8 check; a GIF claiming image/png is accepted but stored as image/gif (sniffer wins).
  • validate_filename(name): basename-strip + length cap at 200 chars + reject any control character (NULL, newline, tab, etc.) that would break logging, CSV, and downstream callers.
  • enforce_image_dimensions(mime, bytes): for image/* MIMEs, reads dimensions via imagesize::blob_size (header-only parse, no decoder instantiated against adversarial input). Rejects width or height > 10000 px. Defends against decompression bombs the byte-cap can not catch.

The three helpers replace the inline declared-MIME check at submit_feedback. Every check now runs against file CONTENT, never the browser's claim.

Admin BFF proxy hardening in bunyip-web/src/handlers/admin.rs:

  • New with_attachment_hardening(builder) helper applied to both feedback_attachment (binary download) and feedback_export (CSV) so future binary-serving routes get the same treatment by reference.
  • X-Content-Type-Options: nosniff forces the browser to respect our declared Content-Type and skip its own MIME sniffing. A text/plain attachment that contains HTML-ish markup never becomes HTML even on legacy browsers.
  • Content-Security-Policy: sandbox sandboxes any inline-rendered content (strictest sandbox: no scripts, no forms, no same-origin). Defence in depth alongside nosniff.
  • Referrer-Policy: no-referrer prevents the attachment URL (feedback id + attachment id) from leaking via Referer when the admin navigates elsewhere.

Out of scope for this PR (intentionally deferred):

  • CSP on bunyip-web admin pages broadly. Worth doing; separate PR.
  • EXIF stripping. Privacy concern, not security; only matters if attachments are ever exposed to non-admin audiences.
  • AV scanning. Overkill at this scale.

just check-container clean (modulo the pre-existing test_config_defaults parallel-env-var flake on main; 188 other tests pass; fmt + clippy + build clean).

#BUNYIP-90

Closes BUNYIP-90. Stacks on BUNYIP-86. BUNYIP-86 shipped attachment uploads with MIME validation that trusted the browser-sent `Content-Type` field. An attacker could upload `evil.exe` with `Content-Type: image/png` and the server stored it as image/png. Several adjacent gaps made the surface larger than necessary: cross-type substitution (real GIF claiming image/png), decompression bombs (5 MB "PNG" that decodes to a billion pixels and explodes the admin browser), filename injection (control characters that break CSV export and logging), and browser MIME sniffing on the admin proxy response. Server-side hardening in `bunyip-api/src/handlers/feedback.rs`: - New deps: `infer = "0.16"` (zero-dep magic-byte sniffer) and `imagesize = "0.13"` (header-only image dimension reader; does NOT decode pixels). Both tiny. - `derive_canonical_mime(bytes, declared)`: sniffs the magic bytes via `infer::get`. If recognised, the sniffed MIME must be in the allowlist and OVERRIDES whatever the browser sent (browser claim ignored). If unrecognised (no magic bytes), the only accepted shape is text/plain claimed + bytes valid as UTF-8; everything else rejects. A binary masquerading as text/plain fails the UTF-8 check; a GIF claiming image/png is accepted but stored as image/gif (sniffer wins). - `validate_filename(name)`: basename-strip + length cap at 200 chars + reject any control character (NULL, newline, tab, etc.) that would break logging, CSV, and downstream callers. - `enforce_image_dimensions(mime, bytes)`: for `image/*` MIMEs, reads dimensions via `imagesize::blob_size` (header-only parse, no decoder instantiated against adversarial input). Rejects width or height > 10000 px. Defends against decompression bombs the byte-cap can not catch. The three helpers replace the inline declared-MIME check at `submit_feedback`. Every check now runs against file CONTENT, never the browser's claim. Admin BFF proxy hardening in `bunyip-web/src/handlers/admin.rs`: - New `with_attachment_hardening(builder)` helper applied to both `feedback_attachment` (binary download) and `feedback_export` (CSV) so future binary-serving routes get the same treatment by reference. - `X-Content-Type-Options: nosniff` forces the browser to respect our declared Content-Type and skip its own MIME sniffing. A text/plain attachment that contains HTML-ish markup never becomes HTML even on legacy browsers. - `Content-Security-Policy: sandbox` sandboxes any inline-rendered content (strictest sandbox: no scripts, no forms, no same-origin). Defence in depth alongside nosniff. - `Referrer-Policy: no-referrer` prevents the attachment URL (feedback id + attachment id) from leaking via Referer when the admin navigates elsewhere. Out of scope for this PR (intentionally deferred): - CSP on bunyip-web admin pages broadly. Worth doing; separate PR. - EXIF stripping. Privacy concern, not security; only matters if attachments are ever exposed to non-admin audiences. - AV scanning. Overkill at this scale. `just check-container` clean (modulo the pre-existing `test_config_defaults` parallel-env-var flake on `main`; 188 other tests pass; fmt + clippy + build clean). #BUNYIP-90
feat(feedback): magic-byte MIME validation + dim bomb cap + proxy hardening
All checks were successful
Create release / Create release from merged PR (pull_request) Has been skipped
Check / fmt / clippy / build / test (pull_request) Successful in 1m5s
935f79b8a1
Closes BUNYIP-90. Stacks on BUNYIP-86.

BUNYIP-86 shipped attachment uploads with MIME validation that trusted the browser-sent `Content-Type` field. An attacker could upload `evil.exe` with `Content-Type: image/png` and the server stored it as image/png. Several adjacent gaps made the surface larger than necessary: cross-type substitution (real GIF claiming image/png), decompression bombs (5 MB "PNG" that decodes to a billion pixels and explodes the admin browser), filename injection (control characters that break CSV export and logging), and browser MIME sniffing on the admin proxy response.

Server-side hardening in `bunyip-api/src/handlers/feedback.rs`:

- New deps: `infer = "0.16"` (zero-dep magic-byte sniffer) and `imagesize = "0.13"` (header-only image dimension reader; does NOT decode pixels). Both tiny.
- `derive_canonical_mime(bytes, declared)`: sniffs the magic bytes via `infer::get`. If recognised, the sniffed MIME must be in the allowlist and OVERRIDES whatever the browser sent (browser claim ignored). If unrecognised (no magic bytes), the only accepted shape is text/plain claimed + bytes valid as UTF-8; everything else rejects. A binary masquerading as text/plain fails the UTF-8 check; a GIF claiming image/png is accepted but stored as image/gif (sniffer wins).
- `validate_filename(name)`: basename-strip + length cap at 200 chars + reject any control character (NULL, newline, tab, etc.) that would break logging, CSV, and downstream callers.
- `enforce_image_dimensions(mime, bytes)`: for `image/*` MIMEs, reads dimensions via `imagesize::blob_size` (header-only parse, no decoder instantiated against adversarial input). Rejects width or height > 10000 px. Defends against decompression bombs the byte-cap can not catch.

The three helpers replace the inline declared-MIME check at `submit_feedback`. Every check now runs against file CONTENT, never the browser's claim.

Admin BFF proxy hardening in `bunyip-web/src/handlers/admin.rs`:

- New `with_attachment_hardening(builder)` helper applied to both `feedback_attachment` (binary download) and `feedback_export` (CSV) so future binary-serving routes get the same treatment by reference.
- `X-Content-Type-Options: nosniff` forces the browser to respect our declared Content-Type and skip its own MIME sniffing. A text/plain attachment that contains HTML-ish markup never becomes HTML even on legacy browsers.
- `Content-Security-Policy: sandbox` sandboxes any inline-rendered content (strictest sandbox: no scripts, no forms, no same-origin). Defence in depth alongside nosniff.
- `Referrer-Policy: no-referrer` prevents the attachment URL (feedback id + attachment id) from leaking via Referer when the admin navigates elsewhere.

Out of scope for this PR (intentionally deferred):

- CSP on bunyip-web admin pages broadly. Worth doing; separate PR.
- EXIF stripping. Privacy concern, not security; only matters if attachments are ever exposed to non-admin audiences.
- AV scanning. Overkill at this scale.

`just check-container` clean (modulo the pre-existing `test_config_defaults` parallel-env-var flake on `main`; 188 other tests pass; fmt + clippy + build clean).

#BUNYIP-90
YousifShkara deleted branch feat/feedback-attachment-security-hardening 2026-06-11 06:03:41 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
psa-systems/bunyip!112
No description provided.