clone

a personal forge on plain git: static pages, stacked-diff reviews, signed local ci, repo sites and releases

Security: go get's reflected markup, and headers on the forge's pages #29

cat description

Two fixes from the security review, to ship first.

  1. ?go-get=1 echoed decoded path segments into an HTML reply. The matcher now takes only name characters, so anything else falls through to the normal page (a 404 for a hostile path), and the reply sends CSP default-src 'none' and nosniff.
  2. Caddy adds HSTS (max-age=31536000, no preload) to every response on the forge's host, and nosniff, Referrer-Policy strict-origin-when-cross-origin and CSP frame-ancestors 'self'; base-uri 'none'; object-src 'none' to its pages, not /sites/ or /previews/. Each is set only when the response has none, so /login/ and /private/ keep theirs.

Checked behind real Caddy in headless Chromium: no console errors or CSP violations on home, owner, repo, blob, review, diff, wiki with Pele diagrams, releases, insights, design, topics, login; fonts load, code search loads sqlite-wasm and reads code.db by range, the events stream answers.

stack2
  #30 Security: sign-in, invites, owner pages, lake, caps and the edgecat/sec-hardening10 commits✓ 10/10merged
#29 Security: go get's reflected markup, and headers on the forge's pagescat/sec-goget2 commits✓ 2/2merged
main base
commits2oldest firstcombined diff 6 files, +119 -2
revisions2r2: behavior change
r2catfe26ac5what changed since r1 behavior change
r1cat13917bb
discussion0 comments

No comments yet: forge comment 29 adds one.

1/2 go get's reply answers only for name paths, and loads nothing

