Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
278 changes: 270 additions & 8 deletions pkgs/id/src/web/routes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -559,6 +559,82 @@ async fn view_handler(Path(name): Path<String>) -> impl IntoResponse {
Redirect::to(&redirect_url)
}

/// Content type a blob may be served with, and whether it may be
/// served inline (#908).
///
/// The type implied by the filename (via [`get_content_type`]) is kept
/// only when it is a passive type a browser cannot turn into script
/// execution in this origin when combined with the sandboxing CSP and
/// `nosniff` headers [`blob_handler`] always sends: images (including
/// SVG, which the CSP sandbox neutralises on top-level navigation),
/// video, audio, PDF, and plain/markdown text. Anything else — notably
/// `text/html`, CSS, JavaScript, JSON and XML — is downgraded to
/// `application/octet-stream` and marked non-inline so the caller gets
/// a download, never active content.
fn safe_blob_content_type(filename: &str) -> (&'static str, bool) {
let content_type = get_content_type(filename);
let base_type = content_type
.split(';')
.next()
.unwrap_or(content_type)
.trim();
let inline_safe = matches!(
base_type,
"image/png"
| "image/jpeg"
| "image/gif"
| "image/webp"
| "image/svg+xml"
| "image/x-icon"
| "image/bmp"
| "video/mp4"
| "video/webm"
| "video/ogg"
| "video/quicktime"
| "video/x-msvideo"
| "audio/mpeg"
| "audio/wav"
| "audio/ogg"
| "audio/flac"
| "audio/aac"
| "audio/mp4"
| "application/pdf"
| "text/plain"
| "text/markdown"
);
if inline_safe {
(content_type, true)
} else {
("application/octet-stream", false)
}
}

/// Reconcile an uploaded file's name with its validated content type
/// (#908): a name whose extension disagrees with the type the upload
/// was validated as gets that extension replaced (or appended when it
/// has none), so a stored name cannot masquerade as a different type —
/// e.g. a PNG uploaded as `evil.html` is stored as `evil.png`.
/// `image/jpeg` accepts both `jpg` and `jpeg`.
fn reconcile_upload_filename(name: &str, ext: &str) -> String {
let acceptable: &[&str] = if ext == "jpg" {
&["jpg", "jpeg"]
} else {
&[ext]
};
let path = std::path::Path::new(name);
let current = path
.extension()
.and_then(|e| e.to_str())
.map(str::to_lowercase);
if current.as_deref().is_some_and(|c| acceptable.contains(&c)) {
return name.to_owned();
}
let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or("file");
// Keep any directory-free parent prefix out: upload names are plain
// file names, and `file_stem` already drops the old extension.
format!("{stem}.{ext}")
}

/// Blob handler - serves raw file content with appropriate Content-Type.
///
/// Used for media files to render directly in browser (images, video, audio, PDF).
Expand All @@ -585,12 +661,29 @@ async fn blob_handler(
.unwrap_or_else(|| "file.bin".to_owned()),
};

let content_type = get_content_type(&filename);

Response::builder()
// #908: the filename — including the caller-supplied `?filename=`
// parameter — must never select an *active* content type. Only a
// fixed allowlist of passive media/text types is served inline;
// everything else (html, css, js, json, xml, unknown) is served as
// an attachment download. Every blob response also carries `nosniff`
// and a sandboxing CSP so even an allowlisted type that can carry
// script (SVG) cannot execute in this origin on a top-level visit,
// while `<img>` embedding of images keeps working.
let (content_type, inline) = safe_blob_content_type(&filename);

