From ee6e71b2eb403bfa513cf2a3465991004a777c65 Mon Sep 17 00:00:00 2001
From: hu13-test
Date: Tue, 27 Oct 2026 19:49:57 +0000
Subject: [PATCH] review #5: harden Pages headers, .forge symlinks, links,
origin/static parity (#106-#117)
---
.forge/wiki/deploy | 5 +
docs/cloudflare.md | 12 +-
functions/inbox/[kind].js | 25 +-
src/build.rs | 262 ++++++++++++++++++--
src/forge.rs | 77 ++++--
src/paths.rs | 61 ++++-
src/releases.rs | 36 ++-
src/serve.rs | 21 +-
src/wiki.rs | 15 +-
tests/pages_inbox.test.mjs | 21 +-
tests/review4_hardening.rs | 6 +-
tests/review5_hardening.rs | 477 +++++++++++++++++++++++++++++++++++++
12 files changed, 958 insertions(+), 60 deletions(-)
create mode 100644 tests/review5_hardening.rs
diff --git a/.forge/wiki/deploy b/.forge/wiki/deploy
index ac9b9d3..d13f21c 100644
--- a/.forge/wiki/deploy
+++ b/.forge/wiki/deploy
@@ -70,6 +70,11 @@ What happens inside (`entrypoint.sh`):
`/releases//**` download policy for Pages. On nginx, add the
equivalent `add_header Content-Disposition attachment` for `/raw/` and
release artifact directories — committed HTML/SVG must never render.
+- Pages clean-URL redirects: Pages answers `.html` paths with a 308
+ (`/issues/index.html` → `/issues/`, `/files/a.md.html` →
+ `/files/a.md`); the links still resolve, and Docker/nginx serve the
+ exact path. `_redirects` status 200 cannot override this. If exact URL
+ shape matters, use a proxy/Worker or accept the Pages form.
- Cloudflare Pages inbox: the static `dist/` alone has **no inbox**. The
Pages Function in `functions/inbox/[kind].js` + R2 binding `INBOX` and
secret `INBOX_SALT` implement the same contract. See
diff --git a/docs/cloudflare.md b/docs/cloudflare.md
index b561c9e..7e6c420 100644
--- a/docs/cloudflare.md
+++ b/docs/cloudflare.md
@@ -4,6 +4,14 @@ The same `dist/` works on Docker/`hu13 serve`, nginx, and Pages. On
Pages the inbox is implemented by a Pages Function backed by R2; a
static-only upload has no working inbox.
+Pages normalizes `.html` request paths with a 308 (for example
+`/issues/index.html` → `/issues/` and `/files/a.md.html` →
+`/files/a.md`). Every generated link still reaches the same page; the
+redirect is a Pages platform behavior, not a missing file. Docker/nginx
+serve the exact `.html` path with no redirect. If byte-identical URLs
+matter, put a proxy/Worker in front or accept the Pages canonical form;
+`_redirects` status 200 cannot override these clean-URL redirects.
+
## 1. Build the site
```sh
@@ -94,4 +102,6 @@ it does not read R2. Copy objects down if you want the terminal reader.
- Committed `/raw/*` bytes and release artifacts under
`/releases//*` always download (`Content-Disposition:
attachment`) on both `serve` and Pages, so hostile HTML/SVG can never
- execute in the inbox origin.
+ execute in the inbox origin. The Pages `_headers` rule uses one named
+ placeholder plus one splat (`/releases/:version/*`): Pages silently
+ skips rules with two splats.
diff --git a/functions/inbox/[kind].js b/functions/inbox/[kind].js
index 1fa9037..3ef6121 100644
--- a/functions/inbox/[kind].js
+++ b/functions/inbox/[kind].js
@@ -258,13 +258,17 @@ async function saltedIpHash(ip, salt) {
// rule for /inbox/* is the durable control; this still stops a single
// hot isolate from storing unlimited submissions.
const buckets = new Map();
+const MAX_RATE_KEYS = 10000;
function rateAllowed(key, limit) {
const now = Date.now();
- if (buckets.size > 10000) {
+ if (buckets.size >= MAX_RATE_KEYS) {
for (const [k, v] of buckets) if (v.reset <= now) buckets.delete(k);
}
let b = buckets.get(key);
if (!b || b.reset <= now) {
+ // Fail closed for a new key when the isolate map is full; do not let
+ // source-IP rotation grow it without bound (f0rge #116).
+ if (!b && buckets.size >= MAX_RATE_KEYS) return false;
b = { count: 0, reset: now + 3600_000 };
buckets.set(key, b);
}
@@ -273,9 +277,24 @@ function rateAllowed(key, limit) {
return true;
}
+// R2 list returns at most 1000 objects per call. Paginate until the
+// configured quota is counted or the listing ends; a truncated page
+// without a cursor fails closed (f0rge #116).
async function overQuota(bucket, limit) {
- const listed = await bucket.list({ limit: Math.min(limit, 1000) });
- return listed.objects.length >= limit;
+ if (!(limit > 0)) return true;
+ let count = 0;
+ let cursor;
+ for (;;) {
+ const page = await bucket.list({
+ limit: Math.min(limit - count, 1000),
+ cursor,
+ });
+ count += page.objects.length;
+ if (count >= limit) return true;
+ if (!page.truncated) return false;
+ if (!page.cursor) return true;
+ cursor = page.cursor;
+ }
}
function nowRfc3339() {
diff --git a/src/build.rs b/src/build.rs
index 4274e88..a572829 100644
--- a/src/build.rs
+++ b/src/build.rs
@@ -191,14 +191,184 @@ fn safe_url(url: &str) -> bool {
matches!(scheme, "http" | "https" | "mailto" | "tel")
}
-/// Render markdown to HTML, escaping raw HTML.
-///
-/// `pulldown-cmark` passes `Html`/`InlineHtml` events through untouched
-/// (R1 finding), so map them through [`esc`] first. GFM tables, task
-/// lists and strikethrough are enabled (f0rge #71); link/image
-/// destinations are scheme-filtered (f0rge #62).
-#[must_use]
-pub fn markdown_to_html(md: &str) -> String {
+/// Context for relative markdown destinations: a repo file page uses
+/// `/files/...` + `/raw/...` + `/assets/...`; a wiki page uses
+/// `/wiki/...` + raw/assets for non-page files.
+#[derive(Clone, Copy)]
+enum LinkBase<'a> {
+ Repo(&'a str),
+ Wiki(&'a str),
+}
+
+fn base_path(base: LinkBase<'_>) -> &str {
+ match base {
+ LinkBase::Repo(p) | LinkBase::Wiki(p) => p,
+ }
+}
+
+/// Lexically resolve a relative link against a repo- or wiki-relative
+/// base directory. Returns `None` when it climbs above the root or has
+/// unsafe/absolute components.
+fn rel_join(base: &str, path: &str) -> Option {
+ if path.starts_with('/') || path.contains(['\\', '\0']) {
+ return None;
+ }
+ let mut parts: Vec<&str> = base.split('/').filter(|s| !s.is_empty()).collect();
+ for seg in path.split('/') {
+ match seg {
+ "" | "." => {}
+ ".." => {
+ parts.pop()?;
+ }
+ other => parts.push(other),
+ }
+ }
+ if parts.is_empty() {
+ return None;
+ }
+ Some(parts.join("/"))
+}
+
+/// Extensions safe to display inline from `assets/`: raster/static
+/// images whose bytes cannot execute as a document. SVG and HTML stay
+/// downloads under `raw/`.
+fn is_inline_image(path: &str) -> bool {
+ let base = path.rsplit('/').next().unwrap_or(path);
+ let Some((stem, ext)) = base.rsplit_once('.') else {
+ return false;
+ };
+ !stem.is_empty()
+ && matches!(
+ ext.to_ascii_lowercase().as_str(),
+ "png" | "jpg" | "jpeg" | "gif" | "webp" | "ico" | "avif"
+ )
+}
+
+/// Rewrite one relative markdown destination to the generated route.
+/// External URLs, fragments, absolute paths and anything that cannot be
+/// resolved stay as-is. Query/fragment suffixes are preserved.
+fn rewrite_dest(dest: &str, base: LinkBase<'_>, image: bool) -> Option {
+ let d = dest.trim();
+ if d.is_empty() || d.starts_with(['#', '/']) || d.starts_with("//") {
+ return None;
+ }
+ // A real scheme (`http:`, `mailto:`, ...) is not a relative file.
+ if let Some(colon) = d.find(':') {
+ let scheme = &d[..colon];
+ let mut chars = scheme.chars();
+ if chars.next().is_some_and(|c| c.is_ascii_alphabetic())
+ && chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.'))
+ {
+ return None;
+ }
+ }
+ let cut = d.find(['?', '#']).unwrap_or(d.len());
+ let (path, suffix) = d.split_at(cut);
+ let target = rel_join(base_path(base), path)?;
+ let target = target.trim_end_matches('/');
+ if target.is_empty() {
+ return None;
+ }
+ let enc = encode_path(target);
+ let asset = match base {
+ LinkBase::Wiki(_) => format!("/assets/.forge/wiki/{enc}"),
+ LinkBase::Repo(_) => format!("/assets/{enc}"),
+ };
+ let route = if is_inline_image(target) {
+ asset
+ } else if image {
+ format!("/raw/{enc}")
+ } else {
+ match base {
+ LinkBase::Wiki(_) => format!("/wiki/{enc}.html"),
+ LinkBase::Repo(_) => {
+ let name = target.rsplit('/').next().unwrap_or(target);
+ if is_markdown(target) || name.eq_ignore_ascii_case("readme") {
+ format!("/files/{enc}.html")
+ } else {
+ format!("/raw/{enc}")
+ }
+ }
+ }
+ };
+ Some(format!("{route}{suffix}"))
+}
+
+/// Strip simple inline/block tags for a heading slug. Entity decoding
+/// runs afterwards so `&` becomes a word separator, not `amp`.
+fn heading_text(inner: &str) -> String {
+ let mut out = String::with_capacity(inner.len());
+ let mut in_tag = false;
+ for c in inner.chars() {
+ match c {
+ '<' => in_tag = true,
+ '>' => in_tag = false,
+ _ if !in_tag => out.push(c),
+ _ => {}
+ }
+ }
+ decode_entities(&out)
+}
+
+fn slugify(text: &str) -> String {
+ let mut out = String::new();
+ let mut dash = false;
+ for c in text.chars().flat_map(char::to_lowercase) {
+ if c.is_alphanumeric() {
+ out.push(c);
+ dash = false;
+ } else if !out.is_empty() && !dash {
+ out.push('-');
+ dash = true;
+ }
+ }
+ while out.ends_with('-') {
+ out.pop();
+ }
+ if out.is_empty() {
+ "section".to_string()
+ } else {
+ out
+ }
+}
+
+fn heading_parts(line: &str) -> Option<(usize, &str, &str)> {
+ let b = line.as_bytes();
+ if b.len() < 8 || b[0] != b'<' || b[1] != b'h' || !(b'1'..=b'6').contains(&b[2]) || b[3] != b'>'
+ {
+ return None;
+ }
+ let level = usize::from(b[2] - b'0');
+ let close = format!("");
+ let end = line.find(&close)?;
+ Some((level, &line[4..end], &line[end + close.len()..]))
+}
+
+/// Add deterministic heading ids so `#section` links work. Input is the
+/// renderer's own output, so headings are one per line and contain only
+/// generated tags; code-block lines are entity-escaped and never match.
+fn add_heading_ids(html: &str) -> String {
+ let mut out = String::with_capacity(html.len() + 64);
+ let mut seen: BTreeMap = BTreeMap::new();
+ for line in html.split_inclusive('\n') {
+ if let Some((level, inner, tail)) = heading_parts(line) {
+ let base = slugify(&heading_text(inner));
+ let count = seen.entry(base.clone()).or_insert(0);
+ *count += 1;
+ let id = if *count == 1 {
+ base
+ } else {
+ format!("{base}-{}", *count)
+ };
+ out.push_str(&format!("{inner}{tail}"));
+ } else {
+ out.push_str(line);
+ }
+ }
+ out
+}
+
+fn render_markdown(md: &str, base: Option>) -> String {
use pulldown_cmark::{html, Event, Options, Parser, Tag};
let options = Options::ENABLE_TABLES
| Options::ENABLE_TASKLISTS
@@ -215,7 +385,11 @@ pub fn markdown_to_html(md: &str) -> String {
id,
}) => {
let dest = if safe_url(&dest_url) {
- decode_entities(&dest_url)
+ let decoded = decode_entities(&dest_url);
+ match base.and_then(|b| rewrite_dest(&decoded, b, false)) {
+ Some(rewritten) => rewritten,
+ None => decoded,
+ }
} else {
"#".to_string()
};
@@ -233,7 +407,11 @@ pub fn markdown_to_html(md: &str) -> String {
id,
}) => {
let dest = if safe_url(&dest_url) {
- decode_entities(&dest_url)
+ let decoded = decode_entities(&dest_url);
+ match base.and_then(|b| rewrite_dest(&decoded, b, true)) {
+ Some(rewritten) => rewritten,
+ None => decoded,
+ }
} else {
"#".to_string()
};
@@ -249,7 +427,31 @@ pub fn markdown_to_html(md: &str) -> String {
let mut out = String::from("\n");
html::push_html(&mut out, mapped);
out.push_str("
\n");
- out
+ add_heading_ids(&out)
+}
+
+/// Render markdown to HTML, escaping raw HTML.
+///
+/// `pulldown-cmark` passes `Html`/`InlineHtml` events through untouched
+/// (R1 finding), so map them through [`esc`] first. GFM tables, task
+/// lists and strikethrough are enabled (f0rge #71); link/image
+/// destinations are scheme-filtered (f0rge #62). Headings get stable
+/// ids so `#section` links resolve (f0rge #112).
+#[must_use]
+pub fn markdown_to_html(md: &str) -> String {
+ render_markdown(md, None)
+}
+
+/// Render markdown from a repo file page or wiki page, rewriting
+/// relative links/images to generated routes (f0rge #112).
+#[must_use]
+pub fn markdown_to_html_in(md: &str, base: &str, wiki: bool) -> String {
+ let base = if wiki {
+ LinkBase::Wiki(base)
+ } else {
+ LinkBase::Repo(base)
+ };
+ render_markdown(md, Some(base))
}
/// Resolve the clone URL to advertise.
@@ -547,6 +749,12 @@ pub fn now_unix() -> i64 {
#[must_use]
pub fn parse_ts_secs(s: &str) -> Option {
let s = s.trim();
+ // Timestamps are ASCII by definition. Reject other text before any
+ // byte-index slicing: a 200-char multibyte `created` used to panic
+ // on a non-char-boundary (f0rge #108).
+ if !s.is_ascii() {
+ return None;
+ }
// Accept both `2026-10-27T14:08:48Z` (RFC3339) and git's
// `2026-10-27 14:08:48 +0000` / `+00:00` forms.
let (date, rest) = match s.split_once('T') {
@@ -1154,7 +1362,10 @@ pub const HEADERS_FILE: &str = "\
X-Content-Type-Options: nosniff
# Artifacts are build outputs copied from .forge/releases/out//;
# never let an artifact .html/.svg execute in the site origin (f0rge #97).
-/releases/*/*
+# Cloudflare Pages allows one splat per rule. A named `:version`
+# placeholder plus one splat covers every artifact depth (f0rge #106).
+# A two-splat rule is silently skipped by Pages and leaves artifacts active.
+/releases/:version/*
Content-Disposition: attachment
X-Content-Type-Options: nosniff
/*
@@ -1486,6 +1697,17 @@ pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result Resultview raw
\n",
href_esc(f)
@@ -1733,7 +1956,12 @@ pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> ResultGuide"),
+ html.contains("Guide
"),
"markdown rendered, got: {html}"
);
assert!(html.contains("/raw/docs/guide.md"), "raw link kept");
@@ -2243,7 +2471,7 @@ mod tests {
#[test]
fn markdown_escapes_raw_html() {
let html = markdown_to_html("# hi\n\n\n\n**bold**\n");
- assert!(html.contains("hi
"), "heading renders");
+ assert!(html.contains("hi
"), "heading renders");
assert!(!html.contains("