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

@@ -17,8 +17,8 @@ use std::sync::Arc;
use async_trait::async_trait;
use picloud_shared::{
validate_files_collection, FileMeta, FileUpdate, FilesError, FilesListPage, FilesService,
NewFile, SdkCallCx, ServiceEvent, ServiceEventEmitter,
sanitize_stored_content_type, validate_files_collection, FileMeta, FileUpdate, FilesError,
FilesListPage, FilesService, NewFile, SdkCallCx, ServiceEvent, ServiceEventEmitter,
};
use uuid::Uuid;
@@ -124,11 +124,14 @@ impl FilesService for FilesServiceImpl {
&self,
cx: &SdkCallCx,
collection: &str,
new: NewFile,
mut new: NewFile,
) -> Result<Uuid, FilesError> {
validate_files_collection(collection)?;
self.check_write(cx).await?;
new.validate(self.max_file_size_bytes)?;
// Audit 2026-06-11 C-2 — coerce dangerous render types to
// application/octet-stream after the shape checks pass.
new.content_type = sanitize_stored_content_type(&new.content_type);
let meta = self.repo.create(cx.app_id, collection, new).await?;
self.emit(cx, "create", collection, &meta, None).await;
Ok(meta.id)
@@ -167,11 +170,15 @@ impl FilesService for FilesServiceImpl {
cx: &SdkCallCx,
collection: &str,
id: &str,
upd: FileUpdate,
mut upd: FileUpdate,
) -> Result<(), FilesError> {
validate_files_collection(collection)?;
self.check_write(cx).await?;
upd.validate(self.max_file_size_bytes)?;
// Audit 2026-06-11 C-2 — sanitize after the shape checks pass.
if let Some(ct) = upd.content_type.as_deref() {
upd.content_type = Some(sanitize_stored_content_type(ct));
}
let Some(uuid) = parse_id(id) else {
return Err(FilesError::NotFound);
};
@@ -479,6 +486,68 @@ mod tests {
}
}
#[tokio::test]
async fn dangerous_render_content_type_is_coerced_on_create() {
// Audit 2026-06-11 C-2 closure.
let files = svc();
let cx = anon_cx(AppId::new());
let id = files
.create(
&cx,
"c",
NewFile {
name: "evil.html".into(),
content_type: "text/html".into(),
data: b"<script>alert(1)</script>".to_vec(),
},
)
.await
.unwrap();
let meta = files
.head(&cx, "c", &id.to_string())
.await
.unwrap()
.unwrap();
assert_eq!(meta.content_type, "application/octet-stream");
}
#[tokio::test]
async fn dangerous_render_content_type_is_coerced_on_update() {
let files = svc();
let cx = anon_cx(AppId::new());
let id = files
.create(
&cx,
"c",
NewFile {
name: "a.bin".into(),
content_type: "application/octet-stream".into(),
data: b"x".to_vec(),
},
)
.await
.unwrap();
files
.update(
&cx,
"c",
&id.to_string(),
FileUpdate {
data: b"y".to_vec(),
name: None,
content_type: Some("image/svg+xml".into()),
},
)
.await
.unwrap();
let meta = files
.head(&cx, "c", &id.to_string())
.await
.unwrap()
.unwrap();
assert_eq!(meta.content_type, "application/octet-stream");
}
#[tokio::test]
async fn create_then_get_head_round_trips() {
let files = svc();