max / makenotwork
7 files changed,
+251 insertions,
-8 deletions
| @@ -59,3 +59,27 @@ pub fn internal_error() -> Response { | |||
| 59 | 59 | ) | |
| 60 | 60 | .into_response() | |
| 61 | 61 | } | |
| 62 | + | ||
| 63 | + | /// A fully static, template-free branded 500. Used only when the Askama render | |
| 64 | + | /// itself fails — falling back to [`internal_error`] there would just re-enter | |
| 65 | + | /// the template engine that's already broken. No interpolation, so it can never | |
| 66 | + | /// fail to produce output. | |
| 67 | + | pub fn internal_error_static() -> Response { | |
| 68 | + | const BODY: &str = "<!DOCTYPE html>\n\ | |
| 69 | + | <html lang=\"en\"><head><meta charset=\"utf-8\">\ | |
| 70 | + | <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\ | |
| 71 | + | <title>Something went wrong</title>\ | |
| 72 | + | <style>body{font-family:system-ui,-apple-system,sans-serif;background:#111;color:#eee;\ | |
| 73 | + | margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center}\ | |
| 74 | + | main{max-width:32rem;padding:2rem;text-align:center}.mark{font-size:2rem;opacity:.55}\ | |
| 75 | + | h1{font-weight:600;font-size:1.25rem;margin:.75rem 0}a{color:#9ad}</style></head>\ | |
| 76 | + | <body><main><div class=\"mark\">◆</div>\ | |
| 77 | + | <h1>Something went wrong</h1>\ | |
| 78 | + | <p>This page couldn't be rendered. The error has been logged. Try again, or return to the <a href=\"/\">forum home</a>.</p>\ | |
| 79 | + | </main></body></html>"; | |
| 80 | + | ( | |
| 81 | + | StatusCode::INTERNAL_SERVER_ERROR, | |
| 82 | + | axum::response::Html(BODY), | |
| 83 | + | ) | |
| 84 | + | .into_response() | |
| 85 | + | } |
| @@ -234,6 +234,70 @@ pub async fn fetch_og_metadata( | |||
| 234 | 234 | } | |
| 235 | 235 | } | |
| 236 | 236 | ||
| 237 | + | /// Normalize an external image response's `Content-Type` to one of the four | |
| 238 | + | /// formats we re-serve, or `None` to reject (the proxy default-denies anything | |
| 239 | + | /// that isn't a recognised image type, so it can't be turned into a relay for | |
| 240 | + | /// arbitrary content). | |
| 241 | + | fn allowed_image_content_type(ct: &str) -> Option<&'static str> { | |
| 242 | + | match ct.split(';').next().unwrap_or("").trim() { | |
| 243 | + | "image/png" => Some("image/png"), | |
| 244 | + | "image/jpeg" => Some("image/jpeg"), | |
| 245 | + | "image/gif" => Some("image/gif"), | |
| 246 | + | "image/webp" => Some("image/webp"), | |
| 247 | + | _ => None, | |
| 248 | + | } | |
| 249 | + | } | |
| 250 | + | ||
| 251 | + | /// Fetch an external image for the same-origin image proxy. | |
| 252 | + | /// | |
| 253 | + | /// Same SSRF guarantees as [`fetch_og_metadata`] (scheme/port pre-check + | |
| 254 | + | /// connect-time `SsrfSafeResolver` that refuses private addresses, even across | |
| 255 | + | /// redirects), capped at `MAX_BODY_SIZE` and a 5s timeout. Returns the bytes and | |
| 256 | + | /// a normalised image content-type, or `None` on any failure or a non-image | |
| 257 | + | /// response. Best-effort by design: the caller maps `None` to a 502. | |
| 258 | + | #[tracing::instrument(skip_all)] | |
| 259 | + | pub async fn fetch_image(http: &reqwest::Client, url: &str) -> Option<(Vec<u8>, &'static str)> { | |
| 260 | + | if !validate_url(url) { | |
| 261 | + | return None; | |
| 262 | + | } | |
| 263 | + | ||
| 264 | + | let resp = http | |
| 265 | + | .get(url) | |
| 266 | + | .timeout(std::time::Duration::from_secs(5)) | |
| 267 | + | .header("User-Agent", "Multithreaded/ImageProxy") | |
| 268 | + | .send() | |
| 269 | + | .await | |
| 270 | + | .ok()?; | |
| 271 | + | ||
| 272 | + | if !resp.status().is_success() { | |
| 273 | + | return None; | |
| 274 | + | } | |
| 275 | + | ||
| 276 | + | // Default-deny: only re-serve a recognised image content-type. | |
| 277 | + | let content_type = resp | |
| 278 | + | .headers() | |
| 279 | + | .get(CONTENT_TYPE) | |
| 280 | + | .and_then(|ct| ct.to_str().ok()) | |
| 281 | + | .and_then(allowed_image_content_type)?; | |
| 282 | + | ||
| 283 | + | // Read the body in chunks, hard-capped at MAX_BODY_SIZE (1 MB). | |
| 284 | + | let mut body = Vec::new(); | |
| 285 | + | let mut stream = resp; | |
| 286 | + | while body.len() < MAX_BODY_SIZE { | |
| 287 | + | let chunk = match stream.chunk().await.ok()? { | |
| 288 | + | Some(c) => c, | |
| 289 | + | None => break, | |
| 290 | + | }; | |
| 291 | + | let remaining = MAX_BODY_SIZE - body.len(); | |
| 292 | + | body.extend_from_slice(&chunk[..chunk.len().min(remaining)]); | |
| 293 | + | } | |
| 294 | + | ||
| 295 | + | if body.is_empty() { | |
| 296 | + | return None; | |
| 297 | + | } | |
| 298 | + | Some((body, content_type)) | |
| 299 | + | } | |
| 300 | + | ||
| 237 | 301 | /// Extract a `<meta property="..." content="...">` value from HTML. | |
| 238 | 302 | fn extract_og_meta(html: &str, property: &str) -> Option<String> { | |
| 239 | 303 | static OG_RE: std::sync::LazyLock<regex_lite::Regex> = std::sync::LazyLock::new(|| { | |
| @@ -289,6 +353,18 @@ mod tests { | |||
| 289 | 353 | } | |
| 290 | 354 | ||
| 291 | 355 | #[test] | |
| 356 | + | fn image_proxy_content_type_allowlist() { | |
| 357 | + | // Recognised image types pass (with parameters stripped); everything else | |
| 358 | + | // is refused so the proxy can't re-serve HTML/SVG/arbitrary content. | |
| 359 | + | assert_eq!(allowed_image_content_type("image/png"), Some("image/png")); | |
| 360 | + | assert_eq!(allowed_image_content_type("image/jpeg; charset=binary"), Some("image/jpeg")); | |
| 361 | + | assert_eq!(allowed_image_content_type("image/webp"), Some("image/webp")); | |
| 362 | + | assert_eq!(allowed_image_content_type("image/svg+xml"), None); | |
| 363 | + | assert_eq!(allowed_image_content_type("text/html"), None); | |
| 364 | + | assert_eq!(allowed_image_content_type(""), None); | |
| 365 | + | } | |
| 366 | + | ||
| 367 | + | #[test] | |
| 292 | 368 | fn extract_urls_skips_non_http() { | |
| 293 | 369 | let input = "[mail](mailto:a@b.com) [site](https://x.com)"; | |
| 294 | 370 | let urls = extract_urls(input); |
| @@ -38,9 +38,44 @@ pub(crate) fn render_markdown(input: &str) -> String { | |||
| 38 | 38 | /// the strict preset — raw HTML is still stripped, dangerous schemes filtered, | |
| 39 | 39 | /// links get `nofollow`. Use for Fan+ subscriber content. | |
| 40 | 40 | pub(crate) fn render_markdown_plus(input: &str) -> String { | |
| 41 | - | docengine::Renderer::strict() | |
| 41 | + | let html = docengine::Renderer::strict() | |
| 42 | 42 | .with_strip_images(false) | |
| 43 | - | .render(input) | |
| 43 | + | .render(input); | |
| 44 | + | proxy_external_images(&html) | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | /// Rewrite external `<img src="http(s)://…">` in rendered Fan+ HTML to load | |
| 48 | + | /// through the same-origin image proxy (`/img-proxy?u=…`), so the page CSP can | |
| 49 | + | /// stay `img-src 'self'` while still showing embedded external images. Uploaded | |
| 50 | + | /// images are served from same-origin `/uploads/…` (no scheme), so they don't | |
| 51 | + | /// match and aren't proxied. docengine has already attribute-escaped the URL, so | |
| 52 | + | /// `&` in a query string is unescaped back to `&` before percent-encoding. | |
| 53 | + | fn proxy_external_images(html: &str) -> String { | |
| 54 | + | static IMG_SRC_RE: std::sync::LazyLock<regex_lite::Regex> = std::sync::LazyLock::new(|| { | |
| 55 | + | regex_lite::Regex::new(r#"(<img\b[^>]*?\bsrc=")(https?://[^"]+)(")"#).unwrap() | |
| 56 | + | }); | |
| 57 | + | IMG_SRC_RE | |
| 58 | + | .replace_all(html, |caps: ®ex_lite::Captures| { | |
| 59 | + | let url = caps[2].replace("&", "&"); | |
| 60 | + | format!("{}/img-proxy?u={}{}", &caps[1], percent_encode_query(&url), &caps[3]) | |
| 61 | + | }) | |
| 62 | + | .into_owned() | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | /// RFC 3986 percent-encode a string for use as a URL query value. Keeps the | |
| 66 | + | /// unreserved set (`A-Za-z0-9-_.~`) and escapes everything else, so the proxied | |
| 67 | + | /// URL round-trips intact through axum's query decoder. | |
| 68 | + | fn percent_encode_query(s: &str) -> String { | |
| 69 | + | let mut out = String::with_capacity(s.len()); | |
| 70 | + | for &b in s.as_bytes() { | |
| 71 | + | match b { | |
| 72 | + | b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { | |
| 73 | + | out.push(b as char) | |
| 74 | + | } | |
| 75 | + | _ => out.push_str(&format!("%{b:02X}")), | |
| 76 | + | } | |
| 77 | + | } | |
| 78 | + | out | |
| 44 | 79 | } | |
| 45 | 80 | ||
| 46 | 81 | /// Render markdown to HTML, resolving `@mentions` to profile links for valid community members. | |
| @@ -515,6 +550,43 @@ pub(crate) fn check_community_state( | |||
| 515 | 550 | } | |
| 516 | 551 | ||
| 517 | 552 | #[cfg(test)] | |
| 553 | + | mod proxy_image_tests { | |
| 554 | + | use super::{percent_encode_query, proxy_external_images}; | |
| 555 | + | ||
| 556 | + | #[test] | |
| 557 | + | fn external_image_is_routed_through_proxy() { | |
| 558 | + | let html = r#"<p><img src="https://example.com/a.png" alt="x"></p>"#; | |
| 559 | + | let out = proxy_external_images(html); | |
| 560 | + | assert!( | |
| 561 | + | out.contains(r#"src="/img-proxy?u=https%3A%2F%2Fexample.com%2Fa.png""#), | |
| 562 | + | "got: {out}" | |
| 563 | + | ); | |
| 564 | + | } | |
| 565 | + | ||
| 566 | + | #[test] | |
| 567 | + | fn same_origin_upload_is_not_proxied() { | |
| 568 | + | let html = r#"<img src="/uploads/abc-123" alt="x">"#; | |
| 569 | + | assert_eq!(proxy_external_images(html), html, "relative uploads must be left alone"); | |
| 570 | + | } | |
| 571 | + | ||
| 572 | + | #[test] | |
| 573 | + | fn query_string_ampersands_round_trip() { | |
| 574 | + | // docengine attribute-escapes `&` to `&`; the proxied value must carry | |
| 575 | + | // the real `&` (percent-encoded), not the entity. | |
| 576 | + | let html = r#"<img src="https://x.test/i?w=1&h=2">"#; | |
| 577 | + | let out = proxy_external_images(html); | |
| 578 | + | assert!(out.contains("u=https%3A%2F%2Fx.test%2Fi%3Fw%3D1%26h%3D2"), "got: {out}"); | |
| 579 | + | assert!(!out.contains("&h=2"), "the entity must not leak into the query"); | |
| 580 | + | } | |
| 581 | + | ||
| 582 | + | #[test] | |
| 583 | + | fn percent_encode_keeps_unreserved() { | |
| 584 | + | assert_eq!(percent_encode_query("aZ0-_.~"), "aZ0-_.~"); | |
| 585 | + | assert_eq!(percent_encode_query("a/b?c=d&e"), "a%2Fb%3Fc%3Dd%26e"); | |
| 586 | + | } | |
| 587 | + | } | |
| 588 | + | ||
| 589 | + | #[cfg(test)] | |
| 518 | 590 | mod validation_tests { | |
| 519 | 591 | use super::*; | |
| 520 | 592 |
| @@ -170,6 +170,7 @@ pub fn forum_routes(state: AppState) -> Router { | |||
| 170 | 170 | ||
| 171 | 171 | let image_routes = Router::new() | |
| 172 | 172 | .route("/uploads/{id}", get(uploads::serve_image_handler)) | |
| 173 | + | .route("/img-proxy", get(uploads::image_proxy_handler)) | |
| 173 | 174 | .route_layer(GovernorLayer { | |
| 174 | 175 | config: image_rate_limit.clone(), | |
| 175 | 176 | }); |
| @@ -1,10 +1,13 @@ | |||
| 1 | 1 | //! Image upload and serving handlers. | |
| 2 | 2 | ||
| 3 | 3 | use axum::{ | |
| 4 | - | extract::{Multipart, Path}, | |
| 4 | + | body::Body, | |
| 5 | + | extract::{Multipart, Path, Query}, | |
| 5 | 6 | http::{StatusCode, header}, | |
| 6 | 7 | response::{IntoResponse, Response}, | |
| 7 | 8 | }; | |
| 9 | + | use serde::Deserialize; | |
| 10 | + | ||
| 8 | 11 | use mt_core::types::{ModAction, ModActor}; | |
| 9 | 12 | ||
| 10 | 13 | use crate::auth::MaybeUser; | |
| @@ -270,3 +273,53 @@ pub(super) async fn remove_image_handler( | |||
| 270 | 273 | ||
| 271 | 274 | Ok(StatusCode::OK) | |
| 272 | 275 | } | |
| 276 | + | ||
| 277 | + | /// Query for [`image_proxy_handler`]. | |
| 278 | + | #[derive(Deserialize)] | |
| 279 | + | pub(super) struct ImageProxyQuery { | |
| 280 | + | /// The external image URL to fetch (percent-encoded by the renderer). | |
| 281 | + | u: String, | |
| 282 | + | } | |
| 283 | + | ||
| 284 | + | /// GET /img-proxy?u=<url> — same-origin proxy for external Fan+ images. | |
| 285 | + | /// | |
| 286 | + | /// The Fan+ markdown renderer rewrites external `<img src="https://…">` to point | |
| 287 | + | /// here so the page CSP can stay `img-src 'self'` (M-UX1). This handler fetches | |
| 288 | + | /// the URL through the SSRF-safe link-preview client (private addresses refused | |
| 289 | + | /// at connect time, even across redirects), caps the body at 1 MB / 5 s, and | |
| 290 | + | /// re-serves only recognised image content-types. | |
| 291 | + | /// | |
| 292 | + | /// Login-gated so it can't be driven as an open image-fetch relay by anonymous | |
| 293 | + | /// clients, and it sits in the per-IP image rate-limit group. | |
| 294 | + | #[tracing::instrument(skip_all)] | |
| 295 | + | pub(super) async fn image_proxy_handler( | |
| 296 | + | axum::extract::State(state): axum::extract::State<AppState>, | |
| 297 | + | MaybeUser(session_user): MaybeUser, | |
| 298 | + | Query(query): Query<ImageProxyQuery>, | |
| 299 | + | ) -> Result<Response, Response> { | |
| 300 | + | // Authenticated members only — an anonymous open proxy is the abuse vector. | |
| 301 | + | let _user = session_user.ok_or_else(|| StatusCode::UNAUTHORIZED.into_response())?; | |
| 302 | + | ||
| 303 | + | let client = match &state.link_preview { | |
| 304 | + | crate::link_preview::LinkPreviewFetcher::Http(c) => c, | |
| 305 | + | crate::link_preview::LinkPreviewFetcher::Noop => { | |
| 306 | + | return Err(StatusCode::SERVICE_UNAVAILABLE.into_response()); | |
| 307 | + | } | |
| 308 | + | }; | |
| 309 | + | ||
| 310 | + | let (bytes, content_type) = crate::link_preview::fetch_image(client, &query.u) | |
| 311 | + | .await | |
| 312 | + | .ok_or_else(|| StatusCode::BAD_GATEWAY.into_response())?; | |
| 313 | + | ||
| 314 | + | // `nosniff` pins the browser to the validated image content-type, so even if | |
| 315 | + | // an upstream served image bytes under a benign type, it can't be reframed as | |
| 316 | + | // HTML/JS. 1 MB cap means buffering here is bounded. | |
| 317 | + | Ok(Response::builder() | |
| 318 | + | .status(StatusCode::OK) | |
| 319 | + | .header(header::CONTENT_TYPE, content_type) | |
| 320 | + | .header(header::CACHE_CONTROL, "private, max-age=86400") | |
| 321 | + | .header(header::X_CONTENT_TYPE_OPTIONS, "nosniff") | |
| 322 | + | .header(header::CONTENT_DISPOSITION, "inline") | |
| 323 | + | .body(Body::from(bytes)) | |
| 324 | + | .unwrap()) | |
| 325 | + | } |
| @@ -10,10 +10,7 @@ mod partials; | |||
| 10 | 10 | pub use public::*; | |
| 11 | 11 | ||
| 12 | 12 | use askama::Template; | |
| 13 | - | use axum::{ | |
| 14 | - | http::StatusCode, | |
| 15 | - | response::{Html, IntoResponse, Response}, | |
| 16 | - | }; | |
| 13 | + | use axum::response::{Html, IntoResponse, Response}; | |
| 17 | 14 | ||
| 18 | 15 | /// CSRF token passed to templates; None when no session store is configured. | |
| 19 | 16 | pub type CsrfTokenOption = Option<String>; | |
| @@ -24,7 +21,9 @@ fn render_template<T: Template>(template: T) -> Response { | |||
| 24 | 21 | Ok(html) => Html(html).into_response(), | |
| 25 | 22 | Err(err) => { | |
| 26 | 23 | tracing::error!(error = ?err, "template rendering error"); | |
| 27 | - | (StatusCode::INTERNAL_SERVER_ERROR, "Template error").into_response() | |
| 24 | + | // Static branded page, not the Error500 *template* — the engine that | |
| 25 | + | // just failed can't be trusted to render the fallback. | |
| 26 | + | crate::error_page::internal_error_static() | |
| 28 | 27 | } | |
| 29 | 28 | } | |
| 30 | 29 | } |
| @@ -36,3 +36,21 @@ async fn serve_image_invalid_uuid_returns_404() { | |||
| 36 | 36 | let resp = h.client.get("/uploads/not-a-uuid").await; | |
| 37 | 37 | assert_eq!(resp.status.as_u16(), 404, "Invalid UUID should return 404"); | |
| 38 | 38 | } | |
| 39 | + | ||
| 40 | + | /// The external-image proxy is login-gated: an anonymous request is rejected | |
| 41 | + | /// before any outbound fetch, so it can't be driven as an open relay (M-UX1). | |
| 42 | + | #[tokio::test] | |
| 43 | + | async fn image_proxy_requires_login() { | |
| 44 | + | let mut h = TestHarness::new().await; | |
| 45 | + | h.client.get("/").await; // establish a session, but stay logged out | |
| 46 | + | ||
| 47 | + | let resp = h | |
| 48 | + | .client | |
| 49 | + | .get("/img-proxy?u=https%3A%2F%2Fexample.com%2Fa.png") | |
| 50 | + | .await; | |
| 51 | + | assert_eq!( | |
| 52 | + | resp.status.as_u16(), | |
| 53 | + | 401, | |
| 54 | + | "anonymous image-proxy request must be rejected before any fetch" | |
| 55 | + | ); | |
| 56 | + | } |