fix(audit-2026-06-11/C-2): file download attachment + nosniff + CSP + MIME allowlist

Stored XSS: the previous get_file handler streamed user-supplied bytes
with Content-Disposition: inline, the user-supplied Content-Type, and
no X-Content-Type-Options / CSP. A Rhai script could store an SVG or
HTML payload whose download URL rendered same-origin under the admin
session cookie.

Closes the response side and the storage side:

* shared::sanitize_stored_content_type: allowlist
  (octet-stream/pdf/json/text-plain/text-csv/image-non-svg/audio/video)
  with anything else coerced to application/octet-stream. New unit tests
  cover the safe/unsafe/case-insensitive/parameter-preserving paths.

* files_service::create/update: sanitize the stored content_type after
  the shape checks pass (sanitize-after-validate keeps the existing
  MissingField / TooLong errors intact). Two new tests confirm text/html
  and image/svg+xml are coerced to application/octet-stream on
  create/update respectively.

* files_api::get_file (admin download):
  - Content-Disposition: attachment (was inline)
  - Content-Type re-sanitized via the shared helper as belt-and-
    suspenders for any pre-existing row that pre-dates this change.
  - X-Content-Type-Options: nosniff
  - Content-Security-Policy: default-src 'none'; sandbox;
    frame-ancestors 'none'
  - Referrer-Policy: no-referrer

Audit ref: security_audit/07_http_cors_csrf_xss.md#c07-02 (response side)
+ security_audit/06_files_pathtraversal.md#f-fs-001 (storage side).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-06-11 20:24:55 +02:00
parent b096ea9c4e
commit fde4796479
4 changed files with 209 additions and 12 deletions

View File

@@ -247,6 +247,52 @@ impl FileUpdate {
}
}
/// Content type used in place of any upload whose declared type would
/// render dangerously in a browser. See [`sanitize_stored_content_type`].
pub const SAFE_RENDER_FALLBACK: &str = "application/octet-stream";
/// Coerce a script-supplied `content_type` to a safe value for storage.
///
/// Audit 2026-06-11 C-2 — storage-side defense. The download response
/// also forces `Content-Disposition: attachment`, `X-Content-Type-Options:
/// nosniff`, and a restrictive CSP (see `manager-core::files_api::get_file`),
/// so this is belt-and-suspenders: even if a future code path serves a
/// file with `inline` again, the stored type can't be `text/html` /
/// `image/svg+xml` / `application/javascript` etc.
///
/// Allowlist (the audit's recommended set):
/// - `application/octet-stream`, `application/pdf`, `application/json`
/// - `text/plain`, `text/csv`
/// - `image/*` (except `image/svg+xml`)
/// - `audio/*`, `video/*`
///
/// Anything else returns `application/octet-stream`. Parameters
/// (`; charset=…`) are preserved when the base type is on the allowlist.
#[must_use]
pub fn sanitize_stored_content_type(ct: &str) -> String {
let base = ct
.split(';')
.next()
.unwrap_or("")
.trim()
.to_ascii_lowercase();
let in_allowlist = matches!(
base.as_str(),
"application/octet-stream"
| "application/pdf"
| "application/json"
| "text/plain"
| "text/csv"
) || (base.starts_with("image/") && !base.starts_with("image/svg"))
|| base.starts_with("audio/")
|| base.starts_with("video/");
if in_allowlist {
ct.to_string()
} else {
SAFE_RENDER_FALLBACK.to_string()
}
}
/// Reject a collection name that is empty or could escape the per-app
/// files tree. UUID-shaped ids never produce traversal paths, but
/// collection names come from scripts so they're validated defensively
@@ -337,3 +383,67 @@ impl FilesService for NoopFilesService {
Err(FilesError::Backend("files is not wired in".into()))
}
}
#[cfg(test)]
mod content_type_sanitizer_tests {
use super::*;
#[test]
fn allowlist_passes_safe_types() {
for ct in [
"application/octet-stream",
"application/pdf",
"application/json",
"text/plain",
"text/csv",
"image/png",
"image/jpeg",
"image/webp",
"audio/mpeg",
"video/mp4",
] {
assert_eq!(sanitize_stored_content_type(ct), ct, "{ct} should pass");
}
}
#[test]
fn dangerous_render_types_are_coerced() {
for ct in [
"text/html",
"text/html; charset=utf-8",
"image/svg+xml",
"image/svg",
"application/xhtml+xml",
"application/javascript",
"text/javascript",
"application/ecmascript",
"application/x-shockwave-flash",
"text/xml",
"application/xml",
] {
assert_eq!(
sanitize_stored_content_type(ct),
SAFE_RENDER_FALLBACK,
"{ct} should coerce"
);
}
}
#[test]
fn case_and_whitespace_insensitive() {
assert_eq!(
sanitize_stored_content_type(" TEXT/HTML "),
SAFE_RENDER_FALLBACK
);
assert_eq!(
sanitize_stored_content_type("IMAGE/Svg+XML"),
SAFE_RENDER_FALLBACK
);
}
#[test]
fn parameters_are_preserved_for_safe_types() {
let ct = "text/plain; charset=utf-8";
assert_eq!(sanitize_stored_content_type(ct), ct);
}
}

View File

@@ -50,8 +50,9 @@ pub use events::{EmitError, NoopEventEmitter, ServiceEvent, ServiceEventEmitter}
pub use exec_summary::ExecResponseSummary;
pub use execution_log::{ExecutionLog, ExecutionStatus};
pub use files::{
validate_collection as validate_files_collection, FileMeta, FileUpdate, FilesError,
FilesListPage, FilesService, NewFile, NoopFilesService,
sanitize_stored_content_type, validate_collection as validate_files_collection, FileMeta,
FileUpdate, FilesError, FilesListPage, FilesService, NewFile, NoopFilesService,
SAFE_RENDER_FALLBACK,
};
pub use http::{HttpError, HttpRequest, HttpResponse, HttpService, NoopHttpService};
pub use ids::{