1 //! `hu13 serve` (m4, f0rge issue #9): std-only static file server2 //! (`TcpListener` + thread-per-connection, no new deps) plus exactly two3 //! POST routes (`/inbox/issue`, `/inbox/pr-prompt`).4 //!5 //! Rules enforced here, fail closed:6 //! - `/data/inbox` is NEVER under www: startup refuses unless the inbox7 //! dir sits outside the served tree ([`ensure_inbox_outside_www`]).8 //! - Static: every response carries `nosniff`; directories never list9 //! (only real files serve; anything else is a generic `404` that echoes10 //! nothing); `..` / NUL / bad escapes never escape www.11 //! - POSTs: urlencoded-only (`multipart/*` → `400`), `Content-Length`12 //! required (absent/unparsable → drop the connection), whole body13 //! capped (`413`, default 32KB, tunable), 5s read timeout (stall →14 //! drop), strict UTF-8 (`400`), NUL → `400`, controls stripped except15 //! newline/tab, caps validated, license required (`400`), honeypot →16 //! fake `received` + zero files, per-IP token bucket (`429`), global17 //! quota (`503` until drained), `Origin` gate (`403`, curl with no18 //! Origin passes), fixed ~500ms delay before every inbox-route19 //! response (success and failure alike — no timing oracle), `received`20 //! page shows the ack uuid once with zero reflection. Wrong method →21 //! `405`.22 //! - Every inbox-route response carries `nosniff` + `no-store`; we set23 //! no cookies and send no CORS headers, so `SameSite=Lax` is24 //! vacuously satisfied (no `Set-Cookie` ever). Full-disk/IO failure →25 //! `503` (lose one POST, never the repo). Caps tunable via flags/env,26 //! no config.27 28 use std::io::{Read, Write};29 use std::net::{TcpListener, TcpStream};30 use std::path::{Path, PathBuf};31 use std::time::{Duration, Instant};32 33 use crate::inbox;34 35 /// Options for [`run`] (flags/env only, no config file).36 #[derive(Debug, Clone)]37 pub struct ServeOptions {38 /// Built site directory to serve.39 pub www: PathBuf,40 /// Inbox directory for POST storage (must sit outside `www`).41 pub inbox_dir: PathBuf,42 /// Listen address, e.g. `0.0.0.0:39313` (`127.0.0.1:0` in tests).43 pub listen: String,44 /// Max chars for `title` (`--max-title` / `HU13_MAX_TITLE`).45 pub max_title: usize,46 /// Max chars for `body`/`prompt` (`--max-body` / `HU13_MAX_BODY`).47 pub max_body: usize,48 /// Max whole POST body bytes (`--max-bytes` / `HU13_MAX_BYTES`).49 pub max_bytes: usize,50 /// Per-IP POSTs per hour (`--rate-per-hour` / `HU13_RATE_PER_HOUR`).51 pub rate_per_hour: u32,52 /// Global quota in files (`--quota` / `HU13_QUOTA`).53 pub quota: usize,54 }55 56 impl ServeOptions {57 /// Defaults matching [`crate::inbox`] constants (m4 core caps).58 #[must_use]59 pub fn with_defaults(www: PathBuf, inbox_dir: PathBuf, listen: String) -> Self {60 Self {61 www,62 inbox_dir,63 listen,64 max_title: inbox::MAX_TITLE_CHARS,65 max_body: inbox::MAX_BODY_CHARS,66 max_bytes: inbox::MAX_REQUEST_BYTES,67 rate_per_hour: inbox::RATE_PER_HOUR,68 quota: inbox::QUOTA_FILES,69 }70 }71 }72 73 /// Per-connection shared state (tuned caps + rate buckets + daily salt).74 /// Max concurrent connections. Each connection owns one thread and75 /// inbox answers sleep ~500ms by design, so an unbounded accept loop is76 /// a trivial thread-exhaustion DoS (f0rge #63). Over the cap: 503 now.77 pub const MAX_CONNS: usize = 64;78 79 /// Max concurrent `git upload-pack` runs (f0rge #64).80 pub const MAX_GIT_UPLOADS: usize = 4;81 82 /// Hard timeout for one `git upload-pack` run. Larger than the 10s83 /// metadata timeout: a clone legitimately streams the whole pack.84 pub const GIT_UPLOAD_TIMEOUT: Duration = Duration::from_secs(120);85 86 /// Total time allowed to read one request head (slowloris bound).87 pub const HEAD_DEADLINE: Duration = Duration::from_secs(10);88 89 struct Shared {90 www: PathBuf,91 inbox_dir: PathBuf,92 salt: String,93 limiter: inbox::RateLimiter,94 max_title: usize,95 max_body: usize,96 max_bytes: usize,97 quota: usize,98 /// Bare repo materialized from `www/repo.bundle` for git smart HTTP99 /// (`GET /repo.bundle/info/refs`, `POST /repo.bundle/git-upload-pack`),100 /// so `git clone http://host:port/repo.bundle` works (m6, issue #32).101 /// `None` when no bundle was present at startup (static-only mode).102 git_dir: Option<PathBuf>,103 /// In-flight upload-pack runs (bounded by [`MAX_GIT_UPLOADS`]).104 git_uploads: std::sync::Arc<std::sync::atomic::AtomicUsize>,105 }106 107 /// Materialize `www/repo.bundle` into a bare repo for smart HTTP.108 ///109 /// Runs `git clone --bare --quiet <bundle> <tmp>/repo.git` once at serve110 /// startup. Returns `None` when no bundle exists or git fails (serve111 /// continues static-only; `/repo.bundle` still downloads via GET).112 fn prepare_git_dir(www: &Path) -> Option<PathBuf> {113 // Static serving already rejects an outside symlink; smart HTTP must114 // use the same resolved-path check (f0rge #117). A symlink pointing115 // inside www is allowed, but a symlink to a private bundle is not.116 let www_root = resolve_existing(www);117 let bundle = resolve_existing(&www.join("repo.bundle"));118 if bundle == www_root || !bundle.starts_with(&www_root) || !bundle.is_file() {119 return None;120 }121 let dir = std::env::temp_dir().join(format!(122 "hu13-git-{}-{}",123 std::process::id(),124 std::time::SystemTime::now()125 .duration_since(std::time::UNIX_EPOCH)126 .map(|d| d.as_nanos())127 .unwrap_or(0)128 ));129 let git_dir = dir.join("repo.git");130 let st = std::process::Command::new("git")131 .arg("clone")132 .arg("--bare")133 .arg("--quiet")134 .arg(&bundle)135 .arg(&git_dir)136 .status();137 match st {138 Ok(s) if s.success() && git_dir.is_dir() => Some(git_dir),139 Ok(s) => {140 eprintln!("hu13: prepare git dir from bundle failed ({s}); smart HTTP disabled");141 None142 }143 Err(e) => {144 eprintln!("hu13: spawn git for smart HTTP: {e}; smart HTTP disabled");145 None146 }147 }148 }149 150 /// Lexically absolutize + normalize `p` (`.`/`..` resolved, no fs touch so151 /// missing paths still check correctly).152 fn absolutize(p: &Path) -> PathBuf {153 let abs = if p.is_absolute() {154 p.to_path_buf()155 } else {156 std::env::current_dir()157 .unwrap_or_else(|_| PathBuf::from("/"))158 .join(p)159 };160 let mut out = PathBuf::new();161 for comp in abs.components() {162 match comp {163 std::path::Component::ParentDir => {164 out.pop();165 }166 std::path::Component::CurDir => {}167 _ => out.push(comp.as_os_str()),168 }169 }170 if out.as_os_str().is_empty() {171 PathBuf::from("/")172 } else {173 out174 }175 }176 177 /// Resolve symlinks in the deepest existing ancestor of `p`, then append178 /// the non-existent suffix. Startup may run before the inbox dir exists.179 fn resolve_existing(p: &Path) -> PathBuf {180 let abs = absolutize(p);181 let mut suffix: Vec<std::ffi::OsString> = Vec::new();182 let mut cur = abs.as_path();183 loop {184 if let Ok(mut resolved) = std::fs::canonicalize(cur) {185 for name in suffix.iter().rev() {186 resolved.push(name);187 }188 return resolved;189 }190 let Some(name) = cur.file_name() else {191 return abs;192 };193 suffix.push(name.to_os_string());194 let Some(parent) = cur.parent() else {195 return abs;196 };197 cur = parent;198 }199 }200 201 /// Refuse to start when the inbox dir is inside (or equal to) www.202 /// Inbox files must never be reachable over HTTP. Resolve symlinks: a203 /// lexical-only check lets `--inbox-dir link-to-www/private` through.204 pub fn ensure_inbox_outside_www(www: &Path, inbox_dir: &Path) -> Result<(), String> {205 let www = resolve_existing(www);206 let inbox = resolve_existing(inbox_dir);207 if inbox == www || inbox.starts_with(&www) {208 return Err(format!(209 "hu13: refusing to start: inbox-dir {} is inside www {} (inbox must never be served)",210 inbox.display(),211 www.display()212 ));213 }214 Ok(())215 }216 217 /// Block forever serving `www` + the two POSTs. Returns only on fatal218 /// startup errors (bind failure etc.).219 pub fn run(opts: &ServeOptions) -> i32 {220 if let Err(e) = ensure_inbox_outside_www(&opts.www, &opts.inbox_dir) {221 eprintln!("{e}");222 return 1;223 }224 if !opts.www.is_dir() {225 eprintln!(226 "hu13: www dir {} missing (build first: hu13 build <repo> -o <dir>)",227 opts.www.display()228 );229 return 1;230 }231 #[cfg(unix)]232 {233 use std::os::unix::fs::DirBuilderExt;234 let mut b = std::fs::DirBuilder::new();235 b.recursive(true).mode(0o700);236 if let Err(e) = b.create(&opts.inbox_dir) {237 eprintln!("hu13: create inbox dir: {e}");238 return 1;239 }240 }241 #[cfg(not(unix))]242 {243 if let Err(e) = std::fs::create_dir_all(&opts.inbox_dir) {244 eprintln!("hu13: create inbox dir: {e}");245 return 1;246 }247 }248 // Re-check after creation: a dangling symlink may only reveal that its249 // target is inside www once `create_dir_all` materializes it.250 if let Err(e) = ensure_inbox_outside_www(&opts.www, &opts.inbox_dir) {251 eprintln!("{e}");252 return 1;253 }254 let listener = match TcpListener::bind(&opts.listen) {255 Ok(l) => l,256 Err(e) => {257 eprintln!("hu13: bind {}: {e}", opts.listen);258 return 1;259 }260 };261 let bound = listener262 .local_addr()263 .map(|a| a.to_string())264 .unwrap_or_else(|_| opts.listen.clone());265 // Integration tests spawn this binary with `--listen 127.0.0.1:0` and266 // parse this exact line to learn the port. Flush: piped stdout blocks.267 println!("hu13: listening on {bound}");268 use std::io::Write as _;269 let _ = std::io::stdout().flush();270 let shared = std::sync::Arc::new(Shared {271 www: opts.www.clone(),272 inbox_dir: opts.inbox_dir.clone(),273 salt: inbox::new_server_salt(),274 limiter: inbox::RateLimiter::new(opts.rate_per_hour),275 max_title: opts.max_title,276 max_body: opts.max_body,277 max_bytes: opts.max_bytes,278 quota: opts.quota,279 git_dir: prepare_git_dir(&opts.www),280 git_uploads: std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)),281 });282 let live = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));283 for conn in listener.incoming() {284 match conn {285 Ok(mut stream) => {286 use std::sync::atomic::Ordering;287 if live.fetch_add(1, Ordering::Relaxed) >= MAX_CONNS {288 live.fetch_sub(1, Ordering::Relaxed);289 reject_overloaded(&mut stream);290 continue;291 }292 let shared = std::sync::Arc::clone(&shared);293 let slot = ConnSlot(std::sync::Arc::clone(&live));294 std::thread::spawn(move || {295 let _slot = slot;296 handle_conn(stream, &shared);297 });298 }299 Err(e) => {300 eprintln!("hu13: accept: {e}");301 }302 }303 }304 eprintln!("hu13: listener closed");305 1306 }307 308 // ---- HTTP plumbing (tiny, no deps) -------------------------------------309 310 /// Decrements the live-connection counter on drop, whatever the thread311 /// does (panic included).312 struct ConnSlot(std::sync::Arc<std::sync::atomic::AtomicUsize>);313 314 impl Drop for ConnSlot {315 fn drop(&mut self) {316 self.0.fetch_sub(1, std::sync::atomic::Ordering::Relaxed);317 }318 }319 320 /// Answer 503 without spawning a thread (the pool is already full).321 fn reject_overloaded(stream: &mut TcpStream) {322 let _ = stream.set_write_timeout(Some(Duration::from_secs(2)));323 let body = b"overloaded324 ";325 let head = format!(326 "HTTP/1.1 503 Service Unavailable\r\nContent-Type: text/plain; charset=utf-8\r\nContent-Length: {}\r\nRetry-After: 1\r\nX-Content-Type-Options: nosniff\r\nConnection: close\r\n\r\n",327 body.len()328 );329 let _ = stream.write_all(head.as_bytes());330 let _ = stream.write_all(body);331 let _ = stream.flush();332 // Drain the request bytes we never read: closing with unread data333 // makes the kernel send RST, which can destroy the 503 the client334 // has not read yet. Bounded by a short timeout, no thread.335 let _ = stream.set_read_timeout(Some(Duration::from_millis(200)));336 let mut buf = [0u8; 4096];337 for _ in 0..4 {338 match stream.read(&mut buf) {339 Ok(0) | Err(_) => break,340 Ok(_) => {}341 }342 }343 }344 345 struct Head {346 method: String,347 target: String,348 headers: Vec<(String, String)>,349 }350 351 /// Read until `\r\n\r\n` (cap 16KiB of headers). `None` = stall/overflow/352 /// EOF → caller drops the connection.353 fn read_head(stream: &mut TcpStream) -> Option<(Head, Vec<u8>)> {354 let start = Instant::now();355 let mut buf: Vec<u8> = Vec::with_capacity(4096);356 let mut chunk = [0u8; 4096];357 loop {358 if buf.len() > 16 * 1024 || start.elapsed() > HEAD_DEADLINE {359 return None;360 }361 match stream.read(&mut chunk) {362 Ok(0) => return None,363 Ok(n) => {364 buf.extend_from_slice(&chunk[..n]);365 if let Some(pos) = find_double_crlf(&buf) {366 let rest = buf[pos + 4..].to_vec();367 let text = std::str::from_utf8(&buf[..pos]).ok()?;368 return parse_head(text).map(|h| (h, rest));369 }370 }371 Err(_) => return None,372 }373 }374 }375 376 fn find_double_crlf(buf: &[u8]) -> Option<usize> {377 buf.windows(4).position(|w| w == b"\r\n\r\n")378 }379 380 fn parse_head(text: &str) -> Option<Head> {381 let mut lines = text.split("\r\n");382 let request_line = lines.next()?;383 let mut parts = request_line.split_whitespace();384 let method = parts.next()?.to_string();385 let target = parts.next()?.to_string();386 if method.is_empty() || target.is_empty() || target.len() > 4096 {387 return None;388 }389 let mut headers = Vec::new();390 for line in lines {391 if line.is_empty() {392 continue;393 }394 let (k, v) = line.split_once(':')?;395 headers.push((k.trim().to_ascii_lowercase(), v.trim().to_string()));396 }397 Some(Head {398 method,399 target,400 headers,401 })402 }403 404 fn header<'a>(h: &'a Head, name: &str) -> Option<&'a str> {405 h.headers406 .iter()407 .find(|(k, _)| k == name)408 .map(|(_, v)| v.as_str())409 }410 411 fn reason(code: u16) -> &'static str {412 match code {413 200 => "OK",414 400 => "Bad Request",415 403 => "Forbidden",416 404 => "Not Found",417 405 => "Method Not Allowed",418 413 => "Content Too Large",419 429 => "Too Many Requests",420 503 => "Service Unavailable",421 _ => "Error",422 }423 }424 425 /// One response, always `nosniff` + `Connection: close`. Inbox-route426 /// responses add `no-store` via `extra`.427 fn respond(428 stream: &mut TcpStream,429 code: u16,430 content_type: &str,431 extra: &[(&str, &str)],432 body: &[u8],433 ) {434 let mut out = format!(435 "HTTP/1.1 {code} {}\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nX-Content-Type-Options: nosniff\r\nReferrer-Policy: strict-origin-when-cross-origin\r\nConnection: close\r\n",436 reason(code),437 body.len()438 );439 for (k, v) in extra {440 out.push_str(k);441 out.push_str(": ");442 out.push_str(v);443 out.push_str("\r\n");444 }445 out.push_str("\r\n");446 let _ = stream.write_all(out.as_bytes());447 let _ = stream.write_all(body);448 let _ = stream.flush();449 }450 451 fn static_error(stream: &mut TcpStream, code: u16) {452 let msg: &str = match code {453 404 => "not found",454 405 => "method not allowed",455 _ => "error",456 };457 // Error pages keep the top-level nav so a lost visitor can click out.458 let nav = "<a href=\"/\">code</a> · <a href=\"/commits.html\">commits</a> · \459 <a href=\"/issues/index.html\">issues</a> · \460 <a href=\"/pulls/index.html\">pulls</a> · \461 <a href=\"/tasks/index.html\">tasks</a> · \462 <a href=\"/ci/index.html\">ci</a> · \463 <a href=\"/releases/index.html\">releases</a> · \464 <a href=\"/wiki/index.html\">wiki</a> · \465 <a href=\"/agents.html\">agents</a>";466 let body = crate::layout::page(msg, nav, &format!("<h1>{msg}</h1>\n<p>Nothing here.</p>\n"));467 let extra = if code == 405 {468 &[("Allow", "GET, HEAD")][..]469 } else {470 &[][..]471 };472 respond(473 stream,474 code,475 "text/html; charset=utf-8",476 extra,477 body.as_bytes(),478 );479 }480 481 fn content_type_for(name: &str) -> &'static str {482 let lower = name.to_ascii_lowercase();483 if lower.ends_with(".html") {484 "text/html; charset=utf-8"485 } else if lower.ends_with(".css") {486 "text/css; charset=utf-8"487 } else if lower.ends_with(".svg") {488 "image/svg+xml"489 } else if lower.ends_with(".png") {490 "image/png"491 } else if lower.ends_with(".jpg") || lower.ends_with(".jpeg") {492 "image/jpeg"493 } else if lower.ends_with(".gif") {494 "image/gif"495 } else if lower.ends_with(".webp") {496 "image/webp"497 } else if lower.ends_with(".ico") {498 "image/x-icon"499 } else if lower.ends_with(".avif") {500 "image/avif"501 } else if lower.ends_with(".webmanifest") {502 "application/manifest+json"503 } else if lower.ends_with(".json") {504 "application/json"505 } else if lower.ends_with(".atom") {506 "application/atom+xml; charset=utf-8"507 } else if lower.ends_with(".md") || lower.ends_with(".txt") {508 "text/plain; charset=utf-8"509 } else if lower.ends_with(".git/head")510 || (lower.contains(".git/")511 && (lower.ends_with("/info/refs")512 || lower.ends_with("/info/packs")513 || lower.ends_with("/info/alternates")514 || lower.ends_with("/packed-refs")))515 {516 // Dumb-HTTP git files (#54): plain text so any static server517 // (Pages/nginx/serve) serves `repo.git` identically. Git ignores518 // mime for dumb reads, but explicit beats octet-stream guessing.519 "text/plain; charset=utf-8"520 } else if lower.ends_with(".gz") {521 "application/gzip"522 } else {523 "application/octet-stream"524 }525 }526 527 /// Extensions that browsers may render as an active document. Committed528 /// files are untrusted (a repo can contain `raw/evil.html`), so these are529 /// always downloaded, never rendered (f0rge #61).530 const RAW_ACTIVE_EXTS: &[&str] = &[531 "html", "htm", "shtml", "xhtml", "xht", "svg", "svgz", "xml", "xsl", "xslt", "mht", "mhtml",532 "pdf", "swf", "hta", "jar", "crx", "xpi",533 ];534 535 /// Text-ish extensions that keep a text MIME type. They still download:536 /// `_headers` sends `attachment` for every `/raw/*` path, so `serve` uses537 /// the same disposition instead of rendering text inline (f0rge #93).538 const RAW_TEXT_EXTS: &[&str] = &[539 "txt",540 "text",541 "md",542 "markdown",543 "rst",544 "adoc",545 "rs",546 "js",547 "mjs",548 "cjs",549 "jsx",550 "ts",551 "tsx",552 "json",553 "jsonl",554 "yaml",555 "yml",556 "toml",557 "ini",558 "cfg",559 "conf",560 "css",561 "scss",562 "less",563 "c",564 "h",565 "cc",566 "cpp",567 "hpp",568 "go",569 "py",570 "rb",571 "sh",572 "bash",573 "zsh",574 "fish",575 "lua",576 "sql",577 "log",578 "diff",579 "patch",580 "csv",581 "tsv",582 "atom",583 "rss",584 "lock",585 "gitignore",586 "gitattributes",587 "editorconfig",588 ];589 590 fn extension(name: &str) -> String {591 let base = name.rsplit('/').next().unwrap_or(name);592 match base.rsplit_once('.') {593 Some((stem, ext)) if !stem.is_empty() => ext.to_ascii_lowercase(),594 _ => String::new(),595 }596 }597 598 /// `(content_type, content_disposition)` for a `/raw/<path>` response.599 ///600 /// Never `text/html`/`image/svg+xml`: a hostile repo could otherwise run601 /// script in the site's origin (where the inbox endpoints live). Active602 /// types become an octet-stream download; text keeps `text/plain` but603 /// also downloads; everything downloads with `attachment` (f0rge #93).604 fn raw_content_type(name: &str) -> (&'static str, &'static str) {605 let ext = extension(name);606 if RAW_ACTIVE_EXTS.contains(&ext.as_str()) {607 ("application/octet-stream", "attachment")608 } else if RAW_TEXT_EXTS.contains(&ext.as_str()) {609 ("text/plain; charset=utf-8", "attachment")610 } else {611 ("application/octet-stream", "attachment")612 }613 }614 615 /// Percent-decode a URL path (`+` stays literal in paths). `None` = bad616 /// escape / NUL / non-UTF-8 → caller answers `400`/drops.617 fn decode_path(s: &str) -> Option<String> {618 let mut bytes: Vec<u8> = Vec::with_capacity(s.len());619 let mut it = s.as_bytes().iter();620 while let Some(&b) = it.next() {621 if b == b'%' {622 let hi = *it.next()?;623 let lo = *it.next()?;624 let hex = |c: u8| -> Option<u8> {625 match c {626 b'0'..=b'9' => Some(c - b'0'),627 b'a'..=b'f' => Some(c - b'a' + 10),628 b'A'..=b'F' => Some(c - b'A' + 10),629 _ => None,630 }631 };632 bytes.push((hex(hi)? << 4) | hex(lo)?);633 } else {634 bytes.push(b);635 }636 }637 if bytes.contains(&0) {638 return None;639 }640 String::from_utf8(bytes).ok()641 }642 643 /// Normalized authority (`host` + effective port) of an `Origin`/`Referer`644 /// URL (`scheme://host[:port][/...]`) or a `Host` header (`host[:port]`).645 /// Scheme defaults matter: `http` → 80, `https` → 443, and a bare `Host`646 /// on this plain-HTTP server → 80. `None` = unparsable/unsupported → fail647 /// closed when supplied by the browser.648 #[derive(Debug, PartialEq, Eq)]649 struct Authority {650 scheme: String,651 host: String,652 port: u16,653 }654 655 fn authority_of_url(value: &str, default_scheme: &str) -> Option<Authority> {656 let v = value.trim();657 if v.is_empty() || v.eq_ignore_ascii_case("null") {658 return None;659 }660 let (scheme, rest) = match v.split_once("://") {661 Some((scheme, rest)) => {662 let scheme = scheme.to_ascii_lowercase();663 if scheme != "http" && scheme != "https" {664 return None;665 }666 (scheme, rest)667 }668 // A bare `Host` has no scheme; use the request scheme (plain HTTP,669 // or `X-Forwarded-Proto` when behind a TLS proxy).670 None => (default_scheme.to_string(), v),671 };672 let hostport = rest.split('/').next().unwrap_or("");673 let hostport = hostport.split('@').next_back().unwrap_or("");674 if hostport.is_empty() {675 return None;676 }677 let (host, port) = if let Some(stripped) = hostport.strip_prefix('[') {678 let (host, after) = stripped.split_once(']')?;679 let port = if after.is_empty() {680 None681 } else {682 Some(after.strip_prefix(':')?)683 };684 (host, port)685 } else if hostport.matches(':').count() == 1 {686 let (host, port) = hostport.split_once(':')?;687 if port.is_empty() {688 return None;689 }690 (host, Some(port))691 } else {692 (hostport, None)693 };694 let host = host.trim().trim_matches('.').to_ascii_lowercase();695 if host.is_empty() {696 return None;697 }698 let port = match port {699 Some(port) => port.parse::<u16>().ok()?,700 None => match scheme.as_str() {701 "https" => 443,702 "http" => 80,703 _ => return None,704 },705 };706 Some(Authority { scheme, host, port })707 }708 709 /// Same-origin gate for the two POST routes: no `Origin` and no710 /// `Referer` (curl/agents) → allow; browser-supplied `Origin` (else711 /// `Referer`) must match the `Host` authority, including port → else712 /// `403`. Comparing only hostnames lets another local port forge a713 /// same-origin request (f0rge #101). Never echoes.714 #[must_use]715 fn origin_allowed(head: &Head) -> bool {716 let origin = header(head, "origin");717 let referer = header(head, "referer");718 let claimed = match origin {719 Some(o) if !o.trim().is_empty() => Some(o),720 _ => match referer {721 Some(r) if !r.trim().is_empty() => Some(r),722 _ => None,723 },724 };725 let Some(claimed) = claimed else {726 return true; // curl sends none727 };728 let scheme = header(head, "x-forwarded-proto")729 .and_then(|v| v.split(',').next())730 .map(str::trim)731 .map(str::to_ascii_lowercase)732 .filter(|v| v == "http" || v == "https")733 .unwrap_or_else(|| "http".to_string());734 let (Some(want), Some(got)) = (735 header(head, "host").and_then(|h| authority_of_url(h, &scheme)),736 authority_of_url(claimed, &scheme),737 ) else {738 return false; // unparsable Host or Origin → fail closed739 };740 want == got741 }742 743 fn handle_conn(mut stream: TcpStream, shared: &Shared) {744 let _ = stream.set_read_timeout(Some(Duration::from_secs(inbox::POST_READ_TIMEOUT_SECS)));745 let _ = stream.set_write_timeout(Some(Duration::from_secs(inbox::POST_READ_TIMEOUT_SECS)));746 let (head, rest) = match read_head(&mut stream) {747 Some(v) => v,748 None => return, // stall/overflow/EOF: drop, fail closed749 };750 let raw_path = head.target.split('?').next().unwrap_or("/");751 let path = match decode_path(raw_path) {752 Some(p) => p,753 None => {754 static_error(&mut stream, 400);755 return;756 }757 };758 if path == "/inbox/issue" || path == "/inbox/pr-prompt" {759 let kind = if path == "/inbox/issue" {760 inbox::InboxType::Issue761 } else {762 inbox::InboxType::PrPrompt763 };764 handle_inbox(&mut stream, &head, rest, kind, shared);765 return;766 }767 // Git smart HTTP for the bundle (m6, issue #32): `git clone768 // http://host:port/repo.bundle` speaks smart HTTP (`info/refs` +769 // `git-upload-pack`), not a raw bundle download. Serve it from a770 // bare repo materialized from `www/repo.bundle` at startup.771 if path == "/repo.bundle/info/refs" || path == "/repo.bundle/git-upload-pack" {772 handle_git_smart(&mut stream, &head, rest, &path, shared);773 return;774 }775 // Static: files only, never a listing (dirs → 404). Unknown paths776 // are 404 for ANY method (including POST); a known file with the777 // wrong method is 405. Fail closed, echo nothing.778 let rel = path.strip_prefix('/').unwrap_or(&path);779 if rel.contains('\0')780 || rel.split('/').any(|s| s == "..")781 || (path.ends_with('/') && path != "/")782 {783 static_error(&mut stream, 404);784 return;785 }786 let file_rel = if path == "/" {787 "index.html".to_string()788 } else {789 rel.to_string()790 };791 let www = resolve_existing(&shared.www);792 let dest = resolve_existing(&shared.www.join(&file_rel));793 if dest != www && !dest.starts_with(&www) {794 static_not_found(&mut stream, &head, shared);795 return;796 }797 // Stat, then stream. Never read a static file into memory: a raw798 // blob can be hundreds of MB and MAX_CONNS=64 (f0rge #80). The799 // method check stays after existence so an unknown POST is still800 // 404 (fail closed, no route discovery).801 let meta = match std::fs::metadata(&dest) {802 Ok(m) if m.is_file() => m,803 _ => {804 static_not_found(&mut stream, &head, shared);805 return;806 }807 };808 if head.method != "GET" && head.method != "HEAD" {809 static_error(&mut stream, 405);810 return;811 }812 // Classify by the resolved path, not the raw request string: a request813 // like `/releases//v1/evil.html` or `/./releases/...` maps to the same814 // file but would bypass a string-prefix check (f0rge #97).815 let served_rel = dest816 .strip_prefix(&www)817 .map(|p| p.to_string_lossy().replace('\\', "/"))818 .unwrap_or_else(|_| file_rel.clone());819 let is_raw = served_rel.starts_with("raw/");820 let is_artifact = crate::releases::is_artifact_path(&served_rel);821 let (ctype, disposition) = if is_raw || is_artifact {822 raw_content_type(&served_rel)823 } else {824 (content_type_for(&served_rel), "inline")825 };826 let extra: &[(&str, &str)] = if is_raw || is_artifact {827 &[("Content-Disposition", disposition)]828 } else {829 &[]830 };831 if head.method == "HEAD" {832 respond_file_head(&mut stream, 200, ctype, extra, meta.len());833 return;834 }835 respond_file(&mut stream, 200, ctype, extra, &dest, meta.len());836 }837 838 /// Read-and-discard in-flight request bytes (bounded) so early rejects839 /// get a clean close instead of a reset. Bounded by a 256KiB cap plus the840 /// 5s read timeout; liars just burn one thread briefly, then we answer.841 fn settle(stream: &mut TcpStream, rest_len: usize, len: Option<usize>) {842 let Some(total) = len else { return };843 let mut n = total.saturating_sub(rest_len).min(256 * 1024);844 let mut chunk = [0u8; 8192];845 while n > 0 {846 let want = n.min(chunk.len());847 match stream.read(&mut chunk[..want]) {848 Ok(0) => break,849 Ok(k) => n -= k,850 Err(_) => break,851 }852 }853 }854 855 fn content_length(head: &Head) -> Option<usize> {856 header(head, "content-length")?.trim().parse().ok()857 }858 859 /// Git smart HTTP for `/repo.bundle` (m6, issue #32).860 ///861 /// `git clone http://host:port/repo.bundle` never GETs the bundle bytes862 /// directly: `git-remote-http` first asks863 /// `GET /repo.bundle/info/refs?service=git-upload-pack`, then864 /// `POST /repo.bundle/git-upload-pack`. Answer both from the bare repo865 /// materialized at startup (`Shared::git_dir`); anything else (wrong866 /// method, missing bundle, git failure) is 404/405 with no echo.867 /// Bounded slot for one upload-pack run (f0rge #64).868 struct GitSlot(std::sync::Arc<std::sync::atomic::AtomicUsize>);869 870 impl GitSlot {871 fn acquire(shared: &Shared) -> Option<Self> {872 use std::sync::atomic::Ordering;873 let n = shared.git_uploads.fetch_add(1, Ordering::Relaxed);874 if n >= MAX_GIT_UPLOADS {875 shared.git_uploads.fetch_sub(1, Ordering::Relaxed);876 return None;877 }878 Some(Self(std::sync::Arc::clone(&shared.git_uploads)))879 }880 }881 882 impl Drop for GitSlot {883 fn drop(&mut self) {884 self.0.fetch_sub(1, std::sync::atomic::Ordering::Relaxed);885 }886 }887 888 /// Headers for a file response, without opening the file body.889 fn respond_file_head(890 stream: &mut TcpStream,891 code: u16,892 content_type: &str,893 extra: &[(&str, &str)],894 len: u64,895 ) {896 let mut out = format!(897 "HTTP/1.1 {code} {}\r\nContent-Type: {content_type}\r\nContent-Length: {len}\r\nX-Content-Type-Options: nosniff\r\nReferrer-Policy: strict-origin-when-cross-origin\r\nConnection: close\r\n",898 reason(code)899 );900 for (k, v) in extra {901 out.push_str(k);902 out.push_str(": ");903 out.push_str(v);904 out.push_str("\r\n");905 }906 out.push_str("\r\n");907 let _ = stream.write_all(out.as_bytes());908 let _ = stream.flush();909 }910 911 /// Serve the built `404.html` (same chrome as every page) with status912 /// 404. Falls back to the tiny in-binary stub when the file is absent.913 /// POSTs keep the old non-echoing stub (`f0rge #85`).914 fn static_not_found(stream: &mut TcpStream, head: &Head, shared: &Shared) {915 if head.method != "GET" && head.method != "HEAD" {916 static_error(stream, 404);917 return;918 }919 let www = resolve_existing(&shared.www);920 let dest = resolve_existing(&shared.www.join("404.html"));921 if dest == www || !dest.starts_with(&www) {922 static_error(stream, 404);923 return;924 }925 let Ok(meta) = std::fs::metadata(&dest) else {926 static_error(stream, 404);927 return;928 };929 if !meta.is_file() {930 static_error(stream, 404);931 return;932 }933 if head.method == "HEAD" {934 respond_file_head(stream, 404, "text/html; charset=utf-8", &[], meta.len());935 } else {936 respond_file(937 stream,938 404,939 "text/html; charset=utf-8",940 &[],941 &dest,942 meta.len(),943 );944 }945 }946 947 /// Stream a file body with `Content-Length` (no full-pack buffering).948 fn respond_file(949 stream: &mut TcpStream,950 code: u16,951 content_type: &str,952 extra: &[(&str, &str)],953 path: &Path,954 len: u64,955 ) {956 respond_file_head(stream, code, content_type, extra, len);957 if let Ok(mut f) = std::fs::File::open(path) {958 let _ = std::io::copy(&mut f, stream);959 }960 let _ = stream.flush();961 }962 963 fn handle_git_smart(964 stream: &mut TcpStream,965 head: &Head,966 rest: Vec<u8>,967 path: &str,968 shared: &Shared,969 ) {970 let Some(git_dir) = shared.git_dir.as_ref() else {971 static_error(stream, 404);972 return;973 };974 if path == "/repo.bundle/info/refs" {975 if head.method != "GET" {976 static_error(stream, 405);977 return;978 }979 let query = head.target.split('?').nth(1).unwrap_or("");980 if !query.contains("service=git-upload-pack") {981 static_error(stream, 404);982 return;983 }984 let out = std::process::Command::new("git")985 .arg("upload-pack")986 .arg("--stateless-rpc")987 .arg("--advertise-refs")988 .arg(git_dir)989 .output();990 match out {991 Ok(o) if o.status.success() => {992 let mut body = b"001e# service=git-upload-pack\n0000".to_vec();993 body.extend_from_slice(&o.stdout);994 respond(995 stream,996 200,997 "application/x-git-upload-pack-advertisement",998 &[("Cache-Control", "no-cache")],999 &body,1000 );1001 }1002 _ => static_error(stream, 404),1003 }1004 return;1005 }1006 // POST /repo.bundle/git-upload-pack1007 if head.method != "POST" {1008 settle(stream, rest.len(), content_length(head));1009 static_error(stream, 405);1010 return;1011 }1012 let len: usize = match header(head, "content-length") {1013 Some(v) => match v.trim().parse() {1014 Ok(n) => n,1015 Err(_) => return,1016 },1017 None => return,1018 };1019 // Wants are small; cap the request at 1MiB (fail closed).1020 const MAX_GIT_REQUEST: usize = 1024 * 1024;1021 if len > MAX_GIT_REQUEST {1022 settle(stream, rest.len(), Some(len));1023 static_error(stream, 413);1024 return;1025 }1026 let mut req_body = rest;1027 if req_body.len() > len {1028 req_body.truncate(len);1029 }1030 while req_body.len() < len {1031 let mut chunk = [0u8; 8192];1032 let want = (len - req_body.len()).min(chunk.len());1033 match stream.read(&mut chunk[..want]) {1034 Ok(0) => break,1035 Ok(n) => req_body.extend_from_slice(&chunk[..n]),1036 Err(_) => return,1037 }1038 }1039 if req_body.len() != len {1040 return;1041 }1042 let Some(_slot) = GitSlot::acquire(shared) else {1043 static_error(stream, 503);1044 return;1045 };1046 // Spool the pack to a temp file and stream it out: a big repo's pack1047 // used to sit in RAM in full (`wait_with_output`), and there was no1048 // deadline at all (f0rge #64).1049 let tmp = std::env::temp_dir().join(format!(1050 "hu13-pack-{}-{}",1051 std::process::id(),1052 std::time::SystemTime::now()1053 .duration_since(std::time::UNIX_EPOCH)1054 .map(|d| d.as_nanos())1055 .unwrap_or(0)1056 ));1057 let pack_file = match std::fs::File::create(&tmp) {1058 Ok(f) => f,1059 Err(_) => {1060 static_error(stream, 503);1061 return;1062 }1063 };1064 let mut child = match std::process::Command::new("git")1065 .arg("upload-pack")1066 .arg("--stateless-rpc")1067 .arg(git_dir)1068 .stdin(std::process::Stdio::piped())1069 .stdout(std::process::Stdio::from(pack_file))1070 .stderr(std::process::Stdio::null())1071 .spawn()1072 {1073 Ok(c) => c,1074 Err(_) => {1075 let _ = std::fs::remove_file(&tmp);1076 static_error(stream, 503);1077 return;1078 }1079 };1080 if let Some(mut stdin) = child.stdin.take() {1081 let _ = stdin.write_all(&req_body);1082 // Drop closes the pipe so upload-pack sees EOF.1083 }1084 let start = Instant::now();1085 let status = loop {1086 match child.try_wait() {1087 Ok(Some(st)) => break Some(st),1088 Ok(None) => {1089 if start.elapsed() > GIT_UPLOAD_TIMEOUT {1090 let _ = child.kill();1091 let _ = child.wait();1092 break None;1093 }1094 std::thread::sleep(Duration::from_millis(10));1095 }1096 Err(_) => break None,1097 }1098 };1099 let ok = status.is_some_and(|st| st.success());1100 if !ok {1101 let _ = std::fs::remove_file(&tmp);1102 static_error(stream, 503);1103 return;1104 }1105 let len = std::fs::metadata(&tmp).map(|m| m.len()).unwrap_or(0);1106 respond_file(1107 stream,1108 200,1109 "application/x-git-upload-pack-result",1110 &[("Cache-Control", "no-cache")],1111 &tmp,1112 len,1113 );1114 let _ = std::fs::remove_file(&tmp);1115 }1116 1117 #[allow(clippy::too_many_lines)]1118 fn handle_inbox(1119 stream: &mut TcpStream,1120 head: &Head,1121 rest: Vec<u8>,1122 kind: inbox::InboxType,1123 shared: &Shared,1124 ) {1125 // Inbox-route responses always carry `no-store` and always wait the1126 // fixed ack delay (success and failure alike — no timing oracle).1127 let answer = |stream: &mut TcpStream, code: u16, body: &str, extra: &[(&str, &str)]| {1128 std::thread::sleep(Duration::from_millis(inbox::ACK_DELAY_MS));1129 let mut merged: Vec<(&str, &str)> = vec![("Cache-Control", "no-store")];1130 merged.extend_from_slice(extra);1131 respond(1132 stream,1133 code,1134 "text/html; charset=utf-8",1135 &merged,1136 body.as_bytes(),1137 );1138 };1139 if head.method != "POST" {1140 settle(stream, rest.len(), content_length(head));1141 let body = inbox::inbox_error_page(1142 "method not allowed",1143 "method not allowed",1144 "This endpoint accepts POST only.",1145 );1146 answer(stream, 405, &body, &[("Allow", "POST")]);1147 return;1148 }1149 // Same-origin gate: browsers send Origin/Referer — mismatch fails1150 // closed; curl/agents send neither and pass. Generic page, no echo.1151 if !origin_allowed(head) {1152 settle(stream, rest.len(), content_length(head));1153 let body = inbox::inbox_error_page("forbidden", "forbidden", "Bad origin.");1154 answer(stream, 403, &body, &[]);1155 return;1156 }1157 let ctype = header(head, "content-type")1158 .unwrap_or("")1159 .to_ascii_lowercase();1160 if ctype.starts_with("multipart/") || !ctype.starts_with("application/x-www-form-urlencoded") {1161 settle(stream, rest.len(), content_length(head));1162 let body = inbox::inbox_error_page(1163 "bad request",1164 "bad request",1165 "This endpoint accepts urlencoded forms only.",1166 );1167 answer(stream, 400, &body, &[]);1168 return;1169 }1170 // No (parsable) Content-Length → drop the connection, no response.1171 let len: usize = match header(head, "content-length") {1172 Some(v) => match v.trim().parse() {1173 Ok(n) => n,1174 Err(_) => return,1175 },1176 None => return,1177 };1178 if len > shared.max_bytes {1179 settle(stream, rest.len(), Some(len));1180 let body = inbox::inbox_error_page("too large", "too large", "Submission too large.");1181 answer(stream, 413, &body, &[]);1182 return;1183 }1184 let mut body_bytes = rest;1185 if body_bytes.len() > len {1186 body_bytes.truncate(len);1187 }1188 while body_bytes.len() < len {1189 let mut chunk = [0u8; 8192];1190 let want = (len - body_bytes.len()).min(chunk.len());1191 match stream.read(&mut chunk[..want]) {1192 Ok(0) => break,1193 Ok(n) => body_bytes.extend_from_slice(&chunk[..n]),1194 Err(_) => return, // stall → drop1195 }1196 }1197 if body_bytes.len() != len {1198 return; // client went away early → drop1199 }1200 let text = match String::from_utf8(body_bytes) {1201 Ok(t) => t,1202 Err(_) => {1203 let body = inbox::inbox_error_page(1204 "bad request",1205 "bad request",1206 "Submissions must be valid UTF-8 text.",1207 );1208 answer(stream, 400, &body, &[]);1209 return;1210 }1211 };1212 if text.contains('\0') {1213 let body = inbox::inbox_error_page("bad request", "bad request", "Bad input.");1214 answer(stream, 400, &body, &[]);1215 return;1216 }1217 let pairs = match inbox::parse_form(&text) {1218 Some(p) => p,1219 None => {1220 let body = inbox::inbox_error_page("bad request", "bad request", "Bad input.");1221 answer(stream, 400, &body, &[]);1222 return;1223 }1224 };1225 match inbox::validate_form_with_limits(kind, &pairs, shared.max_title, shared.max_body) {1226 inbox::Validation::Honeypot => {1227 // Silent `received` with a fresh (unstored) uuid — no oracle.1228 let page = inbox::received_page(&inbox::new_uuid4());1229 answer(stream, 200, &page, &[]);1230 }1231 inbox::Validation::Reject(code) => {1232 let body = inbox::inbox_error_page("bad request", "bad request", "Bad input.");1233 answer(stream, code, &body, &[]);1234 }1235 inbox::Validation::Accept(v) => {1236 let ip = stream1237 .peer_addr()1238 .map(|a| a.ip().to_string())1239 .unwrap_or_else(|_| "unknown".to_string());1240 if !shared.limiter.check(&ip, &shared.salt) {1241 let body =1242 inbox::inbox_error_page("too many requests", "too many requests", "Slow down.");1243 answer(stream, 429, &body, &[]);1244 return;1245 }1246 if !inbox::check_quota_with(&shared.inbox_dir, shared.quota) {1247 let body =1248 inbox::inbox_error_page("unavailable", "unavailable", "Inbox full. Try later.");1249 answer(stream, 503, &body, &[]);1250 return;1251 }1252 match inbox::store_submission(&shared.inbox_dir, &v, &ip, &shared.salt) {1253 Ok(uuid) => {1254 let page = inbox::received_page(&uuid);1255 answer(stream, 200, &page, &[]);1256 }1257 Err(_) => {1258 // Full disk / IO failure: lose one POST, never the repo.1259 let body = inbox::inbox_error_page(1260 "unavailable",1261 "unavailable",1262 "Could not store that. Try later.",1263 );1264 answer(stream, 503, &body, &[]);1265 }1266 }1267 }1268 }1269 }1270 1271 #[cfg(test)]1272 mod tests {1273 use super::*;1274 1275 #[test]1276 fn inbox_inside_www_refused() {1277 let www = Path::new("/tmp/hu13-www");1278 assert!(ensure_inbox_outside_www(www, &www.join("inbox")).is_err());1279 assert!(ensure_inbox_outside_www(www, www).is_err());1280 assert!(ensure_inbox_outside_www(www, Path::new("/tmp/hu13-inbox")).is_ok());1281 // `..` tricks resolve before comparison.1282 assert!(ensure_inbox_outside_www(www, &www.join("sub/../inbox")).is_err());1283 assert!(ensure_inbox_outside_www(www, &www.join("../hu13-inbox")).is_ok());1284 }1285 1286 #[test]1287 fn content_types_sane() {1288 assert!(content_type_for("a.html").starts_with("text/html"));1289 assert!(content_type_for("s.css").starts_with("text/css"));1290 assert!(content_type_for("x.json").starts_with("application/json"));1291 assert!(content_type_for("f.atom").contains("atom"));1292 // m6 favicon (f0rge issue #16): svg icon + web app manifest.1293 assert_eq!(content_type_for("favicon.svg"), "image/svg+xml");1294 assert_eq!(content_type_for("og.png"), "image/png");1295 assert_eq!(content_type_for("photo.JPG"), "image/jpeg");1296 assert_eq!(content_type_for("icon.ico"), "image/x-icon");1297 assert_eq!(1298 content_type_for("manifest.webmanifest"),1299 "application/manifest+json"1300 );1301 assert_eq!(content_type_for("b.bundle"), "application/octet-stream");1302 }1303 1304 #[cfg(unix)]1305 #[test]1306 fn inbox_symlink_into_www_refused() {1307 use std::os::unix::fs::symlink;1308 let base = std::env::temp_dir().join(format!(1309 "hu13-inbox-symlink-{}-{}",1310 std::process::id(),1311 std::time::SystemTime::now()1312 .duration_since(std::time::UNIX_EPOCH)1313 .map(|d| d.as_nanos())1314 .unwrap_or(0)1315 ));1316 let www = base.join("www");1317 let private = www.join("private");1318 std::fs::create_dir_all(&private).unwrap();1319 let inbox = base.join("inbox-link");1320 symlink(&private, &inbox).unwrap();1321 assert!(ensure_inbox_outside_www(&www, &inbox).is_err());1322 1323 let parent = base.join("linked-parent");1324 symlink(&www, &parent).unwrap();1325 assert!(ensure_inbox_outside_www(&www, &parent.join("inbox")).is_err());1326 1327 let www_link = base.join("www-link");1328 symlink(&www, &www_link).unwrap();1329 assert!(ensure_inbox_outside_www(&www_link, &www.join("inbox")).is_err());1330 std::fs::remove_dir_all(&base).ok();1331 }1332 1333 #[test]1334 fn bad_paths_rejected() {1335 assert!(decode_path("/%2e%2e/x").is_some()); // decodes; `..` blocked later1336 assert!(decode_path("/a%00b").is_none(), "NUL rejected");1337 assert!(decode_path("/a%ZZ").is_none(), "bad escape rejected");1338 assert!(decode_path("/plain/path-1_~x").is_some());1339 }1340 1341 fn head_with(host: &str, origin: Option<&str>, referer: Option<&str>) -> Head {1342 let mut headers = vec![("host".to_string(), host.to_string())];1343 if let Some(o) = origin {1344 headers.push(("origin".to_string(), o.to_string()));1345 }1346 if let Some(r) = referer {1347 headers.push(("referer".to_string(), r.to_string()));1348 }1349 Head {1350 method: "POST".to_string(),1351 target: "/inbox/issue".to_string(),1352 headers,1353 }1354 }1355 1356 #[test]1357 fn origin_gate_allows_curl_blocks_forgery() {1358 assert!(origin_allowed(&head_with("127.0.0.1:1", None, None)));1359 assert!(origin_allowed(&head_with(1360 "127.0.0.1:39313",1361 Some("http://127.0.0.1:39313"),1362 None1363 )));1364 assert!(!origin_allowed(&head_with(1365 "127.0.0.1:39313",1366 Some("https://evil.example"),1367 None1368 )));1369 // Different port or scheme is a different origin (f0rge #101).1370 assert!(!origin_allowed(&head_with(1371 "127.0.0.1:39313",1372 Some("http://127.0.0.1:9999"),1373 None1374 )));1375 assert!(!origin_allowed(&head_with(1376 "127.0.0.1:39313",1377 Some("https://127.0.0.1:39313"),1378 None1379 )));1380 assert!(!origin_allowed(&head_with(1381 "a.example:443",1382 Some("https://a.example"),1383 None1384 )));1385 let mut proxied = head_with("a.example", Some("https://a.example"), None);1386 proxied1387 .headers1388 .push(("x-forwarded-proto".to_string(), "https".to_string()));1389 assert!(origin_allowed(&proxied), "TLS proxy can declare https");1390 assert!(!origin_allowed(&head_with(1391 "127.0.0.1:1",1392 Some("null"),1393 None1394 )));1395 // Referer fallback when Origin absent.1396 assert!(origin_allowed(&head_with(1397 "a.example",1398 None,1399 Some("http://a.example/page")1400 )));1401 assert!(!origin_allowed(&head_with(1402 "a.example",1403 None,1404 Some("http://b.example/page")1405 )));1406 }1407 }