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:
@@ -123,10 +123,20 @@ async fn list_files(
|
||||
}
|
||||
|
||||
/// `GET /apps/{id}/files/{collection}/{file_id}` — stream the file's
|
||||
/// bytes inline. The dashboard's Download button hits this; the audit
|
||||
/// flagged the missing handler (callers were 405'ing). Metadata gives
|
||||
/// the Content-Type + Content-Disposition; bytes come from
|
||||
/// `FilesRepo::get` (which checksum-verifies on read).
|
||||
/// bytes as a download. The dashboard's Download button hits this;
|
||||
/// metadata is read via `FilesRepo::head`, bytes via `FilesRepo::get`
|
||||
/// (which checksum-verifies on read).
|
||||
///
|
||||
/// Audit 2026-06-11 C-2 — response hardening. The previous handler
|
||||
/// served `Content-Disposition: inline` with the user-supplied
|
||||
/// `Content-Type` and no `nosniff` / CSP, so an SVG or HTML upload
|
||||
/// rendered same-origin and hijacked the admin session. Now:
|
||||
/// * `Content-Disposition: attachment` always.
|
||||
/// * `Content-Type` re-sanitized to the storage allowlist as
|
||||
/// belt-and-suspenders (`files_service::create` / `update` already
|
||||
/// coerce at write, but older rows may pre-date that change).
|
||||
/// * `X-Content-Type-Options: nosniff` blocks MIME-sniffing fallbacks.
|
||||
/// * Restrictive CSP scopes any residual rendering attempt.
|
||||
async fn get_file(
|
||||
State(s): State<FilesAdminState>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
@@ -150,16 +160,23 @@ async fn get_file(
|
||||
.get(app_id, &collection, id)
|
||||
.await?
|
||||
.ok_or(FilesApiError::NotFound)?;
|
||||
let safe_ct = picloud_shared::sanitize_stored_content_type(&meta.content_type);
|
||||
let disposition = format!(
|
||||
"inline; filename=\"{}\"",
|
||||
"attachment; filename=\"{}\"",
|
||||
meta.name.replace('"', "").replace(['\r', '\n'], "")
|
||||
);
|
||||
let len = bytes.len();
|
||||
Ok(Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header(CONTENT_TYPE, meta.content_type)
|
||||
.header(CONTENT_TYPE, safe_ct)
|
||||
.header(CONTENT_DISPOSITION, disposition)
|
||||
.header(CONTENT_LENGTH, len)
|
||||
.header("X-Content-Type-Options", "nosniff")
|
||||
.header(
|
||||
"Content-Security-Policy",
|
||||
"default-src 'none'; sandbox; frame-ancestors 'none'",
|
||||
)
|
||||
.header("Referrer-Policy", "no-referrer")
|
||||
.body(Body::from(bytes))
|
||||
.expect("build files download response"))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user