The ?go-get=1 reply echoed the owner and repo from the decoded path into an HTML page, so a link with markup in it reflected that markup on the forge's origin. The matcher now takes only name characters, so any other path falls through to the page it would get without go-get=1, and the reply says default-src 'none' and nosniff. Scenarios: hostile segments get the plain 404, and real module paths still get their go-import tag.
4 files changed, +75 -1
Mbin/forge7+++++-
Mtest/lib.sh35++++++++++++++++++++++++++++++
Atest/scenarios/go-get-hostile.sh15+++++++++++++
Atest/scenarios/go-get-import.sh19++++++++++++++++
M bin/forge +6 -1
M test/lib.sh +35 -0
@@ -23,6 +23,7 @@
2323 # synced [n] wait until forge mirror has finished n syncs (1)2424 # lq "<sql>" forge lake query: the rows as CSV, no header2525 # private_server lib/private-server.ts for this forge, at $PRIV26+# caddy_server Caddy as forge server caddyfile has it, at $C2627 # signin ana ana adds a passkey and signs in at $PRIV: the session cookie2728 # csrf_of <cookie> the CSRF token /login/whoami gives that session's pages2829 # http [curl options] url an HTTP request: curl's options (test/http.ts)
@@ -358,6 +359,40 @@ private_server() {
358359 PRIV=http://127.0.0.1:$PRIV_PORT359360 }360361 362+# caddy_server: Caddy with forge server caddyfile's config (no pages363+# domain), on a free port in the background, in front of a private server364+# it starts. Sets C, its URL. Without caddy installed the scenario stops365+# here, passing, and says so. As with private_server, another scenario can366+# take the port first; then this Caddy exits, and it tries another. It367+# binds 127.0.0.1 only: bound to every address, the port is still free for368+# another scenario's server to take on 127.0.0.1, and with it the requests.369+caddy_server() {370+ command -v caddy >/dev/null 2>&1 || { note "caddy is not installed: skipped running it"; exit 0; }371+ private_server372+ n=0373+ while :; do374+ cport=$(free_port)375+ {376+ printf '{\n\tadmin off\n\tauto_https off\n\tpersist_config off\n\tdefault_bind 127.0.0.1\n}\n\n'377+ FORGE_PRIVATE_PORT=$PRIV_PORT "$FORGE" server caddyfile "http://127.0.0.1:$cport"378+ } >"$T/Caddyfile.run"379+ XDG_DATA_HOME=$T/xdg XDG_CONFIG_HOME=$T/xdg caddy run --config "$T/Caddyfile.run" --adapter caddyfile >"$T/caddy.log" 2>&1 &380+ pid=$!381+ k=0382+ until grep -q 'serving initial configuration' "$T/caddy.log" || ! kill -0 "$pid" 2>/dev/null || [ "$k" -ge 100 ]; do383+ sleep 0.1384+ k=$((k + 1))385+ done386+ if kill -0 "$pid" 2>/dev/null && grep -q 'serving initial configuration' "$T/caddy.log"; then break; fi387+ kill "$pid" 2>/dev/null || true388+ n=$((n + 1))389+ [ "$n" -lt 5 ] || { cat "$T/caddy.log" >&2; fail "caddy did not start"; }390+ done391+ T_PIDS="$T_PIDS $pid"392+ C=http://127.0.0.1:$cport393+ wait_http "$C/"394+}395+361396 # stop_servers: stop what the scenario started in the background so far.362397 stop_servers() {363398 [ -z "$T_PIDS" ] || { kill $T_PIDS 2>/dev/null || true; wait $T_PIDS 2>/dev/null || true; }
A test/scenarios/go-get-hostile.sh +15 -0
A test/scenarios/go-get-import.sh +19 -0

2/2 The forge's own pages send HSTS, nosniff, a referrer policy and a CSP

Caddy adds Strict-Transport-Security to every response on the forge's hostname, and to its pages (not /sites/ or /previews/, which are anyone's) X-Content-Type-Options nosniff, Referrer-Policy strict-origin-when-cross-origin and a CSP of frame-ancestors 'self', base-uri 'none' and object-src 'none'. Each is set only if the response has none, so the sign-in and private pages keep their stricter ones. Nothing scripts, styles, fonts or wasm load is restricted.
3 files changed, +44 -1
MREADME.md2+-
Mbin/forge13+++++++++++++
Atest/scenarios/web-headers.sh30++++++++++++++++++++++++++++++
M README.md +1 -1
@@ -97,7 +97,7 @@ review page, signed in ─── HTTPS ────────────> the
9797 | Process | Why |9898 | --- | --- |9999 | sshd | git over ssh. Every key is forced through `forge shell <name>`. |100-| Caddy | Serves `.forge/www` as plain files, read-only HTTPS clones (`git http-backend` through fcgiwrap; nothing else inside a bare repo is served; a private repo takes a token) and `go get` meta tags; proxies `/events/`, every path `www` has no file for (to the private server) and, with a pages domain, sites. |100+| Caddy | Serves `.forge/www` as plain files, read-only HTTPS clones (`git http-backend` through fcgiwrap; nothing else inside a bare repo is served; a private repo takes a token) and `go get` meta tags, with security headers (HSTS, nosniff, a referrer policy and a CSP against framing) on the forge's own pages; proxies `/events/`, every path `www` has no file for (to the private server) and, with a pages domain, sites. |101101 | `forge build` | Runs 11ty after each push, in the background and one repo at a time, then swaps the new pages into `www/`. Unchanged pages are hard links to the last build's. First it adds the push's commits to the repo's commit-graph (see [Cleaning up](#cleaning-up)). |102102 | `lib/events-server.ts` | Tails `.forge/events.jsonl`, which hooks and builds append to, and streams it as server-sent events at `/events/<owner>/<repo>/`. Open pages say who pushed and swap in fresh content when the build finishes. Without it pages still work; their status line says offline. A private repo streams only to its readers, signed in. |103103 | `lib/private-server.ts` | Serves private repos' pages to their readers, signed in with a passkey or holding a token, the sign-in pages (`/login/`, `/private/`), and the 404 of every page that isn't there; takes comments and approvals posted from review pages (see [Comments and approvals](#comments-and-approvals)); answers Caddy whether a token may clone a private repo over HTTPS (see [Private pages](#private-pages)). |
M bin/forge +13 -0
A test/scenarios/web-headers.sh +30 -0
@@ -0,0 +1,30 @@
1+# The forge's own pages, through Caddy, carry its security headers: HSTS,2+# no sniffing, the origin alone as referrer, and a CSP that keeps them out3+# of other sites' frames. A site (anyone's pages) gets only HSTS, which is4+# the hostname's, and the sign-in page keeps its own stricter policy.5+. "$(dirname "$0")/../lib.sh"6+user dami7+repo dami/greet8+clone dami dami/greet "$T/w/d"9+at "$T/w/d" commit "first"10+push_as dami "$T/w/d" origin main11+mkdir -p "$T/site" && printf '<h1>docs</h1>\n' >"$T/site/index.html"12+FORGE_USER=dami ok at "$T/w/d" "$FORGE" site push "$T/site" docs13+build14+caddy_server15+for p in / /dami/greet/ /dami/nope/; do16+ http -s -i "$C$p" | tr -d '\r' >"$T/res"17+ has "$T/res" '^Strict-Transport-Security: max-age=31536000$'18+ has "$T/res" '^X-Content-Type-Options: nosniff$'19+ has "$T/res" '^Referrer-Policy: strict-origin-when-cross-origin$'20+ has "$T/res" "^Content-Security-Policy: frame-ancestors 'self'; base-uri 'none'; object-src 'none'$"21+done22+http -s -i "$C/sites/dami/greet/docs/" | tr -d '\r' >"$T/site.res"23+has "$T/site.res" '^HTTP/1.1 200 '24+has "$T/site.res" '<h1>docs</h1>'25+has "$T/site.res" '^Strict-Transport-Security: '26+lacks "$T/site.res" '^(Content-Security-Policy|X-Content-Type-Options|Referrer-Policy):'27+http -s -i "$C/login/" | tr -d '\r' >"$T/login"28+eq "$(grep -c '^Content-Security-Policy:' "$T/login")" 129+has "$T/login" "^Content-Security-Policy: default-src 'self'.*frame-ancestors 'none'"30+has "$T/login" '^Referrer-Policy: same-origin$'