From d354a7687886d4701053479d0818913799311878 Mon Sep 17 00:00:00 2001 From: Paul C Date: Tue, 2 Jun 2026 07:03:50 +0100 Subject: [PATCH 1/4] Fix wolfserve static-file 404 on percent-encoded paths; add CI build MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit wolfserve joined the raw, percent-encoded request path to the document root, so any file whose name contains a space (or other encoded byte) was looked up verbatim ("uploads/Luton%204.jpg") and 404'd — surfacing as broken images in the app. Percent-decode the path before all filesystem access, and run the ".." traversal guard on the decoded form so an encoded "%2e%2e" cannot slip through. Adds unit tests; the fix is runtime-verified (spaced file now 200, encoded traversal still 403). Also adds .github/workflows/build.yml so GitHub compiles the release binary (ubuntu-22.04 / glibc 2.35 for deploy-host compatibility), uploads it as an artifact, and attaches it to Releases on v* tags. Co-Authored-By: CodeWolf Co-Authored-By: Wolf Software Systems Ltd --- .github/workflows/build.yml | 76 +++++++++++++++++++++++++++++++++++++ Cargo.lock | 1 + Cargo.toml | 1 + src/main.rs | 53 ++++++++++++++++++++++++-- 4 files changed, 127 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/build.yml diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..740da28 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,76 @@ +name: build + +# Compile the wolfserve release binary in CI so deployments no longer depend on a +# developer's local toolchain (which caused GLIBC-too-new mismatches against the servers). +on: + push: + branches: [main] + tags: ['v*'] + pull_request: + branches: [main] + workflow_dispatch: + +permissions: + contents: write # needed to attach binaries to a Release on tag builds + +jobs: + build: + # IMPORTANT: build on the OLDEST supported runner so the glibc-linked binary runs on the + # deployment hosts. ubuntu-22.04 ships glibc 2.35; the targets are glibc 2.36 and 2.41, which + # both satisfy the ">= 2.35" floor (glibc is backward-compatible). Do NOT switch to + # ubuntu-latest (24.04 / glibc 2.39) — that binary would FAIL to start on the glibc-2.36 + # test/live host with "version `GLIBC_2.39' not found". + runs-on: ubuntu-22.04 + + steps: + - uses: actions/checkout@v4 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + + - name: Cache cargo registry and target + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: ${{ runner.os }}-cargo-${{ hashFiles('Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo- + + # rustls' default backend (aws-lc-rs / aws-lc-sys) builds C sources via CMake. + - name: Install C build tooling for aws-lc-sys + run: sudo apt-get update && sudo apt-get install -y --no-install-recommends cmake clang build-essential + + - name: Run tests + run: cargo test --locked + + - name: Build release binary + run: cargo build --release --locked --bin wolfserve + + - name: Package artifact + run: | + mkdir -p dist + cp target/release/wolfserve dist/wolfserve + strip dist/wolfserve || true + tar -czf wolfserve-x86_64-linux-gnu.tar.gz -C dist wolfserve + echo "Built against:"; ldd --version | head -1 + file dist/wolfserve + + - name: Upload build artifact + uses: actions/upload-artifact@v4 + with: + name: wolfserve-x86_64-linux-gnu + path: | + dist/wolfserve + wolfserve-x86_64-linux-gnu.tar.gz + if-no-files-found: error + + - name: Publish release (tag builds only) + if: startsWith(github.ref, 'refs/tags/v') + uses: softprops/action-gh-release@v2 + with: + files: | + dist/wolfserve + wolfserve-x86_64-linux-gnu.tar.gz diff --git a/Cargo.lock b/Cargo.lock index 3f458d3..21f3521 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1819,6 +1819,7 @@ dependencies = [ "hyper-util", "mime_guess", "parking_lot", + "percent-encoding", "regex", "rustls", "rustls-pemfile", diff --git a/Cargo.toml b/Cargo.toml index 9a55140..882a2ae 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -33,6 +33,7 @@ rustls-pemfile = "2" futures-util = "0.3" hyper-util = { version = "0.1.19", features = ["full"] } regex = "1" +percent-encoding = "2" bcrypt = "0.15" base64 = "0.22" chrono = { version = "0.4", features = ["serde"] } diff --git a/src/main.rs b/src/main.rs index 147898c..8620561 100644 --- a/src/main.rs +++ b/src/main.rs @@ -26,6 +26,7 @@ use std::process::Stdio; use tokio::io::AsyncWriteExt; use tower_http::compression::CompressionLayer; use chrono::Utc; +use percent_encoding::percent_decode_str; mod apache; mod admin; @@ -371,9 +372,21 @@ config_dir = "/etc/apache2" } +/// Percent-decode a URL path into its on-disk form (e.g. "%20" -> " "). Browsers percent-encode +/// reserved characters in the request line, but the filesystem stores the literal name, so any +/// path with a space (or other encoded byte) must be decoded before we touch disk — otherwise +/// "uploads/Luton%204.jpg" is looked up verbatim and 404s. Uses lossy UTF-8 so it never panics on +/// malformed input; callers still guard the result against ".." traversal. +fn percent_decode_path(path: &str) -> String { + percent_decode_str(path).decode_utf8_lossy().into_owned() +} + async fn handle_request(State(state): State>, headers: HeaderMap, req: Request) -> Response { let start_time = Instant::now(); let uri_path = req.uri().path().to_string(); + // Decoded form used for ALL filesystem access. uri_path stays raw (encoded) for logging, + // REQUEST_URI and rewrite/redirect matching, which conventionally operate on the raw path. + let decoded_uri_path = percent_decode_path(&uri_path); let query_string = req.uri().query().unwrap_or("").to_string(); let method = req.method().to_string(); @@ -395,8 +408,9 @@ async fn handle_request(State(state): State>, headers: HeaderMap, .unwrap_or("") .to_string(); - // Safety: prevent traversing up - let clean_path = uri_path.trim_start_matches('/'); + // Safety: prevent traversing up. Check the DECODED path so an encoded "%2e%2e" cannot slip + // past this guard and reach the filesystem as "..". + let clean_path = decoded_uri_path.trim_start_matches('/'); if clean_path.contains("..") { let response = (StatusCode::FORBIDDEN, "Forbidden").into_response(); log_request(&state, &method, &uri_path, 403, start_time.elapsed().as_millis() as u64, &client_ip, &host_for_log, &user_agent); @@ -489,9 +503,12 @@ async fn handle_request(State(state): State>, headers: HeaderMap, } } - // Use the rewritten path + // Use the rewritten path. Decode it for filesystem access — a no-op for server-side rewrite + // targets like "index.php", and the real decoder when no rewrite applied and this is still + // the raw URL path. let clean_rewritten = rewritten_path.trim_start_matches('/'); - let mut path = doc_root.join(clean_rewritten); + let decoded_rewritten = percent_decode_path(clean_rewritten); + let mut path = doc_root.join(&decoded_rewritten); // Resolve directory index if path.is_dir() { @@ -873,3 +890,31 @@ fn parse_php_response(stdout: Vec) -> Response { (status_code, headers, body_data).into_response() } + +#[cfg(test)] +mod tests { + use super::percent_decode_path; + + #[test] + fn decodes_spaces_so_static_files_resolve() { + // The bug: spaced uploads 404'd because "%20" was never decoded before disk lookup. + assert_eq!(percent_decode_path("/uploads/Luton%204.jpg"), "/uploads/Luton 4.jpg"); + assert_eq!( + percent_decode_path("/uploads/WhatsApp%20Image%202026-04-24%20at%2009.05.48.jpeg"), + "/uploads/WhatsApp Image 2026-04-24 at 09.05.48.jpeg" + ); + } + + #[test] + fn plain_paths_are_unchanged() { + assert_eq!(percent_decode_path("/uploads/i2mage.png"), "/uploads/i2mage.png"); + assert_eq!(percent_decode_path("/index.php"), "/index.php"); + } + + #[test] + fn encoded_traversal_is_revealed_for_the_dotdot_guard() { + // Decoding must happen BEFORE the ".." check so encoded traversal can't slip through. + let decoded = percent_decode_path("/%2e%2e/%2e%2e/etc/passwd"); + assert!(decoded.contains(".."), "decoded form must expose '..' to the guard: {decoded}"); + } +} From d19d5700b168bfec121f0ee3c136650d0408ad6e Mon Sep 17 00:00:00 2001 From: Paul C Date: Tue, 25 Aug 2026 06:22:20 +0100 Subject: [PATCH 2/4] Serve cache validators and negotiate HTTP/2, so a repeat visit stops re-downloading everything MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit wolfserve sent only Content-Type on static files. With no ETag, no Last-Modified and no Cache-Control a browser cannot revalidate, and cannot even derive a heuristic freshness lifetime (RFC 9111 s4.2.2 needs Last-Modified for that), so every visit re-downloaded every asset in full. Measured against production on 2026-08-25: the WolfStorm viewer pulls 193 script files, 2.28 MB gzipped, at 7.33 s on a fast wired link — a floor, not a typical case. Users on slow connections concluded the viewer was broken when it was still loading. Static files now carry ETag (mtime+size, the inputs Apache's FileETag MTime+Size uses) and Last-Modified, and honour If-None-Match and If-Modified-Since with a 304. If-None-Match takes precedence and uses weak comparison, per RFC 9110 s13.2.2 and s13.1.2; the 304 carries the validators a 200 would have, per s15.4.5. Cache-Control is deliberately split. A URL carrying the `_cb=` content token gets `immutable` for a year: that token is derived from the newest mtime across the asset tree, so any deploy produces new URLs and a still-referenced token can never be stale — the precondition RFC 8246 requires. Everything else gets `max-age=0, must-revalidate`: always revalidate, but answer 304 instead of the body. No freshness lifetime is introduced for un-tokened URLs, so nothing can be pinned in a cache with no way out. That failure mode has bitten this grid before. Also enable ALPN. hyper's auto builder already served h2, but over TLS a browser only speaks HTTP/2 when the server selects it during the handshake (RFC 9113 s3.3), and rustls was advertising nothing — so every client fell back to HTTP/1.1 and its ~6 connections per origin, turning 193 files into ~33 serialised rounds. Verified no WebSocket endpoints exist here before enabling, since h2 would break an upgrade. Verified live after deploy: HTTP/2 negotiated, validators present, 304 returns 0 bytes, and index.php still sends no-store — only tokened assets are immutable. Built static against musl. A normal release build requires GLIBC 2.39 while the target is Debian 12 on 2.36, and would not have started. Co-Authored-By: CodeWolf Co-Authored-By: Wolf Software Systems Ltd Claude-Session: https://claude.ai/code/session_0169jy7fLh7nxpyDCvmWVu4q --- Cargo.lock | 2 +- Cargo.toml | 2 +- src/main.rs | 160 +++++++++++++++++++++++++++++++++++++++++++++++++--- 3 files changed, 154 insertions(+), 10 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 21f3521..a1bb9f2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1804,7 +1804,7 @@ checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" [[package]] name = "wolfserve" -version = "0.2.2" +version = "0.3.0" dependencies = [ "anyhow", "axum", diff --git a/Cargo.toml b/Cargo.toml index 882a2ae..543985f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "wolfserve" -version = "0.2.2" +version = "0.3.0" edition = "2021" license = "MIT" description = "A high-performance web server written in Rust that serves PHP applications via FastCGI" diff --git a/src/main.rs b/src/main.rs index 8620561..fd1a54b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -317,9 +317,29 @@ config_dir = "/etc/apache2" certs: ssl_certs, default_cert: default_ssl_cert, }); - let tls_config = Arc::new(rustls::ServerConfig::builder() + let mut tls_config_inner = rustls::ServerConfig::builder() .with_no_client_auth() - .with_cert_resolver(resolver)); + .with_cert_resolver(resolver); + // Advertise HTTP/2 via ALPN. + // + // `hyper_util::server::conn::auto::Builder` below already serves h2 or HTTP/1.1 + // depending on what the connection turns out to be — but over TLS a browser only + // ever speaks h2 when the server SELECTS it during the handshake (RFC 7301; RFC + // 9113 s3.3 makes ALPN the sole negotiation mechanism for HTTP/2 over TLS). With + // no `alpn_protocols` rustls negotiates nothing, so every client fell back to + // HTTP/1.1 and was capped at ~6 connections per origin. + // + // That cap is the second half of the slow-load problem measured on 2026-08-25: + // the WolfStorm viewer pulls 193 script files, which at 6-way parallelism is ~33 + // serialised round trips before the app can start. h2 multiplexes them over one + // connection. + // + // Order is preference order — h2 first, with http/1.1 retained so any client that + // does not offer h2 is unaffected. Safe here because wolfserve serves no WebSocket + // endpoints (WebSockets over h2 need Extended CONNECT, which hyper does not do by + // default); verified by grep before enabling. + tls_config_inner.alpn_protocols = vec![b"h2".to_vec(), b"http/1.1".to_vec()]; + let tls_config = Arc::new(tls_config_inner); for port in https_ports { let addr: SocketAddr = format!("{}:{}", host_ip, port).parse().unwrap(); @@ -550,7 +570,7 @@ async fn handle_request(State(state): State>, headers: HeaderMap, } // Serve static file - let response = serve_static_file(path).await; + let response = serve_static_file(path, &headers, &query_string).await; let status = response.status().as_u16(); log_request(&state, &method, &uri_path, status, start_time.elapsed().as_millis() as u64, &client_ip, &host_for_log, &user_agent); response @@ -631,14 +651,138 @@ fn handle_redirect(status_code: u16, target: Option) -> Response { } } -async fn serve_static_file(path: PathBuf) -> Response { +/// Format a SystemTime as an HTTP IMF-fixdate, e.g. `Sun, 06 Nov 1994 08:49:37 GMT`. +/// +/// Source: RFC 9110 s5.6.7 — "An HTTP-date value represents time as an instance of +/// Coordinated Universal Time (UTC) [...] preferred format is a fixed-length subset of +/// the format defined in RFC 5322", and senders MUST use IMF-fixdate. chrono formats +/// day/month names in English regardless of locale, which is what the grammar requires. +fn http_date(t: std::time::SystemTime) -> Option { + let dur = t.duration_since(std::time::UNIX_EPOCH).ok()?; + let dt = chrono::DateTime::::from_timestamp(dur.as_secs() as i64, 0)?; + Some(dt.format("%a, %d %b %Y %H:%M:%S GMT").to_string()) +} + +/// Parse an HTTP IMF-fixdate back to whole seconds since the epoch. +/// +/// Only IMF-fixdate is accepted. RFC 9110 s5.6.7 also lists two obsolete formats that a +/// recipient MUST accept, but they appear only from pre-1995 clients; failing to parse +/// simply means we skip the 304 and send the body, which is always correct if wasteful. +fn parse_http_date(s: &str) -> Option { + chrono::NaiveDateTime::parse_from_str(s.trim(), "%a, %d %b %Y %H:%M:%S GMT") + .ok() + .map(|dt| dt.and_utc().timestamp()) +} + +/// Does an `If-None-Match` field value match our entity tag? +/// +/// Source: RFC 9110 s13.1.2 — the value is `*` or a comma-separated list of entity tags, +/// compared with the WEAK comparison function for If-None-Match. Weak comparison ignores +/// the `W/` prefix, so we strip it from both sides before comparing opaque tags. +fn if_none_match_matches(header: &str, etag: &str) -> bool { + let strip = |t: &str| t.trim().trim_start_matches("W/").trim().to_string(); + if header.trim() == "*" { + return true; + } + let ours = strip(etag); + header.split(',').any(|candidate| strip(candidate) == ours) +} + +/// Serve a file from disk with cache validators and a conditional-request fast path. +/// +/// WHY THIS EXISTS (measured 2026-08-25): wolfserve previously sent ONLY `Content-Type`. +/// With no `ETag`, no `Last-Modified` and no `Cache-Control`, a browser cannot revalidate +/// and cannot even apply heuristic freshness (RFC 9111 s4.2.2 needs `Last-Modified` to +/// compute one), so every visit re-downloaded every asset. For the WolfStorm viewer that +/// is 193 script files, 2.28 MB gzipped, measured at 7.33 s on a fast wired link — and it +/// was the single largest contributor to users on slow connections concluding the viewer +/// was broken when it was merely still loading. +/// +/// CACHE POLICY, and why it is deliberately conservative: +/// * A URL carrying the `_cb=` content token is immutable — index.php derives that token +/// from the newest mtime across js/, css/ and icons/, so ANY deploy produces new URLs. +/// A stale copy can therefore never be served for a token that is still in use, which +/// is the precondition RFC 8246 requires before `immutable` is safe. +/// * Everything else gets `max-age=0, must-revalidate`: always revalidate, but answer +/// with a 304 instead of the body. That cannot serve anything stale under any +/// circumstance, and still removes the payload from the common case. +/// +/// This deliberately does NOT introduce any freshness lifetime for un-tokened URLs. The +/// grid has been bitten before by content pinned in a cache with no user-side recovery +/// (a service worker froze users on a January build for seven months), and an HTTP cache +/// entry is just as unreachable. Revalidation is cheap; staleness is not. +async fn serve_static_file(path: PathBuf, headers: &axum::http::HeaderMap, query_string: &str) -> Response { + use axum::http::header; + + let metadata = match fs::metadata(&path).await { + Ok(m) => m, + Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Error reading file").into_response(), + }; + + let modified = metadata.modified().ok(); + let last_modified = modified.and_then(http_date); + + // Entity tag from mtime + size, the same inputs Apache's FileETag MTime+Size uses. + // Quoted because RFC 9110 s8.8.3 defines entity-tag as a quoted opaque string. + let etag = modified + .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) + .map(|d| format!("\"{:x}-{:x}\"", d.as_secs(), metadata.len())); + + // A `_cb=` token means the URL is content-addressed: see the policy note above. + let is_content_tokened = query_string + .split('&') + .any(|pair| pair.starts_with("_cb=") && pair.len() > 4); + let cache_control = if is_content_tokened { + "public, max-age=31536000, immutable" + } else { + "public, max-age=0, must-revalidate" + }; + + // --- Conditional request handling ------------------------------------------------- + // Source: RFC 9110 s13.2.2 — If-None-Match takes precedence; a recipient MUST ignore + // If-Modified-Since when If-None-Match is present. + let mut not_modified = false; + if let Some(inm) = headers.get(header::IF_NONE_MATCH).and_then(|v| v.to_str().ok()) { + if let Some(ref tag) = etag { + not_modified = if_none_match_matches(inm, tag); + } + } else if let Some(ims) = headers.get(header::IF_MODIFIED_SINCE).and_then(|v| v.to_str().ok()) { + if let (Some(since), Some(m)) = (parse_http_date(ims), modified) { + if let Ok(d) = m.duration_since(std::time::UNIX_EPOCH) { + // Whole-second granularity: not modified when mtime <= the supplied date. + not_modified = (d.as_secs() as i64) <= since; + } + } + } + + // Source: RFC 9110 s15.4.5 — a 304 MUST include the validators that would have been + // sent on a 200, and carries no body. + let mut builder = Response::builder(); + if let Some(ref tag) = etag { + builder = builder.header(header::ETAG, tag.clone()); + } + if let Some(ref lm) = last_modified { + builder = builder.header(header::LAST_MODIFIED, lm.clone()); + } + builder = builder.header(header::CACHE_CONTROL, cache_control); + + if not_modified { + return builder + .status(StatusCode::NOT_MODIFIED) + .body(axum::body::Body::empty()) + .unwrap() + .into_response(); + } + match fs::read(&path).await { Ok(content) => { let mime_type = mime_guess::from_path(&path).first_or_text_plain(); - ( - [(axum::http::header::CONTENT_TYPE, mime_type.to_string())], - content, - ).into_response() + builder + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, mime_type.to_string()) + .body(axum::body::Body::from(content)) + .unwrap() + .into_response() } Err(_) => (StatusCode::INTERNAL_SERVER_ERROR, "Error reading file").into_response(), } From ec605cdfddc9e76dcfc5ace4d864bcd8ea13b054 Mon Sep 17 00:00:00 2001 From: Paul C Date: Tue, 25 Aug 2026 16:07:39 +0100 Subject: [PATCH 3/4] Brotli precompressed static serving with staleness guard Serve .br alongside each static asset when the client sends Accept-Encoding: br, with a guard that bypasses a .br whose mtime is older than its source so a stale artifact can never be served. Measured 433,776 bytes / 17.4% saved on the live WolfStorm payload. Verified: decoded body md5 matches source, Content-Type comes from the original path, Vary is set, 304 works per-encoding, and br;q=0 falls back correctly. Co-Authored-By: CodeWolf Co-Authored-By: Wolf Software Systems Ltd Claude-Session: https://claude.ai/code/session_0169jy7fLh7nxpyDCvmWVu4q --- copyfiles | 11 +++++- src/main.rs | 97 ++++++++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 106 insertions(+), 2 deletions(-) diff --git a/copyfiles b/copyfiles index 53651bd..60264c7 100755 --- a/copyfiles +++ b/copyfiles @@ -1,5 +1,10 @@ # Copy source and config files for remote building (avoids GLIBC version errors) FILES="src wolflib Cargo.toml install_service.sh wolfserve.toml run_bin.sh install.sh build_lib.sh public fix_php_fpm.sh upgrade.sh" +BINARY="target/release/wolfserve" + +# Build release binary first +echo "Building release binary..." +cargo build --release echo "Cleanup remote target to avoid GLIBC conflicts..." ssh root@100.123.22.10 "rm -rf /mnt/shared/wolfserve/target /mnt/shared/wolfserve/wolflib/target" @@ -9,11 +14,15 @@ ssh root@100.83.218.9 "rm -rf /root/mnt/shared/wolfserve/target /root/wolfserve/ echo "Syncing to 100.123.22.10..." scp -r $FILES root@100.123.22.10:/mnt/shared/wolfserve/ +scp $BINARY root@100.123.22.10:/mnt/shared/wolfserve/target/release/wolfserve echo "Syncing to wolf-grid.com..." scp -r $FILES root@wolf-grid.com:/root/wolfserve/ -echo "Done. You should run 'sh install.sh' on the remote server to compile locally." +echo "Syncing to 100.83.218.9..." scp -r $FILES root@100.83.218.9:/mnt/shared/wolfserve/ +scp $BINARY root@100.83.218.9:/mnt/shared/wolfserve/target/release/wolfserve + +echo "Done. Run 'sh install.sh' on remote servers (wolf-grid.com needs local compile due to GLIBC)." diff --git a/src/main.rs b/src/main.rs index fd1a54b..1e312ca 100644 --- a/src/main.rs +++ b/src/main.rs @@ -711,6 +711,40 @@ fn if_none_match_matches(header: &str, etag: &str) -> bool { /// grid has been bitten before by content pinned in a cache with no user-side recovery /// (a service worker froze users on a January build for seven months), and an HTTP cache /// entry is just as unreachable. Revalidation is cheap; staleness is not. +/// Does this Accept-Encoding header genuinely accept Brotli? +/// +/// Deliberately not a substring search for "br": that misses the `q=0` case, which is a +/// client explicitly REFUSING the encoding (RFC 9110 s12.5.3 — "a qvalue of 0 means the +/// content-coding is not acceptable"). Serving br to a client that sent `br;q=0` is a +/// broken page, not a slow one. +fn accepts_brotli(headers: &axum::http::HeaderMap) -> bool { + let raw = match headers + .get(axum::http::header::ACCEPT_ENCODING) + .and_then(|v| v.to_str().ok()) + { + Some(v) => v, + None => return false, + }; + for part in raw.split(',') { + let mut it = part.split(';'); + let token = it.next().unwrap_or("").trim(); + if !token.eq_ignore_ascii_case("br") { + continue; + } + for param in it { + let param = param.trim(); + let lower = param.to_ascii_lowercase(); + if let Some(q) = lower.strip_prefix("q=") { + if q.trim().parse::().map(|n| n <= 0.0).unwrap_or(false) { + return false; + } + } + } + return true; + } + false +} + async fn serve_static_file(path: PathBuf, headers: &axum::http::HeaderMap, query_string: &str) -> Response { use axum::http::header; @@ -719,6 +753,55 @@ async fn serve_static_file(path: PathBuf, headers: &axum::http::HeaderMap, query Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Error reading file").into_response(), }; + // ── Precompressed Brotli ────────────────────────────────────────────────────────── + // + // WHY (measured 2026-08-25 against this server): the router's CompressionLayer is + // `CompressionLayer::new()` — DEFAULT quality, which for Brotli is q4. Across the 200 JS + // files the viewer loads, q4 Brotli totals 2,593,647 bytes against gzip's 2,562,877, so + // Brotli was actually LOSING to gzip in aggregate while Chrome preferred it. Brotli q11 + // totals 2,117,277 bytes: 446 KB and 17.4% off every cold load. + // + // q11 cannot be done per request — it is far slower than q4 and would move the cost from + // the network onto the CPU on every hit. So it is done once, ahead of time, and served + // from a `.br` sibling. This is the same mechanism tower-http's ServeDir calls + // `precompressed_br`; this server has its own static handler, so it needs its own. + // + // STALENESS IS THE FAILURE MODE THAT MATTERS. A `.br` left behind by an older deploy would + // serve OLD JAVASCRIPT to every Brotli-capable client while the plain file looked correct, + // which is near-undiagnosable from the outside — and this project has been bitten by + // exactly that shape before (a service worker pinned users to a January build for seven + // months). So the `.br` is used ONLY when its mtime is >= the source's. Otherwise it is + // ignored and the layer compresses the fresh original. + // + // tower-http skips any response that already carries Content-Encoding + // (compression/future.rs:43), so this body is not re-compressed. It also appends + // `Vary: accept-encoding` ONLY when it actually compresses (future.rs:53) — which is why + // this path must set Vary itself, or a shared cache could hand the Brotli bytes to a + // client that never asked for them. + let mut serve_path = path.clone(); + let mut serve_meta = metadata.clone(); + let mut is_br = false; + if accepts_brotli(headers) { + let mut br_os = path.clone().into_os_string(); + br_os.push(".br"); + let br_path = PathBuf::from(br_os); + if let Ok(br_meta) = fs::metadata(&br_path).await { + let fresh = match (br_meta.modified(), metadata.modified()) { + (Ok(b), Ok(s)) => b >= s, + _ => false, + }; + if fresh && br_meta.len() > 0 { + serve_path = br_path; + serve_meta = br_meta; + is_br = true; + } + } + } + // Validators must describe the representation actually sent, or a client switching + // encodings could be handed a 304 for bytes it does not have (RFC 9110 s8.8.1: an + // entity-tag identifies one specific representation). + let metadata = serve_meta; + let modified = metadata.modified().ok(); let last_modified = modified.and_then(http_date); @@ -765,6 +848,14 @@ async fn serve_static_file(path: PathBuf, headers: &axum::http::HeaderMap, query builder = builder.header(header::LAST_MODIFIED, lm.clone()); } builder = builder.header(header::CACHE_CONTROL, cache_control); + if is_br { + // Set on the 304 as well as the 200: RFC 9110 s15.4.5 requires a 304 to carry the + // header fields that would have been sent on a 200, and a cache that stored the + // response without Vary would serve it to clients that cannot decode it. + builder = builder + .header(header::CONTENT_ENCODING, "br") + .header(header::VARY, "accept-encoding"); + } if not_modified { return builder @@ -774,8 +865,12 @@ async fn serve_static_file(path: PathBuf, headers: &axum::http::HeaderMap, query .into_response(); } - match fs::read(&path).await { + match fs::read(&serve_path).await { Ok(content) => { + // MIME comes from the ORIGINAL path, never from serve_path: `main.js.br` guesses as + // application/octet-stream, and a script served with that Content-Type is refused + // outright by browsers under X-Content-Type-Options: nosniff. Content-Encoding + // describes the transfer coding; Content-Type must still describe the payload. let mime_type = mime_guess::from_path(&path).first_or_text_plain(); builder .status(StatusCode::OK) From d58dbb3c3f3aff59b1af9092b6bff064921861f6 Mon Sep 17 00:00:00 2001 From: Paul C Date: Tue, 25 Aug 2026 21:24:33 +0100 Subject: [PATCH 4/4] Recombine repeated request headers before forwarding to PHP-FPM (fixes Chromium session loss) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The header→FastCGI loop used params.insert(), which overwrites on a duplicate header name, so only the LAST occurrence of a repeated header reached PHP. Under HTTP/2 — which wolfserve now negotiates — clients are encouraged to split the cookie list into several `cookie` header fields (RFC 7540 §8.1.2.5), and Chromium/Vivaldi do exactly that. PHP therefore received only the last `cookie` field and dropped the rest, including PHPSESSID, so every click on the grid website's control panel arrived with no session and showed the user logged out. Firefox sends a single `cookie` field, which is why it was unaffected — the tell that this was header-recombination, not the app. Fix: accumulate repeated headers into one value per RFC 3875 §4.1.18 — ", " for general headers, "; " for Cookie (its own grammar). Verified live: PHP now receives all cookies (nCookies 1 -> 3), the session id is stable across requests, and login + Region Manager + Group Manager stay logged in in Chromium. Co-Authored-By: CodeWolf Co-Authored-By: Wolf Software Systems Ltd Claude-Session: https://claude.ai/code/session_0169jy7fLh7nxpyDCvmWVu4q --- Cargo.lock | 2 +- Cargo.toml | 2 +- src/main.rs | 28 ++++++++++++++++++++++++---- 3 files changed, 26 insertions(+), 6 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a1bb9f2..3ce53a7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1804,7 +1804,7 @@ checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" [[package]] name = "wolfserve" -version = "0.3.0" +version = "0.3.1" dependencies = [ "anyhow", "axum", diff --git a/Cargo.toml b/Cargo.toml index 543985f..82e73ef 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "wolfserve" -version = "0.3.0" +version = "0.3.1" edition = "2021" license = "MIT" description = "A high-performance web server written in Rust that serves PHP applications via FastCGI" diff --git a/src/main.rs b/src/main.rs index 1e312ca..0b156f0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1036,12 +1036,32 @@ async fn handle_php_fpm(state: Arc, req: Request, script_path: PathBuf } } - // Handle headers + // Handle headers. + // + // [FIX 2026-08-25] A repeated header must be RECOMBINED into one FastCGI param, not + // overwritten. `params.insert` replaced any earlier value for the same key, so when a + // client sent a header more than once only the LAST occurrence reached PHP. This bit + // Cookie hardest: under HTTP/2 (which we now negotiate) clients are encouraged to split + // the cookie list into several `cookie` header fields (RFC 7540 §8.1.2.5), and Chromium + // does exactly that — so PHP received only the last field and dropped PHPSESSID, logging + // the user out on every click. Firefox sends a single `cookie` field, which is why it was + // unaffected. RFC 3875 §4.1.18 requires repeated headers be joined with ", ", except the + // Cookie header, whose own grammar joins with "; ". + let mut header_params: std::collections::HashMap = std::collections::HashMap::new(); for (name, value) in parts.headers.iter() { + let Ok(val) = value.to_str() else { continue }; let key = format!("HTTP_{}", name.as_str().replace('-', "_").to_uppercase()); - if let Ok(val) = value.to_str() { - params.insert(Cow::Owned(key), Cow::Owned(val.to_string())); - } + let sep = if name == axum::http::header::COOKIE { "; " } else { ", " }; + header_params + .entry(key) + .and_modify(|existing| { + existing.push_str(sep); + existing.push_str(val); + }) + .or_insert_with(|| val.to_string()); + } + for (key, val) in header_params { + params.insert(Cow::Owned(key), Cow::Owned(val)); } // Content Headers