let mut builder = Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, content_type)
.header(header::CACHE_CONTROL, "public, max-age=31536000, immutable")
.header(header::X_CONTENT_TYPE_OPTIONS, "nosniff")
.header(
header::CONTENT_SECURITY_POLICY,
"default-src 'none'; sandbox",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Allow inline SVG styles while retaining script restrictions

For uploaded or synced SVGs that use <style> blocks or style attributes, this policy blocks their styling when the blob is opened directly, changing colors, hiding strokes, or making diagrams unreadable. With no explicit style-src, it falls back to default-src 'none' under the CSP specification. SVG remains an explicitly supported inline type, so preserve its non-executable styling, for example with style-src 'unsafe-inline', while retaining the sandbox and script restrictions. The new <svg/> response-header test cannot detect this rendering regression.

Useful? React with 👍 / 👎.

);
if !inline {
builder = builder.header(header::CONTENT_DISPOSITION, "attachment");
}
builder
.body(Body::from(bytes.to_vec()))
.unwrap_or_else(|_| {
(
Expand Down Expand Up @@ -1373,10 +1466,12 @@ async fn upload_handler(State(state): State<AppState>, mut multipart: Multipart)
.into_response();
}

// Get filename from the field, or generate one
// Get filename from the field, or generate one. A supplied name is
// reconciled with the validated content type (#908) so it cannot
// masquerade as a different type than the bytes were checked as.
let ext = mime_to_extension(&content_type);
let filename = match field.file_name().map(ToOwned::to_owned) {
Some(name) if !name.trim().is_empty() => name,
Some(name) if !name.trim().is_empty() => reconcile_upload_filename(&name, ext),
_ => {
let ts = SystemTime::now()
.duration_since(UNIX_EPOCH)
Expand Down Expand Up @@ -1484,7 +1579,10 @@ async fn upload_handler(State(state): State<AppState>, mut multipart: Multipart)
tracing::warn!("[routes] Failed to set content-type tag: {:#}", err);
}

let url = format!("/blob/{hash_str}?filename={filename}");
let url = format!(
"/blob/{hash_str}?filename={}",
urlencoding::encode(&filename)
);

Json(UploadResponse {
hash: hash_str,
Expand Down Expand Up @@ -1537,7 +1635,7 @@ async fn images_list_handler(State(state): State<AppState>) -> Response {
continue;
}

let url = format!("/blob/{hash_str}?filename={name}");
let url = format!("/blob/{hash_str}?filename={}", urlencoding::encode(&name));

images.push(ImageEntry {
hash: hash_str,
Expand Down Expand Up @@ -2203,11 +2301,15 @@ mod upload_router_tests {
}

fn multipart_body(file_bytes: &[u8]) -> Vec<u8> {
multipart_body_named("photo.png", "image/png", file_bytes)
}

fn multipart_body_named(filename: &str, content_type: &str, file_bytes: &[u8]) -> Vec<u8> {
let mut body = Vec::new();
body.extend_from_slice(
format!(
"--{BOUNDARY}\r\nContent-Disposition: form-data; name=\"file\"; \
filename=\"photo.png\"\r\nContent-Type: image/png\r\n\r\n"
filename=\"{filename}\"\r\nContent-Type: {content_type}\r\n\r\n"
)
.as_bytes(),
);
Expand All @@ -2216,6 +2318,36 @@ mod upload_router_tests {
body
}

async fn get_blob(router: Router, uri: &str) -> (StatusCode, axum::http::HeaderMap, Vec<u8>) {
let response = router
.oneshot(
Request::builder()
.method("GET")
.uri(uri)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let status = response.status();
let headers = response.headers().clone();
let bytes = to_bytes(response.into_body(), usize::MAX).await.unwrap();
(status, headers, bytes.to_vec())
}

fn header_str<'a>(headers: &'a axum::http::HeaderMap, name: &str) -> Option<&'a str> {
headers.get(name).and_then(|v| v.to_str().ok())
}

/// Extract the blob hash from an upload response's JSON body.
fn uploaded_hash(upload_body: &str) -> String {
let start = upload_body.find("/blob/").unwrap() + "/blob/".len();
let rest = &upload_body[start..];
rest.chars()
.take_while(|c| *c != '?' && *c != '"')
.collect()
}

async fn upload(router: Router, body: Vec<u8>) -> (StatusCode, String) {
let response = router
.oneshot(
Expand Down Expand Up @@ -2282,6 +2414,136 @@ mod upload_router_tests {
let (status, _body) = upload(router, b"garbage without any boundary".to_vec()).await;
assert_eq!(status, StatusCode::BAD_REQUEST);
}

// #908: a caller-supplied `?filename=evil.html` must not turn a PNG
// blob into same-origin HTML. The blob is downgraded to a download.
#[tokio::test]
async fn blob_html_filename_param_is_not_served_as_html() {
let (router, _dir, _endpoint, _docs) = router().await;
let (status, body) = upload(router.clone(), multipart_body(b"png-bytes")).await;
assert_eq!(status, StatusCode::OK, "body: {body}");
let hash = uploaded_hash(&body);
let (status, headers, bytes) =
get_blob(router, &format!("/blob/{hash}?filename=evil.html")).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(bytes, b"png-bytes");
assert_eq!(
header_str(&headers, "content-type"),
Some("application/octet-stream")
);
assert_eq!(
header_str(&headers, "content-disposition"),
Some("attachment")
);
assert_eq!(
header_str(&headers, "x-content-type-options"),
Some("nosniff")
);
let csp = header_str(&headers, "content-security-policy").unwrap_or("");
assert!(csp.contains("sandbox"), "csp: {csp}");
}

// #908: a real PNG is still served inline as image/png (the editor
// embeds the returned URL as an image src), with protective headers.
#[tokio::test]
async fn blob_png_is_served_inline_with_protective_headers() {
let (router, _dir, _endpoint, _docs) = router().await;
let (status, body) = upload(router.clone(), multipart_body(b"png-bytes")).await;
assert_eq!(status, StatusCode::OK, "body: {body}");
let hash = uploaded_hash(&body);
let (status, headers, _bytes) =
get_blob(router, &format!("/blob/{hash}?filename=photo.png")).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(header_str(&headers, "content-type"), Some("image/png"));
assert!(headers.get("content-disposition").is_none());
assert_eq!(
header_str(&headers, "x-content-type-options"),
Some("nosniff")
);
let csp = header_str(&headers, "content-security-policy").unwrap_or("");
assert!(csp.contains("sandbox"), "csp: {csp}");
}

// #908: an SVG (which can carry script) keeps its image type for
// embedding but is served with the sandboxing CSP + nosniff so a
// top-level visit cannot execute it in this origin.
#[tokio::test]
async fn blob_svg_is_served_with_protective_headers() {
let (router, _dir, _endpoint, _docs) = router().await;
let (status, body) = upload(
router.clone(),
multipart_body_named("icon.svg", "image/svg+xml", b"<svg/>"),
)
.await;
assert_eq!(status, StatusCode::OK, "body: {body}");
let hash = uploaded_hash(&body);
let (status, headers, _bytes) =
get_blob(router, &format!("/blob/{hash}?filename=icon.svg")).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(header_str(&headers, "content-type"), Some("image/svg+xml"));
assert_eq!(
header_str(&headers, "x-content-type-options"),
Some("nosniff")
);
let csp = header_str(&headers, "content-security-policy").unwrap_or("");
assert!(csp.contains("default-src 'none'"), "csp: {csp}");
assert!(csp.contains("sandbox"), "csp: {csp}");
}

// #908: an upload whose name disagrees with its validated content
// type is stored under the reconciled name, and the returned URL
// carries that name (percent-encoded by construction here).
#[tokio::test]
async fn upload_name_masquerading_as_other_type_is_reconciled() {
let (router, _dir, _endpoint, _docs) = router().await;
let (status, body) = upload(
router,
multipart_body_named("evil.html", "image/png", b"png-bytes"),
)
.await;
assert_eq!(status, StatusCode::OK, "body: {body}");
assert!(body.contains("\"name\":\"evil.png\""), "body: {body}");
assert!(body.contains("filename=evil.png"), "body: {body}");
}

#[test]
fn safe_blob_content_type_allowlists_passive_types_only() {
assert_eq!(safe_blob_content_type("a.png"), ("image/png", true));
assert_eq!(safe_blob_content_type("a.svg"), ("image/svg+xml", true));
assert_eq!(safe_blob_content_type("a.pdf"), ("application/pdf", true));
assert_eq!(
safe_blob_content_type("evil.html"),
("application/octet-stream", false)
);
assert_eq!(
safe_blob_content_type("evil.HTML"),
("application/octet-stream", false)
);
assert_eq!(
safe_blob_content_type("x.js"),
("application/octet-stream", false)
);
assert_eq!(
safe_blob_content_type("x.json"),
("application/octet-stream", false)
);
assert_eq!(
safe_blob_content_type("noext"),
("application/octet-stream", false)
);
}

#[test]
fn reconcile_upload_filename_matches_validated_type() {
assert_eq!(reconcile_upload_filename("evil.html", "png"), "evil.png");
assert_eq!(reconcile_upload_filename("photo.png", "png"), "photo.png");
assert_eq!(reconcile_upload_filename("photo.jpeg", "jpg"), "photo.jpeg");
assert_eq!(reconcile_upload_filename("photo", "webp"), "photo.webp");
assert_eq!(
reconcile_upload_filename("archive.tar.gz", "gif"),
"archive.tar.gif"
);
}
}

// =============================================================================
Expand Down
Loading