file review #4 issues #97-#105
Message
file review #4 issues #97-#105
Diff
.forge/issues/66e7353a-2898-4d28-822d-47c7aee9c862/issue.md+44 −0
--- /dev/null+++ b/.forge/issues/66e7353a-2898-4d28-822d-47c7aee9c862/issue.md@@ -0,0 +1,44 @@1+---2+id: 66e7353a-2898-4d28-822d-47c7aee9c8623+title: public-origin postprocessor rewrites raw and release artifact HTML4+state: open5+labels:6+- kind:build7+assignees: []8+milestone: null9+created: 2026-10-27T18:08:32.871053719Z10+updated: null11+---12+## What13+14+`absolutize_social_meta()` walks the whole output tree and rewrites every `.html` file:15+16+`src/build.rs:1312-1334`:17+18+- `path.extension() == "html"`19+- `std::fs::read_to_string(&path)?`20+- replace `content="/og.png"`21+22+That includes committed raw HTML under `raw/` and copied release artifacts under `releases/<version>/`, not just generated pages.23+24+## Reproduced25+26+A repo with `raw/x.html` containing invalid UTF-8:27+28+```29+hu13 build . -o out --public-origin https://example.test30+hu13: social meta read out/raw/raw/x.html: stream did not contain valid UTF-831+```32+33+With valid raw HTML containing `content="/og.png"`, the copied bytes are silently rewritten to the absolute URL. Same for release artifact HTML.34+35+## Impact36+37+- A repo with a binary/invalid-UTF-8 `.html` raw file cannot build when `--public-origin` is set.38+- `/raw/` and release artifact bytes no longer match the source (breaks byte-identical downloads, hashes, and user expectations).39+40+## Fix41+42+- Only postprocess generated page paths (or pass the absolute URL when rendering pages).43+- Skip `raw/`, `repo.git/`, and `releases/<version>/` artifact trees.44+- Regression test: raw `.html` invalid UTF-8 + raw HTML marker survives unchanged; generated page still gets absolute `og:image`.
.forge/issues/69c0303b-0dcd-4b1b-a67c-04bcfdde011f/issue.md+49 −0
--- /dev/null+++ b/.forge/issues/69c0303b-0dcd-4b1b-a67c-04bcfdde011f/issue.md@@ -0,0 +1,49 @@1+---2+id: 69c0303b-0dcd-4b1b-a67c-04bcfdde011f3+title: release artifacts are served as active HTML/SVG (stored XSS)4+state: open5+labels:6+- kind:security7+assignees: []8+milestone: null9+created: 2026-10-27T18:08:25.218070362Z10+updated: null11+---12+## What13+14+Release artifacts under `.forge/releases/out/<version>/` are copied verbatim to `dist/releases/<version>/`, but they are served as active documents:15+16+- `src/releases.rs:462-465` copies the whole tree.17+- `src/serve.rs:729-733` only special-cases `raw/`; an artifact named `evil.html` gets `Content-Type: text/html` and `inline` disposition.18+- `src/build.rs:1150` `_headers` protects only `/raw/*`; Pages serves the artifact inline too.19+20+## Reproduced21+22+Fixture repo:23+24+```25+mkdir -p .forge/releases/out/v1.026+cat > .forge/releases/out/v1.0/evil.html <<'HTML'27+<!doctype html><script>document.body.textContent="PWNED"</script>28+HTML29+```30+31+Build, serve, then:32+33+```34+curl -I http://127.0.0.1:PORT/releases/v1.0/evil.html35+HTTP/1.1 200 OK36+Content-Type: text/html; charset=utf-837+```38+39+The script executes in the site origin (the origin that hosts the inbox).40+41+## Impact42+43+Stored XSS via a release artifact (HTML/SVG/etc.), on `hu13 serve` and Cloudflare Pages.44+45+## Fix46+47+- Treat `/releases/<version>/...` artifacts as downloads in `serve` (same policy as raw) and add a Pages `_headers` rule, e.g. `/releases/*/*` -> `Content-Disposition: attachment`, `X-Content-Type-Options: nosniff`.48+- Keep `/releases/<version>.html` itself inline.49+- Regression test: artifact `.html`/`.svg` response is attachment and `application/octet-stream`/safe MIME.
.forge/issues/983bd144-65ee-436f-a783-d80072c63ec5/issue.md+46 −0
--- /dev/null+++ b/.forge/issues/983bd144-65ee-436f-a783-d80072c63ec5/issue.md@@ -0,0 +1,46 @@1+---2+id: 983bd144-65ee-436f-a783-d80072c63ec53+title: release/wiki/feed URLs lack percent-encoding4+state: open5+labels:6+- area:release7+assignees: []8+milestone: null9+created: 2026-10-27T18:08:32.940173587Z10+updated: null11+---12+## What13+14+Generated URLs outside `/files`/`/raw` still use HTML escaping but not URL path encoding:15+16+- `src/releases.rs:426`: `/releases/{version}.html`17+- `src/releases.rs:329`: artifact paths (also the XSS finding)18+- `src/wiki.rs:292`: `/wiki/{path}.html`19+- `src/feed.rs:102`: release entry links20+21+`#` and `?` in repo-controlled names therefore become fragments/queries and link to the wrong file.22+23+## Reproduced24+25+Fixture with release version `v1#x` and wiki file `a#b.md`:26+27+```html28+<a href="/releases/v1#x.html">29+<a href="/wiki/a#b.md.html">30+```31+32+Feed entry:33+34+```xml35+<link href="/releases/v1#x.html"/>36+```37+38+The output files contain literal `#`, but the links request `v1` / `a` with a fragment.39+40+## Impact41+42+Broken release, artifact, wiki, and feed links for valid repo names.43+44+## Fix45+46+Reuse the `encode_path`/`href_esc` logic from #89 for release versions, wiki paths, artifact paths, and feed release links. Add a build test with `#`, `?`, `%`, and spaces.
.forge/issues/ac514078-d6b2-4e59-94d0-00c523e41936/issue.md+38 −0
--- /dev/null+++ b/.forge/issues/ac514078-d6b2-4e59-94d0-00c523e41936/issue.md@@ -0,0 +1,38 @@1+---2+id: ac514078-d6b2-4e59-94d0-00c523e419363+title: serve same-origin gate ignores port and scheme4+state: open5+labels:6+- kind:security7+assignees: []8+milestone: null9+created: 2026-10-27T18:08:32.890435135Z10+updated: null11+---12+## What13+14+The local `serve` same-origin gate compares only hostnames; it discards scheme and port.15+16+`src/serve.rs:599-658`: `host_of_url()` strips `:port`; `origin_allowed()` compares `want == got`.17+18+## Reproduced19+20+Run `hu13 serve` on `127.0.0.1:39333`, then:21+22+```23+curl -X POST \24+ -H 'Origin: http://127.0.0.1:9999' \25+ -H 'Content-Type: application/x-www-form-urlencoded' \26+ --data 'title=Hello&body=World&license=agree' \27+ http://127.0.0.1:39333/inbox/issue28+```29+30+Response is `200` and a submission is stored. A browser page on a different local port (or different scheme) can therefore POST to the inbox, even though the Pages Function compares `.host` including the port.31+32+## Impact33+34+Cross-origin POST/CSRF bypasses the browser same-origin gate; local/Pages behavior differs.35+36+## Fix37+38+Compare normalized scheme + host + port (handle default ports), not just the hostname. Add tests for same host/different port and different scheme; keep the curl no-Origin path.
.forge/issues/c815bb87-3bbd-44eb-9fb1-cb818ec10a8c/issue.md+51 −0
--- /dev/null+++ b/.forge/issues/c815bb87-3bbd-44eb-9fb1-cb818ec10a8c/issue.md@@ -0,0 +1,51 @@1+---2+id: c815bb87-3bbd-44eb-9fb1-cb818ec10a8c3+title: release artifact filenames inject HTML into release pages4+state: open5+labels:6+- kind:security7+assignees: []8+milestone: null9+created: 2026-10-27T18:08:32.834116005Z10+updated: null11+---12+## What13+14+`artifact_href()` interpolates the artifact path without HTML escaping or URL encoding:15+16+`src/releases.rs:329-330`:17+18+```rust19+fn artifact_href(version: &str, path: &str) -> String {20+ format!("/releases/{}/{path}", esc(version))21+}22+```23+24+`src/releases.rs:363-365` puts this directly into `href="..."`.25+26+## Reproduced27+28+Create an artifact file whose name is:29+30+```31+evil" onmouseover="alert(1).txt32+```33+34+Build. The release page contains:35+36+```html37+<a href="/releases/v1.0/evil" onmouseover="alert(1).txt">…</a>38+```39+40+A quote and arbitrary markup/event handlers are injected into the page. Filenames are repo-controlled (`collect_artifacts` accepts any name), so this is stored XSS on the release page.41+42+Also unencoded `#`/`?` in paths break the download link.43+44+## Impact45+46+Stored XSS / broken artifact links from `.forge` data.47+48+## Fix49+50+- Percent-encode the path for the URL, then HTML-escape the entire href (reuse the path encoder added for #89, or expose it from `build`).51+- Add tests with `"`, `<`, `#`, `?`, and spaces in artifact names.
.forge/issues/defeff90-dfdb-435e-a6e0-e99d096f8b8d/issue.md+34 −0
--- /dev/null+++ b/.forge/issues/defeff90-dfdb-435e-a6e0-e99d096f8b8d/issue.md@@ -0,0 +1,34 @@1+---2+id: defeff90-dfdb-435e-a6e0-e99d096f8b8d3+title: serve serves og.png as octet-stream4+state: open5+labels:6+- area:render7+assignees: []8+milestone: null9+created: 2026-10-27T18:08:32.955063267Z10+updated: null11+---12+## What13+14+`serve` does not have a `.png` (or common image) branch in `content_type_for`; `dist/og.png` is served as `application/octet-stream`.15+16+`src/serve.rs:446-477`.17+18+## Reproduced19+20+```21+curl -I http://127.0.0.1:PORT/og.png22+HTTP/1.1 200 OK23+Content-Type: application/octet-stream24+```25+26+Pages/static hosts serve `image/png`. Social scrapers may ignore the preview image when the MIME type is octet-stream, and `serve` is no longer single-shape with Pages.27+28+## Impact29+30+Social-card/OG preview parity regression; minor client behavior difference.31+32+## Fix33+34+Add common image MIME types (`png`, `jpg`/`jpeg`, `gif`, `webp`, `ico`) to `content_type_for`; add a content-type test for `og.png`.
.forge/issues/f67e6008-5161-4880-b3e7-b685736a69c1/issue.md+42 −0
--- /dev/null+++ b/.forge/issues/f67e6008-5161-4880-b3e7-b685736a69c1/issue.md@@ -0,0 +1,42 @@1+---2+id: f67e6008-5161-4880-b3e7-b685736a69c13+title: build panics on multibyte release summary4+state: open5+labels:6+- kind:build7+assignees: []8+milestone: null9+created: 2026-10-27T18:08:32.854172841Z10+updated: null11+---12+## What13+14+The release feed truncates a summary by character count, then explicitly truncates the same string by **byte** count:15+16+`src/feed.rs:92-94`:17+18+```rust19+let mut s: String = first_line.chars().take(200).collect();20+if s.len() > 200 {21+ s.truncate(200);22+}23+```24+25+For multibyte characters `s.len()` can exceed 200 even when there are only 200 chars, and `truncate(200)` may land inside a UTF-8 code point.26+27+## Reproduced28+29+Release body first line = `"€"` repeated 200 times (3 bytes each). `hu13 build` exits 101:30+31+```32+thread 'main' panicked at src/feed.rs:94:23:33+assertion failed: self.is_char_boundary(new_len)34+```35+36+## Impact37+38+Any release note with enough multibyte text crashes the whole build (denial of build / release).39+40+## Fix41+42+Remove the redundant byte truncate (the value is already at most 200 chars), or truncate only at a char boundary. Add a regression test with 200 `€` / emoji.
.forge/issues/f9296420-bf9c-41fc-a0da-8955bd400ae7/issue.md+49 −0
--- /dev/null+++ b/.forge/issues/f9296420-bf9c-41fc-a0da-8955bd400ae7/issue.md@@ -0,0 +1,49 @@1+---2+id: f9296420-bf9c-41fc-a0da-8955bd400ae73+title: CI redaction corrupts logs for relative repo paths4+state: open5+labels:6+- kind:build7+assignees: []8+milestone: null9+created: 2026-10-27T18:08:32.923269325Z10+updated: null11+---12+## What13+14+CI log redaction mangles relative repo paths because it treats `.` as a literal path needle.15+16+`src/forge.rs:771-779`:17+18+```rust19+for p in [root_abs.as_path(), root] {20+ let needle = p.to_string_lossy();21+ if !needle.is_empty() && needle != "/" {22+ out = out.replace(needle.as_ref(), "<repo>");23+ }24+}25+```26+27+When the repo is passed as `.` (the normal `hu13 build .` invocation), `repo_root` is `.`/`""`, so every `.` in every log becomes `<repo>`.28+29+## Reproduced30+31+Build from a fixture with `hu13 build . -o …` and a CI log containing:32+33+```34+Checking hu13 v0.1.0 (/tmp/rev4-ci)35+```36+37+Rendered log:38+39+```40+Checking hu13 v0<repo>1<repo>0 (<repo>)41+```42+43+## Impact44+45+CI pages are corrupted/unreadable; relative repo arguments are common. This is a regression in #94.46+47+## Fix48+49+Canonicalize the repo root first and only replace absolute path needles; skip `.`/`..` and empty relative parents. Add a test invoking a relative repo path, not only absolute fixtures.
.forge/issues/fb59a187-80c0-4c80-a6f9-3de604f9843d/issue.md+37 −0
--- /dev/null+++ b/.forge/issues/fb59a187-80c0-4c80-a6f9-3de604f9843d/issue.md@@ -0,0 +1,37 @@1+---2+id: fb59a187-80c0-4c80-a6f9-3de604f9843d3+title: inbox-dir symlink into www exposes private submissions4+state: open5+labels:6+- kind:security7+assignees: []8+milestone: null9+created: 2026-10-27T18:08:32.906968950Z10+updated: null11+---12+## What13+14+`ensure_inbox_outside_www()` only compares lexical paths; `serve` static file lookup follows symlinks inside `www`.15+16+`src/serve.rs:175-186`.17+18+## Reproduced19+20+```21+mkdir -p /tmp/www/private22+ln -s /tmp/www/private /tmp/inbox-link23+hu13 serve --www /tmp/www --inbox-dir /tmp/inbox-link --listen 127.0.0.1:3933224+curl http://127.0.0.1:39332/private/leak.json25+```26+27+Startup succeeds (the inbox path is lexically outside `/tmp/www`), and the file is served with `200`. Actual inbox submissions written through the symlink land inside the served tree.28+29+## Impact30+31+Violates the explicit “inbox must never be served” invariant; private submissions can be exposed.32+33+## Fix34+35+- Canonicalize/resolve the existing ancestors of both `www` and `inbox-dir` before comparison (the build `resolve_existing` helper is a model).36+- Consider refusing symlinked components under `www` for static serving.37+- Tests: inbox path symlinked into www, inbox parent symlinked into www, and www itself a symlink.