1 From ee6e71b2eb403bfa513cf2a3465991004a777c65 Mon Sep 17 00:00:00 20012 From: hu13-test <[email protected]>3 Date: Tue, 27 Oct 2026 19:49:57 +00004 Subject: [PATCH] review #5: harden Pages headers, .forge symlinks, links,5 origin/static parity (#106-#117)6 7 ---8 .forge/wiki/deploy | 5 +9 docs/cloudflare.md | 12 +-10 functions/inbox/[kind].js | 25 +-11 src/build.rs | 262 ++++++++++++++++++--12 src/forge.rs | 77 ++++--13 src/paths.rs | 61 ++++-14 src/releases.rs | 36 ++-15 src/serve.rs | 21 +-16 src/wiki.rs | 15 +-17 tests/pages_inbox.test.mjs | 21 +-18 tests/review4_hardening.rs | 6 +-19 tests/review5_hardening.rs | 477 +++++++++++++++++++++++++++++++++++++20 12 files changed, 958 insertions(+), 60 deletions(-)21 create mode 100644 tests/review5_hardening.rs22 23 diff --git a/.forge/wiki/deploy b/.forge/wiki/deploy24 index ac9b9d3..d13f21c 10064425 --- a/.forge/wiki/deploy26 +++ b/.forge/wiki/deploy27 @@ -70,6 +70,11 @@ What happens inside (`entrypoint.sh`):28 `/releases/<version>/**` download policy for Pages. On nginx, add the29 equivalent `add_header Content-Disposition attachment` for `/raw/` and30 release artifact directories — committed HTML/SVG must never render.31 +- Pages clean-URL redirects: Pages answers `.html` paths with a 30832 + (`/issues/index.html` → `/issues/`, `/files/a.md.html` →33 + `/files/a.md`); the links still resolve, and Docker/nginx serve the34 + exact path. `_redirects` status 200 cannot override this. If exact URL35 + shape matters, use a proxy/Worker or accept the Pages form.36 - Cloudflare Pages inbox: the static `dist/` alone has **no inbox**. The37 Pages Function in `functions/inbox/[kind].js` + R2 binding `INBOX` and38 secret `INBOX_SALT` implement the same contract. See39 diff --git a/docs/cloudflare.md b/docs/cloudflare.md40 index b561c9e..7e6c420 10064441 --- a/docs/cloudflare.md42 +++ b/docs/cloudflare.md43 @@ -4,6 +4,14 @@ The same `dist/` works on Docker/`hu13 serve`, nginx, and Pages. On44 Pages the inbox is implemented by a Pages Function backed by R2; a45 static-only upload has no working inbox.46 47 +Pages normalizes `.html` request paths with a 308 (for example48 +`/issues/index.html` → `/issues/` and `/files/a.md.html` →49 +`/files/a.md`). Every generated link still reaches the same page; the50 +redirect is a Pages platform behavior, not a missing file. Docker/nginx51 +serve the exact `.html` path with no redirect. If byte-identical URLs52 +matter, put a proxy/Worker in front or accept the Pages canonical form;53 +`_redirects` status 200 cannot override these clean-URL redirects.54 +55 ## 1. Build the site56 57 ```sh58 @@ -94,4 +102,6 @@ it does not read R2. Copy objects down if you want the terminal reader.59 - Committed `/raw/*` bytes and release artifacts under60 `/releases/<version>/*` always download (`Content-Disposition:61 attachment`) on both `serve` and Pages, so hostile HTML/SVG can never62 - execute in the inbox origin.63 + execute in the inbox origin. The Pages `_headers` rule uses one named64 + placeholder plus one splat (`/releases/:version/*`): Pages silently65 + skips rules with two splats.66 diff --git a/functions/inbox/[kind].js b/functions/inbox/[kind].js67 index 1fa9037..3ef6121 10064468 --- a/functions/inbox/[kind].js69 +++ b/functions/inbox/[kind].js70 @@ -258,13 +258,17 @@ async function saltedIpHash(ip, salt) {71 // rule for /inbox/* is the durable control; this still stops a single72 // hot isolate from storing unlimited submissions.73 const buckets = new Map();74 +const MAX_RATE_KEYS = 10000;75 function rateAllowed(key, limit) {76 const now = Date.now();77 - if (buckets.size > 10000) {78 + if (buckets.size >= MAX_RATE_KEYS) {79 for (const [k, v] of buckets) if (v.reset <= now) buckets.delete(k);80 }81 let b = buckets.get(key);82 if (!b || b.reset <= now) {83 + // Fail closed for a new key when the isolate map is full; do not let84 + // source-IP rotation grow it without bound (f0rge #116).85 + if (!b && buckets.size >= MAX_RATE_KEYS) return false;86 b = { count: 0, reset: now + 3600_000 };87 buckets.set(key, b);88 }89 @@ -273,9 +277,24 @@ function rateAllowed(key, limit) {90 return true;91 }92 93 +// R2 list returns at most 1000 objects per call. Paginate until the94 +// configured quota is counted or the listing ends; a truncated page95 +// without a cursor fails closed (f0rge #116).96 async function overQuota(bucket, limit) {97 - const listed = await bucket.list({ limit: Math.min(limit, 1000) });98 - return listed.objects.length >= limit;99 + if (!(limit > 0)) return true;100 + let count = 0;101 + let cursor;102 + for (;;) {103 + const page = await bucket.list({104 + limit: Math.min(limit - count, 1000),105 + cursor,106 + });107 + count += page.objects.length;108 + if (count >= limit) return true;109 + if (!page.truncated) return false;110 + if (!page.cursor) return true;111 + cursor = page.cursor;112 + }113 }114 115 function nowRfc3339() {116 diff --git a/src/build.rs b/src/build.rs117 index 4274e88..a572829 100644118 --- a/src/build.rs119 +++ b/src/build.rs120 @@ -191,14 +191,184 @@ fn safe_url(url: &str) -> bool {121 matches!(scheme, "http" | "https" | "mailto" | "tel")122 }123 124 -/// Render markdown to HTML, escaping raw HTML.125 -///126 -/// `pulldown-cmark` passes `Html`/`InlineHtml` events through untouched127 -/// (R1 finding), so map them through [`esc`] first. GFM tables, task128 -/// lists and strikethrough are enabled (f0rge #71); link/image129 -/// destinations are scheme-filtered (f0rge #62).130 -#[must_use]131 -pub fn markdown_to_html(md: &str) -> String {132 +/// Context for relative markdown destinations: a repo file page uses133 +/// `/files/...` + `/raw/...` + `/assets/...`; a wiki page uses134 +/// `/wiki/...` + raw/assets for non-page files.135 +#[derive(Clone, Copy)]136 +enum LinkBase<'a> {137 + Repo(&'a str),138 + Wiki(&'a str),139 +}140 +141 +fn base_path(base: LinkBase<'_>) -> &str {142 + match base {143 + LinkBase::Repo(p) | LinkBase::Wiki(p) => p,144 + }145 +}146 +147 +/// Lexically resolve a relative link against a repo- or wiki-relative148 +/// base directory. Returns `None` when it climbs above the root or has149 +/// unsafe/absolute components.150 +fn rel_join(base: &str, path: &str) -> Option<String> {151 + if path.starts_with('/') || path.contains(['\\', '\0']) {152 + return None;153 + }154 + let mut parts: Vec<&str> = base.split('/').filter(|s| !s.is_empty()).collect();155 + for seg in path.split('/') {156 + match seg {157 + "" | "." => {}158 + ".." => {159 + parts.pop()?;160 + }161 + other => parts.push(other),162 + }163 + }164 + if parts.is_empty() {165 + return None;166 + }167 + Some(parts.join("/"))168 +}169 +170 +/// Extensions safe to display inline from `assets/`: raster/static171 +/// images whose bytes cannot execute as a document. SVG and HTML stay172 +/// downloads under `raw/`.173 +fn is_inline_image(path: &str) -> bool {174 + let base = path.rsplit('/').next().unwrap_or(path);175 + let Some((stem, ext)) = base.rsplit_once('.') else {176 + return false;177 + };178 + !stem.is_empty()179 + && matches!(180 + ext.to_ascii_lowercase().as_str(),181 + "png" | "jpg" | "jpeg" | "gif" | "webp" | "ico" | "avif"182 + )183 +}184 +185 +/// Rewrite one relative markdown destination to the generated route.186 +/// External URLs, fragments, absolute paths and anything that cannot be187 +/// resolved stay as-is. Query/fragment suffixes are preserved.188 +fn rewrite_dest(dest: &str, base: LinkBase<'_>, image: bool) -> Option<String> {189 + let d = dest.trim();190 + if d.is_empty() || d.starts_with(['#', '/']) || d.starts_with("//") {191 + return None;192 + }193 + // A real scheme (`http:`, `mailto:`, ...) is not a relative file.194 + if let Some(colon) = d.find(':') {195 + let scheme = &d[..colon];196 + let mut chars = scheme.chars();197 + if chars.next().is_some_and(|c| c.is_ascii_alphabetic())198 + && chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.'))199 + {200 + return None;201 + }202 + }203 + let cut = d.find(['?', '#']).unwrap_or(d.len());204 + let (path, suffix) = d.split_at(cut);205 + let target = rel_join(base_path(base), path)?;206 + let target = target.trim_end_matches('/');207 + if target.is_empty() {208 + return None;209 + }210 + let enc = encode_path(target);211 + let asset = match base {212 + LinkBase::Wiki(_) => format!("/assets/.forge/wiki/{enc}"),213 + LinkBase::Repo(_) => format!("/assets/{enc}"),214 + };215 + let route = if is_inline_image(target) {216 + asset217 + } else if image {218 + format!("/raw/{enc}")219 + } else {220 + match base {221 + LinkBase::Wiki(_) => format!("/wiki/{enc}.html"),222 + LinkBase::Repo(_) => {223 + let name = target.rsplit('/').next().unwrap_or(target);224 + if is_markdown(target) || name.eq_ignore_ascii_case("readme") {225 + format!("/files/{enc}.html")226 + } else {227 + format!("/raw/{enc}")228 + }229 + }230 + }231 + };232 + Some(format!("{route}{suffix}"))233 +}234 +235 +/// Strip simple inline/block tags for a heading slug. Entity decoding236 +/// runs afterwards so `&` becomes a word separator, not `amp`.237 +fn heading_text(inner: &str) -> String {238 + let mut out = String::with_capacity(inner.len());239 + let mut in_tag = false;240 + for c in inner.chars() {241 + match c {242 + '<' => in_tag = true,243 + '>' => in_tag = false,244 + _ if !in_tag => out.push(c),245 + _ => {}246 + }247 + }248 + decode_entities(&out)249 +}250 +251 +fn slugify(text: &str) -> String {252 + let mut out = String::new();253 + let mut dash = false;254 + for c in text.chars().flat_map(char::to_lowercase) {255 + if c.is_alphanumeric() {256 + out.push(c);257 + dash = false;258 + } else if !out.is_empty() && !dash {259 + out.push('-');260 + dash = true;261 + }262 + }263 + while out.ends_with('-') {264 + out.pop();265 + }266 + if out.is_empty() {267 + "section".to_string()268 + } else {269 + out270 + }271 +}272 +273 +fn heading_parts(line: &str) -> Option<(usize, &str, &str)> {274 + let b = line.as_bytes();275 + if b.len() < 8 || b[0] != b'<' || b[1] != b'h' || !(b'1'..=b'6').contains(&b[2]) || b[3] != b'>'276 + {277 + return None;278 + }279 + let level = usize::from(b[2] - b'0');280 + let close = format!("</h{level}>");281 + let end = line.find(&close)?;282 + Some((level, &line[4..end], &line[end + close.len()..]))283 +}284 +285 +/// Add deterministic heading ids so `#section` links work. Input is the286 +/// renderer's own output, so headings are one per line and contain only287 +/// generated tags; code-block lines are entity-escaped and never match.288 +fn add_heading_ids(html: &str) -> String {289 + let mut out = String::with_capacity(html.len() + 64);290 + let mut seen: BTreeMap<String, usize> = BTreeMap::new();291 + for line in html.split_inclusive('\n') {292 + if let Some((level, inner, tail)) = heading_parts(line) {293 + let base = slugify(&heading_text(inner));294 + let count = seen.entry(base.clone()).or_insert(0);295 + *count += 1;296 + let id = if *count == 1 {297 + base298 + } else {299 + format!("{base}-{}", *count)300 + };301 + out.push_str(&format!("<h{level} id=\"{id}\">{inner}</h{level}>{tail}"));302 + } else {303 + out.push_str(line);304 + }305 + }306 + out307 +}308 +309 +fn render_markdown(md: &str, base: Option<LinkBase<'_>>) -> String {310 use pulldown_cmark::{html, Event, Options, Parser, Tag};311 let options = Options::ENABLE_TABLES312 | Options::ENABLE_TASKLISTS313 @@ -215,7 +385,11 @@ pub fn markdown_to_html(md: &str) -> String {314 id,315 }) => {316 let dest = if safe_url(&dest_url) {317 - decode_entities(&dest_url)318 + let decoded = decode_entities(&dest_url);319 + match base.and_then(|b| rewrite_dest(&decoded, b, false)) {320 + Some(rewritten) => rewritten,321 + None => decoded,322 + }323 } else {324 "#".to_string()325 };326 @@ -233,7 +407,11 @@ pub fn markdown_to_html(md: &str) -> String {327 id,328 }) => {329 let dest = if safe_url(&dest_url) {330 - decode_entities(&dest_url)331 + let decoded = decode_entities(&dest_url);332 + match base.and_then(|b| rewrite_dest(&decoded, b, true)) {333 + Some(rewritten) => rewritten,334 + None => decoded,335 + }336 } else {337 "#".to_string()338 };339 @@ -249,7 +427,31 @@ pub fn markdown_to_html(md: &str) -> String {340 let mut out = String::from("<div class=\"markdown\">\n");341 html::push_html(&mut out, mapped);342 out.push_str("</div>\n");343 - out344 + add_heading_ids(&out)345 +}346 +347 +/// Render markdown to HTML, escaping raw HTML.348 +///349 +/// `pulldown-cmark` passes `Html`/`InlineHtml` events through untouched350 +/// (R1 finding), so map them through [`esc`] first. GFM tables, task351 +/// lists and strikethrough are enabled (f0rge #71); link/image352 +/// destinations are scheme-filtered (f0rge #62). Headings get stable353 +/// ids so `#section` links resolve (f0rge #112).354 +#[must_use]355 +pub fn markdown_to_html(md: &str) -> String {356 + render_markdown(md, None)357 +}358 +359 +/// Render markdown from a repo file page or wiki page, rewriting360 +/// relative links/images to generated routes (f0rge #112).361 +#[must_use]362 +pub fn markdown_to_html_in(md: &str, base: &str, wiki: bool) -> String {363 + let base = if wiki {364 + LinkBase::Wiki(base)365 + } else {366 + LinkBase::Repo(base)367 + };368 + render_markdown(md, Some(base))369 }370 371 /// Resolve the clone URL to advertise.372 @@ -547,6 +749,12 @@ pub fn now_unix() -> i64 {373 #[must_use]374 pub fn parse_ts_secs(s: &str) -> Option<i64> {375 let s = s.trim();376 + // Timestamps are ASCII by definition. Reject other text before any377 + // byte-index slicing: a 200-char multibyte `created` used to panic378 + // on a non-char-boundary (f0rge #108).379 + if !s.is_ascii() {380 + return None;381 + }382 // Accept both `2026-10-27T14:08:48Z` (RFC3339) and git's383 // `2026-10-27 14:08:48 +0000` / `+00:00` forms.384 let (date, rest) = match s.split_once('T') {385 @@ -1154,7 +1362,10 @@ pub const HEADERS_FILE: &str = "\386 X-Content-Type-Options: nosniff387 # Artifacts are build outputs copied from .forge/releases/out/<ver>/;388 # never let an artifact .html/.svg execute in the site origin (f0rge #97).389 -/releases/*/*390 +# Cloudflare Pages allows one splat per rule. A named `:version`391 +# placeholder plus one splat covers every artifact depth (f0rge #106).392 +# A two-splat rule is silently skipped by Pages and leaves artifacts active.393 +/releases/:version/*394 Content-Disposition: attachment395 X-Content-Type-Options: nosniff396 /*397 @@ -1486,6 +1697,17 @@ pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result<BuildSu398 std::fs::write(&dest, &blob.bytes).map_err(|e| format!("hu13: write raw {f}: {e}"))?;399 blobs.insert(f.clone(), blob.bytes);400 }401 + // Raster images mentioned by markdown need an inline route; `/raw`402 + // intentionally downloads everything. Copy only non-executable403 + // image formats under `/assets` (f0rge #112). SVG/HTML stay raw.404 + if is_inline_image(f) {405 + let adest = out.join("assets").join(f);406 + if let Some(parent) = adest.parent() {407 + std::fs::create_dir_all(parent)408 + .map_err(|e| format!("hu13: create assets parent: {e}"))?;409 + }410 + std::fs::copy(&dest, &adest).map_err(|e| format!("hu13: write asset {f}: {e}"))?;411 + }412 }413 414 // Per-path pages (m6 code-gitea, f0rge #33): Gitea-style file415 @@ -1591,7 +1813,8 @@ pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result<BuildSu416 bytes.len() as u64,417 Some(display.lines().count()),418 ));419 - body.push_str(&markdown_to_html(&display));420 + let base = f.rsplit_once('/').map(|(d, _)| d).unwrap_or("");421 + body.push_str(&markdown_to_html_in(&display, base, false));422 body.push_str(&format!(423 "<p class=\"muted\"><a href=\"/raw/{}\">view raw</a></p>\n",424 href_esc(f)425 @@ -1733,7 +1956,12 @@ pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result<BuildSu426 esc(path)427 ));428 if is_markdown(path) || path.to_lowercase().contains("readme") {429 - body.push_str(&markdown_to_html(&blob.display));430 + let (base, wiki) = if let Some(rest) = path.strip_prefix(".forge/wiki/") {431 + (rest.rsplit_once('/').map(|(d, _)| d).unwrap_or(""), true)432 + } else {433 + (path.rsplit_once('/').map(|(d, _)| d).unwrap_or(""), false)434 + };435 + body.push_str(&markdown_to_html_in(&blob.display, base, wiki));436 } else {437 body.push_str(&render_text_pre(&blob.display, path));438 }439 @@ -2070,7 +2298,7 @@ mod tests {440 let (_repo, out) = build_fixture();441 let html = std::fs::read_to_string(out.join("files/docs/guide.md.html")).expect("md page");442 assert!(443 - html.contains("<h1>Guide</h1>"),444 + html.contains("<h1 id=\"guide\">Guide</h1>"),445 "markdown rendered, got: {html}"446 );447 assert!(html.contains("/raw/docs/guide.md"), "raw link kept");448 @@ -2243,7 +2471,7 @@ mod tests {449 #[test]450 fn markdown_escapes_raw_html() {451 let html = markdown_to_html("# hi\n\n<script>alert(1)</script>\n\n**bold**\n");452 - assert!(html.contains("<h1>hi</h1>"), "heading renders");453 + assert!(html.contains("<h1 id=\"hi\">hi</h1>"), "heading renders");454 assert!(!html.contains("<script>"), "no raw passthrough");455 assert!(html.contains("<script>"), "escaped instead");456 assert!(457 @@ -3035,6 +3263,6 @@ mod tests {458 // Agents guide renders its markdown h1; spacing comes from the459 // shared `main>h1:first-child` gap-var rule (CSS assert in layout).460 let agents = read("agents.html");461 - assert!(agents.contains("<h1>"), "agents h1");462 + assert!(agents.contains("<h1"), "agents h1");463 }464 }465 diff --git a/src/forge.rs b/src/forge.rs466 index ecdbcba..d866287 100644467 --- a/src/forge.rs468 +++ b/src/forge.rs469 @@ -22,7 +22,7 @@ use std::path::Path;470 471 use serde::Deserialize;472 473 -use crate::build::markdown_to_html;474 +use crate::build::markdown_to_html_in;475 use crate::layout::{diff_html, esc, page_site_with_provenance};476 477 /// `.forge/` files larger than this are skipped (schema SEC-01: 256 KiB).478 @@ -89,6 +89,35 @@ fn read_text_capped(path: &Path, label: &str) -> Option<String> {479 }480 }481 482 +/// Like [`read_text_capped`], but keeps arbitrary bytes (CI logs may be483 +/// lossy UTF-8). Symlinks and oversized files are skipped, so `.forge`484 +/// cannot make the build read an unbounded or outside file (f0rge #107).485 +fn read_bytes_capped(path: &Path, label: &str) -> Option<Vec<u8>> {486 + match std::fs::symlink_metadata(path) {487 + Ok(m) if m.file_type().is_symlink() => {488 + eprintln!("hu13: forge: skipping {label}: symlink");489 + return None;490 + }491 + _ => {}492 + }493 + let len = std::fs::metadata(path).map(|m| m.len()).unwrap_or(0);494 + if len > FORGE_MAX_BYTES {495 + eprintln!("hu13: forge: skipping {label}: over 256 KiB ({len} bytes)");496 + return None;497 + }498 + match std::fs::read(path) {499 + Ok(bytes) if bytes.len() as u64 <= FORGE_MAX_BYTES => Some(bytes),500 + Ok(_) => {501 + eprintln!("hu13: forge: skipping {label}: over 256 KiB");502 + None503 + }504 + Err(e) => {505 + eprintln!("hu13: forge: skipping {label}: cannot read: {e}");506 + None507 + }508 + }509 +}510 +511 /// Parse YAML frontmatter; on error warn (naming file via `label` plus the512 /// serde error, which names the field) and return `None` (skip the file).513 fn parse_frontmatter<T>(yaml: &str, label: &str) -> Option<T>514 @@ -120,8 +149,13 @@ fn sorted_entries(dir: &Path, only_dirs: bool) -> Vec<String> {515 continue;516 }517 };518 - let is_dir = ent.file_type().map(|t| t.is_dir()).unwrap_or(false);519 - if is_dir != only_dirs {520 + let meta = match std::fs::symlink_metadata(ent.path()) {521 + Ok(m) => m,522 + Err(_) => continue,523 + };524 + // Never descend or read through a symlink: `.forge` entries are525 + // repo-controlled and must not escape the checkout (f0rge #107).526 + if meta.file_type().is_symlink() || meta.is_dir() != only_dirs {527 continue;528 }529 names.push(ent.file_name().to_string_lossy().into_owned());530 @@ -592,6 +626,10 @@ fn load_issues(root: &Path) -> Vec<Issue> {531 eprintln!("hu13: forge: skipping {label}: unsafe id {:?}", fm.id);532 continue;533 }534 + if crate::paths::is_reserved_index(&fm.id) {535 + eprintln!("hu13: forge: skipping {label}: reserved id index");536 + continue;537 + }538 let comments = load_comments(539 &dir.join(&uuid).join("comments"),540 &format!("issues/{uuid}/comments"),541 @@ -637,9 +675,13 @@ fn load_prs(root: &Path) -> Vec<Pr> {542 eprintln!("hu13: forge: skipping {label}: unsafe id {:?}", fm.id);543 continue;544 }545 - let patch = std::fs::read(dir.join(&uuid).join("patch.diff"))546 - .ok()547 - .map(|bytes| String::from_utf8_lossy(&bytes).into_owned());548 + if crate::paths::is_reserved_index(&fm.id) {549 + eprintln!("hu13: forge: skipping {label}: reserved id index");550 + continue;551 + }552 + // Symlink-safe and bounded: a hostile patch.diff used to be read553 + // whole, even through a symlink (f0rge #107).554 + let patch = read_text_capped(&dir.join(&uuid).join("patch.diff"), "patch.diff");555 let comments = load_comments(556 &dir.join(&uuid).join("comments"),557 &format!("prs/{uuid}/comments"),558 @@ -839,8 +881,12 @@ fn load_ci(root: &Path) -> CiInfo {559 if !name.ends_with(".log") {560 continue;561 }562 - let bytes = std::fs::read(dir.join("runs").join(&ts).join("steps").join(&name))563 - .unwrap_or_default();564 + let label = format!("ci/runs/{ts}/steps/{name}");565 + let bytes = read_bytes_capped(566 + &dir.join("runs").join(&ts).join("steps").join(&name),567 + &label,568 + )569 + .unwrap_or_default();570 let text = String::from_utf8_lossy(&bytes);571 logs.push((name, truncate_log(&redact_paths(&text, repo_root))));572 }573 @@ -874,7 +920,10 @@ fn load_ci(root: &Path) -> CiInfo {574 /// `.forge/` yields empty sections.575 #[must_use]576 pub fn load(repo: &Path) -> ForgeData {577 - let root = repo.join(".forge");578 + // Resolve `.forge` and require it to stay inside the repo. A symlinked579 + // `.forge` used to publish host issues/wiki/releases (f0rge #107).580 + let root = crate::paths::repo_subdir(repo, ".forge")581 + .unwrap_or_else(|| repo.join(".hu13-missing-forge"));582 ForgeData {583 issues: load_issues(&root),584 prs: load_prs(&root),585 @@ -971,7 +1020,7 @@ fn render_comment(c: &Comment, now: i64) -> String {586 esc(author),587 time_ago(&c.created, now),588 ));589 - out.push_str(&markdown_to_html(&c.body));590 + out.push_str(&markdown_to_html_in(&c.body, "", false));591 out.push_str("</li>\n");592 out593 }594 @@ -1022,7 +1071,7 @@ fn render_issue_page(595 esc(&issue.id)596 ));597 body.push_str(&details);598 - body.push_str(&markdown_to_html(&issue.body));599 + body.push_str(&markdown_to_html_in(&issue.body, "", false));600 body.push_str(&format!("<h2>Comments ({})</h2>\n", issue.comments.len()));601 if issue.comments.is_empty() {602 body.push_str("<p class=\"muted\">No comments yet.</p>\n");603 @@ -1096,7 +1145,7 @@ fn render_pr_page(604 esc(&pr.id)605 ));606 body.push_str(&details);607 - body.push_str(&markdown_to_html(&pr.body));608 + body.push_str(&markdown_to_html_in(&pr.body, "", false));609 body.push_str("<h2>Patch</h2>\n");610 match &pr.patch {611 Some(patch) if !patch.is_empty() => {612 @@ -1131,7 +1180,7 @@ fn render_pr_page(613 esc(author),614 time_ago(&r.created, now),615 esc(state),616 - markdown_to_html(&r.body),617 + markdown_to_html_in(&r.body, "", false),618 ));619 }620 body.push_str("</ul>\n");621 @@ -1312,7 +1361,7 @@ fn render_tasks_index(622 "<p class=\"muted\">assignee: <code>{}</code></p>\n",623 esc(a)624 )),625 - body_md = markdown_to_html(&t.body),626 + body_md = markdown_to_html_in(&t.body, "", false),627 ));628 }629 body.push_str("</ul>\n");630 diff --git a/src/paths.rs b/src/paths.rs631 index 437caaa..049b015 100644632 --- a/src/paths.rs633 +++ b/src/paths.rs634 @@ -7,7 +7,11 @@635 //!636 //! Rules: no absolute paths, no `\` or NUL, no empty/`.`/`..` segments.637 //! The checks are lexical on purpose — they run before anything is638 -//! created, so there is nothing to canonicalize yet.639 +//! created, so there is nothing to canonicalize yet. Filesystem readers640 +//! additionally use [`repo_subdir`] so a symlinked `.forge` cannot point641 +//! outside the checkout (f0rge #107).642 +643 +use std::path::{Path, PathBuf};644 645 /// `true` when `segment` is a single safe path component.646 #[must_use]647 @@ -24,6 +28,29 @@ pub fn is_safe_relative(rel: &str) -> bool {648 && rel.split('/').all(is_safe_segment)649 }650 651 +/// Resolve `repo/rel` through symlinks and require the result to stay652 +/// inside the resolved repo root. Returns `None` for missing paths,653 +/// symlink escapes, or the repo root itself. Used for untrusted654 +/// `.forge` trees (f0rge #107).655 +#[must_use]656 +pub fn repo_subdir(repo: &Path, rel: &str) -> Option<PathBuf> {657 + let root = std::fs::canonicalize(repo).ok()?;658 + let target = std::fs::canonicalize(root.join(rel)).ok()?;659 + if target == root || !target.starts_with(&root) {660 + return None;661 + }662 + Some(target)663 +}664 +665 +/// `true` for names that would overwrite a generated section index666 +/// (`releases/index.html`, `issues/index.html`, `pulls/index.html`,667 +/// `wiki/index.html`). Case-insensitive so a case-insensitive host668 +/// cannot collide either (f0rge #110).669 +#[must_use]670 +pub fn is_reserved_index(name: &str) -> bool {671 + name.eq_ignore_ascii_case("index")672 +}673 +674 /// `true` when `id` is safe both as a path segment and as a displayed675 /// forge id: ASCII alphanumerics, `-`, `_` only.676 #[must_use]677 @@ -71,4 +98,36 @@ mod tests {678 assert!(!is_safe_id(bad), "{bad:?} must be rejected");679 }680 }681 +682 + #[test]683 + fn reserved_index_names() {684 + for name in ["index", "INDEX", "Index"] {685 + assert!(is_reserved_index(name));686 + }687 + for name in ["index.html", "indexes", "my-index", ""] {688 + assert!(!is_reserved_index(name));689 + }690 + }691 +692 + #[test]693 + fn subdir_stays_inside_repo() {694 + let dir = std::env::temp_dir().join(format!(695 + "hu13-paths-{}-{}",696 + std::process::id(),697 + std::time::SystemTime::now()698 + .duration_since(std::time::UNIX_EPOCH)699 + .map(|d| d.as_nanos())700 + .unwrap_or(0)701 + ));702 + std::fs::create_dir_all(dir.join("inside")).unwrap();703 + std::fs::write(dir.join("inside/x"), "x").unwrap();704 + assert!(repo_subdir(&dir, "inside").is_some());705 + assert!(repo_subdir(&dir, "missing").is_none());706 + #[cfg(unix)]707 + {708 + std::os::unix::fs::symlink("/etc", dir.join("escape")).unwrap();709 + assert!(repo_subdir(&dir, "escape").is_none());710 + }711 + let _ = std::fs::remove_dir_all(&dir);712 + }713 }714 diff --git a/src/releases.rs b/src/releases.rs715 index 75acf0c..c9b9d28 100644716 --- a/src/releases.rs717 +++ b/src/releases.rs718 @@ -21,7 +21,7 @@ use std::path::{Path, PathBuf};719 720 use serde::Deserialize;721 722 -use crate::build::{href_esc, markdown_to_html};723 +use crate::build::{href_esc, markdown_to_html_in};724 use crate::layout::{esc, page_site_with_provenance};725 726 /// `.forge/` files larger than this are skipped (schema SEC-01: 256 KiB).727 @@ -125,6 +125,11 @@ fn read_text_capped(path: &Path, label: &str) -> Option<String> {728 /// Collect every file under `dir` as `(relative /-separated path, size)`,729 /// sorted by path. No size cap: the maintainer shipped it, hu13 serves it.730 fn collect_artifacts(dir: &Path) -> Vec<Artifact> {731 + // The version directory itself may be a symlink; never list outside.732 + match std::fs::symlink_metadata(dir) {733 + Ok(m) if m.is_dir() && !m.file_type().is_symlink() => {}734 + _ => return Vec::new(),735 + }736 let mut out = Vec::new();737 let mut stack: Vec<PathBuf> = vec![PathBuf::new()];738 while let Some(rel) = stack.pop() {739 @@ -179,6 +184,10 @@ fn collect_artifacts(dir: &Path) -> Vec<Artifact> {740 741 /// Copy the WHOLE `src_dir` tree to `dest_dir` (no size cap).742 fn copy_tree(src_dir: &Path, dest_dir: &Path) -> Result<(), String> {743 + match std::fs::symlink_metadata(src_dir) {744 + Ok(m) if m.is_dir() && !m.file_type().is_symlink() => {}745 + _ => return Ok(()),746 + }747 let mut stack: Vec<PathBuf> = vec![PathBuf::new()];748 while let Some(rel) = stack.pop() {749 let src = src_dir.join(&rel);750 @@ -226,7 +235,9 @@ fn copy_tree(src_dir: &Path, dest_dir: &Path) -> Result<(), String> {751 /// an empty vec. Order is `(created, version)`.752 #[must_use]753 pub fn load(repo: &Path) -> Vec<Release> {754 - let root = repo.join(".forge").join("releases");755 + let Some(root) = crate::paths::repo_subdir(repo, ".forge/releases") else {756 + return Vec::new();757 + };758 let rd = match std::fs::read_dir(&root) {759 Ok(rd) => rd,760 Err(_) => return Vec::new(),761 @@ -280,6 +291,10 @@ pub fn load(repo: &Path) -> Vec<Release> {762 );763 continue;764 }765 + if crate::paths::is_reserved_index(&fm.version) {766 + eprintln!("hu13: releases: skipping {label}: reserved version index");767 + continue;768 + }769 // Artifacts: present only when `out/<version>/` exists at build.770 // Absent -> notes-only, no warning.771 let out_dir = root.join("out").join(&fm.version);772 @@ -366,7 +381,7 @@ fn render_release_page(773 esc(sha)774 ));775 }776 - body.push_str(&markdown_to_html(&rel.body));777 + body.push_str(&markdown_to_html_in(&rel.body, "", false));778 if rel.artifacts.is_empty() {779 body.push_str("<p class=\"muted\">No artifacts attached.</p>\n");780 } else {781 @@ -428,7 +443,7 @@ pub fn render_loaded(782 short_sha: &str,783 description: Option<&str>,784 ) -> Result<(), String> {785 - let root = repo.join(".forge").join("releases");786 + let root = crate::paths::repo_subdir(repo, ".forge/releases");787 let mut body = String::from("<div class=\"page-head\"><h1>Releases</h1></div>\n");788 if releases.is_empty() {789 body.push_str("<p class=\"muted\">No releases yet.</p>\n");790 @@ -472,11 +487,14 @@ pub fn render_loaded(791 rel.version792 ));793 }794 - // Copy the whole out dir when present (no size cap).795 - let src_dir = root.join("out").join(&rel.version);796 - if src_dir.is_dir() {797 - let dest_dir = out.join("releases").join(&rel.version);798 - copy_tree(&src_dir, &dest_dir)?;799 + // Copy the whole out dir when present (no size cap). The root is800 + // resolved once; copy_tree still refuses a symlinked version dir.801 + if let Some(root) = root.as_ref() {802 + let src_dir = root.join("out").join(&rel.version);803 + if src_dir.is_dir() {804 + let dest_dir = out.join("releases").join(&rel.version);805 + copy_tree(&src_dir, &dest_dir)?;806 + }807 }808 let html = render_release_page(rel, site_name, nav, branch, short_sha, description);809 let page_dest = out.join("releases").join(format!("{}.html", rel.version));810 diff --git a/src/serve.rs b/src/serve.rs811 index 9edb446..89bf867 100644812 --- a/src/serve.rs813 +++ b/src/serve.rs814 @@ -110,8 +110,12 @@ struct Shared {815 /// startup. Returns `None` when no bundle exists or git fails (serve816 /// continues static-only; `/repo.bundle` still downloads via GET).817 fn prepare_git_dir(www: &Path) -> Option<PathBuf> {818 - let bundle = www.join("repo.bundle");819 - if !bundle.is_file() {820 + // Static serving already rejects an outside symlink; smart HTTP must821 + // use the same resolved-path check (f0rge #117). A symlink pointing822 + // inside www is allowed, but a symlink to a private bundle is not.823 + let www_root = resolve_existing(www);824 + let bundle = resolve_existing(&www.join("repo.bundle"));825 + if bundle == www_root || !bundle.starts_with(&www_root) || !bundle.is_file() {826 return None;827 }828 let dir = std::env::temp_dir().join(format!(829 @@ -428,7 +432,7 @@ fn respond(830 body: &[u8],831 ) {832 let mut out = format!(833 - "HTTP/1.1 {code} {}\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nX-Content-Type-Options: nosniff\r\nConnection: close\r\n",834 + "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",835 reason(code),836 body.len()837 );838 @@ -492,6 +496,8 @@ fn content_type_for(name: &str) -> &'static str {839 "image/webp"840 } else if lower.ends_with(".ico") {841 "image/x-icon"842 + } else if lower.ends_with(".avif") {843 + "image/avif"844 } else if lower.ends_with(".webmanifest") {845 "application/manifest+json"846 } else if lower.ends_with(".json") {847 @@ -888,7 +894,7 @@ fn respond_file_head(848 len: u64,849 ) {850 let mut out = format!(851 - "HTTP/1.1 {code} {}\r\nContent-Type: {content_type}\r\nContent-Length: {len}\r\nX-Content-Type-Options: nosniff\r\nConnection: close\r\n",852 + "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",853 reason(code)854 );855 for (k, v) in extra {856 @@ -910,7 +916,12 @@ fn static_not_found(stream: &mut TcpStream, head: &Head, shared: &Shared) {857 static_error(stream, 404);858 return;859 }860 - let dest = shared.www.join("404.html");861 + let www = resolve_existing(&shared.www);862 + let dest = resolve_existing(&shared.www.join("404.html"));863 + if dest == www || !dest.starts_with(&www) {864 + static_error(stream, 404);865 + return;866 + }867 let Ok(meta) = std::fs::metadata(&dest) else {868 static_error(stream, 404);869 return;870 diff --git a/src/wiki.rs b/src/wiki.rs871 index 25bfc48..8465473 100644872 --- a/src/wiki.rs873 +++ b/src/wiki.rs874 @@ -14,7 +14,7 @@875 use std::collections::BTreeMap;876 use std::path::Path;877 878 -use crate::build::{href_esc, markdown_to_html};879 +use crate::build::{href_esc, markdown_to_html_in};880 use crate::layout::{esc, page_site_with_provenance};881 882 /// Wiki files larger than this are skipped (schema SEC-01: 256 KiB).883 @@ -75,7 +75,8 @@ pub fn page_title(body: &str, filename: &str) -> String {884 /// [`render`] would emit for skipped entries.885 #[must_use]886 pub fn page_count(repo: &Path) -> usize {887 - let root = repo.join(".forge").join("wiki");888 + let root = crate::paths::repo_subdir(repo, ".forge/wiki")889 + .unwrap_or_else(|| repo.join(".hu13-missing-wiki"));890 if !root.exists() {891 return 0;892 }893 @@ -128,7 +129,7 @@ pub fn page_count(repo: &Path) -> usize {894 };895 if meta.is_dir() {896 stack.push(rel);897 - } else if meta.is_file() {898 + } else if meta.is_file() && !crate::paths::is_reserved_index(&rel) {899 rel_files.push(rel);900 }901 }902 @@ -169,7 +170,8 @@ pub fn render(903 short_sha: &str,904 description: Option<&str>,905 ) -> Result<Vec<WikiPage>, String> {906 - let root = repo.join(".forge").join("wiki");907 + let root = crate::paths::repo_subdir(repo, ".forge/wiki")908 + .unwrap_or_else(|| repo.join(".hu13-missing-wiki"));909 let mut rel_files: Vec<String> = Vec::new();910 if root.exists() {911 // Iterative walk over `/`-separated relative dirs. Symlinked dirs912 @@ -226,7 +228,7 @@ pub fn render(913 };914 if meta.is_dir() {915 stack.push(rel);916 - } else if meta.is_file() {917 + } else if meta.is_file() && !crate::paths::is_reserved_index(&rel) {918 rel_files.push(rel);919 }920 }921 @@ -317,7 +319,8 @@ pub fn render(922 esc(&p.title),923 esc(&p.path),924 );925 - body.push_str(&markdown_to_html(&text));926 + let base = p.path.rsplit_once('/').map(|(d, _)| d).unwrap_or("");927 + body.push_str(&markdown_to_html_in(&text, base, true));928 let html = page_site_with_provenance(929 &format!("{} — wiki — {site_name}", p.title),930 nav,931 diff --git a/tests/pages_inbox.test.mjs b/tests/pages_inbox.test.mjs932 index a825c71..1696bae 100644933 --- a/tests/pages_inbox.test.mjs934 +++ b/tests/pages_inbox.test.mjs935 @@ -10,10 +10,16 @@ class FakeBucket {936 constructor() {937 this.objects = new Map();938 }939 - async list({ limit = 1000 } = {}) {940 + async list({ limit = 1000, cursor } = {}) {941 + const keys = [...this.objects.keys()];942 + const start = cursor === undefined ? 0 : Number(cursor);943 + const objects = keys.slice(start, start + limit).map((key) => ({ key }));944 + const next = start + objects.length;945 + const truncated = next < keys.length;946 return {947 - objects: [...this.objects.keys()].slice(0, limit).map((key) => ({ key })),948 - truncated: this.objects.size > limit,949 + objects,950 + truncated,951 + cursor: truncated ? String(next) : undefined,952 };953 }954 async put(key, value) {955 @@ -159,6 +165,15 @@ test("quota is enforced before storing", async () => {956 assert.equal(e.INBOX.objects.size, 1);957 });958 959 +test("quota above 1000 paginates R2 listings", async () => {960 + const bucket = new FakeBucket();961 + for (let i = 0; i < 1500; i++) bucket.objects.set(`old-${i}.json`, "x");962 + const e = env({ INBOX: bucket, INBOX_QUOTA: "1500" });963 + const { res } = await post("issue", validIssue, {}, e);964 + assert.equal(res.status, 503);965 + assert.equal(bucket.objects.size, 1500, "full inbox did not store");966 +});967 +968 test("missing R2 binding or salt fails closed", async () => {969 const noBucket = await post("issue", validIssue, {}, { INBOX_SALT: "x" });970 assert.equal(noBucket.res.status, 503);971 diff --git a/tests/review4_hardening.rs b/tests/review4_hardening.rs972 index 961f0bc..371bdc6 100644973 --- a/tests/review4_hardening.rs974 +++ b/tests/review4_hardening.rs975 @@ -222,7 +222,11 @@ fn review4_build_and_release_hardening() {976 );977 978 let headers = read(&out.join("_headers"));979 - assert!(headers.contains("/releases/*/*"), "{headers}");980 + assert!(headers.contains("/releases/:version/*"), "{headers}");981 + assert!(982 + !headers.contains("/releases/*/*"),983 + "two splats are invalid on Pages: {headers}"984 + );985 assert!(986 headers.contains("Content-Disposition: attachment"),987 "{headers}"988 diff --git a/tests/review5_hardening.rs b/tests/review5_hardening.rs989 new file mode 100644990 index 0000000..9756eaa991 --- /dev/null992 +++ b/tests/review5_hardening.rs993 @@ -0,0 +1,477 @@994 +//! Review #5 hardening (f0rge #106–#117): one-splat Pages artifact995 +//! headers, symlink-safe `.forge` reads, multibyte timestamps, reserved996 +//! indexes, context-aware markdown links/images/anchors, and `serve`997 +//! header/404/bundle parity.998 +999 +use std::io::{BufRead, BufReader, Read, Write};1000 +use std::net::TcpStream;1001 +use std::path::{Path, PathBuf};1002 +use std::process::{Child, Command, Stdio};1003 +use std::sync::atomic::{AtomicU64, Ordering};1004 +use std::time::Duration;1005 +1006 +static SEQ: AtomicU64 = AtomicU64::new(0);1007 +1008 +fn fresh(prefix: &str) -> PathBuf {1009 + let dir = std::env::temp_dir().join(format!(1010 + "hu13-review5-{prefix}-{}-{}",1011 + std::process::id(),1012 + std::time::SystemTime::now()1013 + .duration_since(std::time::UNIX_EPOCH)1014 + .map(|d| d.as_nanos())1015 + .unwrap_or(0)1016 + .wrapping_add(u128::from(SEQ.fetch_add(1, Ordering::Relaxed)))1017 + ));1018 + std::fs::create_dir_all(&dir).unwrap();1019 + dir1020 +}1021 +1022 +fn git(repo: &Path, args: &[&str]) {1023 + let st = Command::new("git")1024 + .arg("-C")1025 + .arg(repo)1026 + .args(args)1027 + .status()1028 + .expect("spawn git");1029 + assert!(st.success(), "git {args:?} failed");1030 +}1031 +1032 +fn write(repo: &Path, rel: &str, body: &str) {1033 + let dest = repo.join(rel);1034 + std::fs::create_dir_all(dest.parent().expect("parent")).unwrap();1035 + std::fs::write(dest, body).unwrap();1036 +}1037 +1038 +fn init_repo() -> PathBuf {1039 + let repo = fresh("repo");1040 + git(&repo, &["init", "-b", "main"]);1041 + git(&repo, &["config", "user.name", "hu13-test"]);1042 + git(&repo, &["config", "user.email", "[email protected]"]);1043 + git(&repo, &["config", "commit.gpgsign", "false"]);1044 + repo1045 +}1046 +1047 +fn commit(repo: &Path, msg: &str) {1048 + git(repo, &["add", "-A"]);1049 + git(repo, &["commit", "-qm", msg]);1050 +}1051 +1052 +fn build(repo: &Path, out: &Path) -> std::process::Output {1053 + Command::new(env!("CARGO_BIN_EXE_hu13"))1054 + .arg("build")1055 + .arg(repo)1056 + .arg("-o")1057 + .arg(out)1058 + .output()1059 + .expect("spawn hu13 build")1060 +}1061 +1062 +fn read(path: &Path) -> String {1063 + std::fs::read_to_string(path).unwrap_or_else(|e| panic!("read {}: {e}", path.display()))1064 +}1065 +1066 +struct Server {1067 + child: Child,1068 + addr: String,1069 +}1070 +1071 +impl Drop for Server {1072 + fn drop(&mut self) {1073 + self.child.kill().ok();1074 + self.child.wait().ok();1075 + }1076 +}1077 +1078 +fn start(www: &Path, inbox: &Path) -> Server {1079 + let mut cmd = Command::new(env!("CARGO_BIN_EXE_hu13"));1080 + cmd.arg("serve")1081 + .arg("--www")1082 + .arg(www)1083 + .arg("--inbox-dir")1084 + .arg(inbox)1085 + .arg("--listen")1086 + .arg("127.0.0.1:0")1087 + .stdout(Stdio::piped())1088 + .stderr(Stdio::null());1089 + let mut child = cmd.spawn().expect("spawn serve");1090 + let stdout = child.stdout.take().expect("stdout");1091 + let mut addr = String::new();1092 + for line in BufReader::new(stdout).lines().take(50).flatten() {1093 + if let Some(rest) = line.split("listening on ").nth(1) {1094 + addr = rest.trim().to_string();1095 + break;1096 + }1097 + }1098 + assert!(!addr.is_empty(), "serve printed no listening line");1099 + Server { child, addr }1100 +}1101 +1102 +fn get(addr: &str, path: &str) -> String {1103 + let mut s = TcpStream::connect(addr).expect("connect");1104 + s.set_read_timeout(Some(Duration::from_secs(10))).unwrap();1105 + write!(1106 + s,1107 + "GET {path} HTTP/1.1\r\nHost: {addr}\r\nConnection: close\r\n\r\n"1108 + )1109 + .unwrap();1110 + let mut out = Vec::new();1111 + s.read_to_end(&mut out).unwrap();1112 + String::from_utf8_lossy(&out).into_owned()1113 +}1114 +1115 +#[test]1116 +fn pages_headers_use_one_splat_for_artifacts() {1117 + let repo = init_repo();1118 + write(&repo, "README.md", "# headers\n");1119 + commit(&repo, "init");1120 + let out = fresh("headers-out");1121 + let res = build(&repo, &out);1122 + assert!(1123 + res.status.success(),1124 + "{}",1125 + String::from_utf8_lossy(&res.stderr)1126 + );1127 + let headers = read(&out.join("_headers"));1128 + assert!(headers.contains("/releases/:version/*"), "{headers}");1129 + assert!(!headers.contains("/releases/*/*"), "invalid two-splat rule");1130 + let rules: Vec<&str> = headers.lines().filter(|l| l.starts_with('/')).collect();1131 + for rule in rules {1132 + assert!(1133 + rule.matches('*').count() <= 1,1134 + "Pages allows one splat per rule: {rule}"1135 + );1136 + }1137 +}1138 +1139 +#[test]1140 +fn multibyte_timestamps_do_not_panic() {1141 + let repo = init_repo();1142 + write(&repo, "README.md", "# timestamps\n");1143 + write(1144 + &repo,1145 + ".forge/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/issue.md",1146 + "---\nid: aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa\ntitle: T\nstate: open\ncreated: \"2026-01-01 12:34:56+€!\"\n---\nbody\n",1147 + );1148 + commit(&repo, "init");1149 + let out = fresh("ts-out");1150 + let res = build(&repo, &out);1151 + assert!(1152 + res.status.success(),1153 + "build panicked? {}",1154 + String::from_utf8_lossy(&res.stderr)1155 + );1156 + let issues = read(&out.join("issues/index.html"));1157 + assert!(issues.contains("€"), "timestamp text preserved");1158 +}1159 +1160 +#[cfg(unix)]1161 +#[test]1162 +fn symlinked_forge_trees_and_files_are_ignored() {1163 + let secret = fresh("secret");1164 + write(&secret, "wiki/leak.md", "TOP SECRET WIKI\n");1165 + write(1166 + &secret,1167 + "releases/out/v1/leak.html",1168 + "TOP SECRET ARTIFACT\n",1169 + );1170 + write(&secret, "patch.diff", "TOP SECRET PATCH\n");1171 + write(&secret, "steps/00.log", "TOP SECRET LOG\n");1172 +1173 + let repo = init_repo();1174 + write(&repo, "README.md", "# symlinks\n");1175 + write(1176 + &repo,1177 + ".forge/releases/v1.md",1178 + "---\nversion: v1\ncreated: 2026-01-01T00:00:00Z\n---\nnotes\n",1179 + );1180 + write(1181 + &repo,1182 + ".forge/prs/cccccccc-cccc-cccc-cccc-cccccccccccc/pr.md",1183 + "---\nid: cccccccc-cccc-cccc-cccc-cccccccccccc\ntitle: P\nstate: open\nbase: main\nhead: topic\ncreated: 2026-01-01T00:00:00Z\n---\nbody\n",1184 + );1185 + write(1186 + &repo,1187 + ".forge/ci/runs/2026-01-01T00-00-00Z/summary.md",1188 + "---\nrun: 2026-01-01T00-00-00Z\nverdict: pass\ncreated: 2026-01-01T00:00:00Z\nsteps: []\n---\nsummary\n",1189 + );1190 + write(&repo, ".forge/ci/steps/00_test.sh", "true\n");1191 + std::fs::create_dir_all(repo.join(".forge/releases/out")).unwrap();1192 + std::os::unix::fs::symlink(secret.join("wiki"), repo.join(".forge/wiki")).unwrap();1193 + std::os::unix::fs::symlink(1194 + secret.join("releases/out/v1"),1195 + repo.join(".forge/releases/out/v1"),1196 + )1197 + .unwrap();1198 + std::os::unix::fs::symlink(1199 + secret.join("patch.diff"),1200 + repo.join(".forge/prs/cccccccc-cccc-cccc-cccc-cccccccccccc/patch.diff"),1201 + )1202 + .unwrap();1203 + std::fs::create_dir_all(repo.join(".forge/ci/runs/2026-01-01T00-00-00Z/steps")).unwrap();1204 + std::os::unix::fs::symlink(1205 + secret.join("steps/00.log"),1206 + repo.join(".forge/ci/runs/2026-01-01T00-00-00Z/steps/00.log"),1207 + )1208 + .unwrap();1209 + commit(&repo, "symlinks");1210 +1211 + let out = fresh("symlink-out");1212 + let res = build(&repo, &out);1213 + assert!(1214 + res.status.success(),1215 + "{}",1216 + String::from_utf8_lossy(&res.stderr)1217 + );1218 + let mut hits = Vec::new();1219 + for entry in walk(&out) {1220 + if let Ok(text) = std::fs::read_to_string(&entry) {1221 + if text.contains("TOP SECRET") {1222 + hits.push(entry);1223 + }1224 + }1225 + }1226 + assert!(hits.is_empty(), "host files published: {hits:?}");1227 + assert!(!out.join("wiki/leak.md.html").exists());1228 + assert!(!out.join("releases/v1/leak.html").exists());1229 +}1230 +1231 +#[cfg(unix)]1232 +#[test]1233 +fn symlinked_forge_root_is_not_read() {1234 + let secret = fresh("root-secret");1235 + write(&secret, "wiki/leak.md", "TOP SECRET ROOT\n");1236 + write(1237 + &secret,1238 + "releases/v1.md",1239 + "---\nversion: v1\ncreated: 2026-01-01T00:00:00Z\n---\nTOP SECRET ROOT\n",1240 + );1241 + let repo = init_repo();1242 + write(&repo, "README.md", "# root\n");1243 + std::os::unix::fs::symlink(&secret, repo.join(".forge")).unwrap();1244 + commit(&repo, "root symlink");1245 + let out = fresh("root-out");1246 + let res = build(&repo, &out);1247 + assert!(1248 + res.status.success(),1249 + "{}",1250 + String::from_utf8_lossy(&res.stderr)1251 + );1252 + for entry in walk(&out) {1253 + if let Ok(text) = std::fs::read_to_string(&entry) {1254 + assert!(!text.contains("TOP SECRET ROOT"), "{}", entry.display());1255 + }1256 + }1257 +}1258 +1259 +fn walk(root: &Path) -> Vec<PathBuf> {1260 + let mut out = Vec::new();1261 + let mut stack = vec![root.to_path_buf()];1262 + while let Some(dir) = stack.pop() {1263 + let Ok(rd) = std::fs::read_dir(&dir) else {1264 + continue;1265 + };1266 + for ent in rd.flatten() {1267 + let p = ent.path();1268 + if p.is_dir() {1269 + stack.push(p);1270 + } else {1271 + out.push(p);1272 + }1273 + }1274 + }1275 + out1276 +}1277 +1278 +#[test]1279 +fn reserved_index_names_do_not_hijack_section_indexes() {1280 + let repo = init_repo();1281 + write(&repo, "README.md", "# indexes\n");1282 + write(1283 + &repo,1284 + ".forge/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/issue.md",1285 + "---\nid: index\ntitle: Hijack Issue\nstate: open\ncreated: 2026-01-01T00:00:00Z\n---\nbody\n",1286 + );1287 + write(1288 + &repo,1289 + ".forge/prs/bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb/pr.md",1290 + "---\nid: index\ntitle: Hijack Pull\nstate: open\nbase: main\nhead: topic\ncreated: 2026-01-01T00:00:00Z\n---\nbody\n",1291 + );1292 + write(1293 + &repo,1294 + ".forge/releases/index.md",1295 + "---\nversion: index\ncreated: 2026-01-01T00:00:00Z\n---\nHijack Release\n",1296 + );1297 + write(&repo, ".forge/wiki/index", "# Hijack Wiki\n");1298 + commit(&repo, "indexes");1299 +1300 + let out = fresh("index-out");1301 + let res = build(&repo, &out);1302 + assert!(1303 + res.status.success(),1304 + "{}",1305 + String::from_utf8_lossy(&res.stderr)1306 + );1307 + let stderr = String::from_utf8_lossy(&res.stderr);1308 + assert!(stderr.contains("reserved"), "warned: {stderr}");1309 + for (rel, marker) in [1310 + ("issues/index.html", "Hijack Issue"),1311 + ("pulls/index.html", "Hijack Pull"),1312 + ("releases/index.html", "Hijack Release"),1313 + ("wiki/index.html", "Hijack Wiki"),1314 + ] {1315 + let page = read(&out.join(rel));1316 + assert!(!page.contains(marker), "{rel} hijacked by {marker}");1317 + }1318 + assert!(read(&out.join("issues/index.html")).contains("Issues"));1319 + assert!(read(&out.join("pulls/index.html")).contains("Pulls"));1320 + assert!(read(&out.join("releases/index.html")).contains("Releases"));1321 + assert!(read(&out.join("wiki/index.html")).contains("Wiki"));1322 +}1323 +1324 +#[test]1325 +fn markdown_links_images_and_heading_anchors_resolve() {1326 + let repo = init_repo();1327 + write(1328 + &repo,1329 + "README.md",1330 + "# Root\n\n## Section One\n\n[guide](docs/guide.md#section-one) \n\n[web](https://example.com)\n",1331 + );1332 + write(1333 + &repo,1334 + "docs/guide.md",1335 + "# Guide\n\n[peer](peer.md) [root](../README.md) \n",1336 + );1337 + write(&repo, "docs/peer.md", "# Peer\n");1338 + std::fs::create_dir_all(repo.join("docs")).unwrap();1339 + std::fs::write(repo.join("docs/pic.png"), b"\x89PNG\r\n\x1a\n").unwrap();1340 + write(1341 + &repo,1342 + ".forge/wiki/home.md",1343 + "# Wiki Home\n\n[other](other.md) \n",1344 + );1345 + write(&repo, ".forge/wiki/other.md", "# Other\n");1346 + std::fs::write(repo.join(".forge/wiki/pic.png"), b"\x89PNG\r\n\x1a\n").unwrap();1347 + write(1348 + &repo,1349 + ".forge/releases/v1.md",1350 + "---\nversion: v1\ncreated: 2026-01-01T00:00:00Z\n---\n# Release\n\n[guide](docs/guide.md) \n",1351 + );1352 + write(1353 + &repo,1354 + ".forge/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/issue.md",1355 + "---\nid: aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa\ntitle: Link issue\nstate: open\ncreated: 2026-01-01T00:00:00Z\n---\n# Issue\n\n[guide](docs/guide.md) \n",1356 + );1357 + write(1358 + &repo,1359 + ".forge/prs/bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb/pr.md",1360 + "---\nid: bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb\ntitle: Link PR\nstate: open\nbase: main\nhead: topic\ncreated: 2026-01-01T00:00:00Z\n---\n# PR\n\n[guide](docs/guide.md) \n",1361 + );1362 + commit(&repo, "markdown");1363 +1364 + let out = fresh("md-out");1365 + let res = build(&repo, &out);1366 + assert!(1367 + res.status.success(),1368 + "{}",1369 + String::from_utf8_lossy(&res.stderr)1370 + );1371 +1372 + let index = read(&out.join("index.html"));1373 + assert!(index.contains("id=\"root\""), "{index}");1374 + assert!(index.contains("id=\"section-one\""), "{index}");1375 + assert!(1376 + index.contains("href=\"/files/docs/guide.md.html#section-one\""),1377 + "{index}"1378 + );1379 + assert!(index.contains("src=\"/assets/docs/pic.png\""), "{index}");1380 + assert!(1381 + index.contains("href=\"https://example.com\""),1382 + "external kept"1383 + );1384 +1385 + let guide = read(&out.join("files/docs/guide.md.html"));1386 + assert!(guide.contains("id=\"guide\""), "{guide}");1387 + assert!(1388 + guide.contains("href=\"/files/docs/peer.md.html\""),1389 + "{guide}"1390 + );1391 + assert!(guide.contains("href=\"/files/README.md.html\""), "{guide}");1392 + assert!(guide.contains("src=\"/assets/docs/pic.png\""), "{guide}");1393 +1394 + let wiki = read(&out.join("wiki/home.md.html"));1395 + assert!(wiki.contains("id=\"wiki-home\""), "{wiki}");1396 + assert!(wiki.contains("href=\"/wiki/other.md.html\""), "{wiki}");1397 + assert!(1398 + wiki.contains("src=\"/assets/.forge/wiki/pic.png\""),1399 + "{wiki}"1400 + );1401 + assert!(out.join("assets/docs/pic.png").is_file());1402 + assert!(out.join("assets/.forge/wiki/pic.png").is_file());1403 +1404 + for rel in [1405 + "releases/v1.html",1406 + "issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa.html",1407 + "pulls/bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb.html",1408 + ] {1409 + let page = read(&out.join(rel));1410 + assert!(1411 + page.contains("href=\"/files/docs/guide.md.html\""),1412 + "{rel}: {page}"1413 + );1414 + assert!(1415 + page.contains("src=\"/assets/docs/pic.png\""),1416 + "{rel}: {page}"1417 + );1418 + }1419 +}1420 +1421 +#[cfg(unix)]1422 +#[test]1423 +fn serve_404_headers_and_bundle_symlinks_are_safe() {1424 + let secret = fresh("serve-secret");1425 + write(&secret, "secret.html", "TOP SECRET 404\n");1426 + let www = fresh("serve-www");1427 + write(&www, "index.html", "<p>ok</p>\n");1428 + std::os::unix::fs::symlink(secret.join("secret.html"), www.join("404.html")).unwrap();1429 +1430 + let src = fresh("bundle-src");1431 + git(&src, &["init", "-b", "main"]);1432 + git(&src, &["config", "user.name", "hu13-test"]);1433 + git(&src, &["config", "user.email", "[email protected]"]);1434 + write(&src, "a.txt", "secret\n");1435 + commit(&src, "init");1436 + let bundle = fresh("private-bundle");1437 + git(1438 + &src,1439 + &[1440 + "bundle",1441 + "create",1442 + bundle.join("repo.bundle").to_str().unwrap(),1443 + "main",1444 + ],1445 + );1446 + std::os::unix::fs::symlink(bundle.join("repo.bundle"), www.join("repo.bundle")).unwrap();1447 +1448 + let inbox = fresh("serve-inbox");1449 + let srv = start(&www, &inbox);1450 + std::thread::sleep(Duration::from_millis(100));1451 +1452 + let root = get(&srv.addr, "/");1453 + assert!(root.starts_with("HTTP/1.1 200"), "{root}");1454 + assert!(1455 + root.contains("Referrer-Policy: strict-origin-when-cross-origin"),1456 + "{root}"1457 + );1458 +1459 + let missing = get(&srv.addr, "/nope");1460 + assert!(missing.starts_with("HTTP/1.1 404"), "{missing}");1461 + assert!(!missing.contains("TOP SECRET 404"), "{missing}");1462 + assert!(1463 + missing.contains("Referrer-Policy: strict-origin-when-cross-origin"),1464 + "{missing}"1465 + );1466 +1467 + let refs = get(&srv.addr, "/repo.bundle/info/refs?service=git-upload-pack");1468 + assert!(refs.starts_with("HTTP/1.1 404"), "{refs}");1469 + assert!(!refs.contains("git-upload-pack-advertisement"), "{refs}");1470 +}1471 -- 1472 2.56.01473