Two fixes from the security review, to ship first.
- ?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.
- 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.
| #30 Security: sign-in, invites, owner pages, lake, caps and the edge | 10 commits | ✓ 10/10 | merged |
| #29 Security: go get's reflected markup, and headers on the forge's pages | 2 commits | ✓ 2/2 | merged |
| main base |
| 1/2 | go get's reply answers only for name paths, and loads nothing | +75 | -1 | ✓ |
| 2/2 | The forge's own pages send HSTS, nosniff, a referrer policy and a CSP | +44 | -1 | ✓ |
revisions2r2: behavior change
| r2 | cat | fe26ac5 | what changed since r1 behavior change | |
| r1 | cat | 13917bb |
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.
| M | bin/ | 7 | +++++- |
| M | test/ | 35 | ++++++++++++++++++++++++++++++ |
| A | test/ | 15 | +++++++++++++ |
| A | test/ | 19 | ++++++++++++++++ |
@@ -1099,12 +1099,17 @@ $addr {
10991099 flush_interval -111001100 }11011101 }1102+ # Only names: the reply echoes the owner and repo from the path, which1103+ # Caddy has decoded, so a path with anything else in them (<, ", %3C)1104+ # isn't a module path and gets the page it would without go-get=1.11021105 @goget {11031106 query go-get=11104- path_regexp goget ^/([A-Za-z0-9_-][^/]*)/([^/]+?)(\.git)?(/.*)?$1107+ path_regexp goget ^/([A-Za-z0-9_-][A-Za-z0-9._-]*)/([A-Za-z0-9._-]+?)(\.git)?(/.*)?$11051108 }11061109 handle @goget {11071110 header Content-Type "text/html; charset=utf-8"1111+ header Content-Security-Policy "default-src 'none'"1112+ header X-Content-Type-Options nosniff11081113 respond "<!DOCTYPE html><meta name=\"go-import\" content=\"$gohost/{re.goget.1}/{re.goget.2} git https://$gohost/{re.goget.1}/{re.goget.2}.git\"><p>go get $gohost/{re.goget.1}/{re.goget.2}</p>" 20011091114 }11101115 EOF@@ -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; }@@ -0,0 +1,15 @@
1+# ?go-get=1 answers with the owner and repo from the path in its page, so2+# only paths whose segments are names get that answer. Anything else (here3+# a planted tag, sent encoded as a browser would) gets the page the path4+# would have without go-get=1, and the markup appears nowhere.5+. "$(dirname "$0")/../lib.sh"6+user dami7+repo dami/greet8+export FORGE_BASE_URL=https://git.test9+caddy_server10+for p in '/zz/probe%3Cb%3Emarker' '/zz%3Cb%3Ex/greet' '/dami/greet%22%3E%3Cb%3Ex' '/dami/greet.git%3Cb%3E/x'; do11+ http -s -i "$C$p?go-get=1" >"$T/res"12+ lacks "$T/res" '<b>'13+ lacks "$T/res" 'go-import'14+ has "$T/res" '^HTTP/1.1 404 '15+done@@ -0,0 +1,19 @@
1+# go get <domain>/<owner>/<repo>[/pkg] asks for the path with ?go-get=1 and2+# reads the go-import meta tag: the module's root and its https clone. The3+# reply is that tag alone, which may load nothing and isn't sniffed.4+. "$(dirname "$0")/../lib.sh"5+user dami6+repo dami/greet7+export FORGE_BASE_URL=https://git.test8+caddy_server9+for p in /dami/greet /dami/greet.git /dami/greet/cmd/hello /dami/my_mod-2.x; do10+ http -s -i "$C$p?go-get=1" >"$T/res"11+ has "$T/res" '^HTTP/1.1 200 '12+ has "$T/res" '^Content-Type: text/html; charset=utf-8'13+ has "$T/res" "^Content-Security-Policy: default-src 'none'"14+ has "$T/res" '^X-Content-Type-Options: nosniff'15+done16+http -s "$C/dami/greet/cmd/hello?go-get=1" >"$T/body"17+has "$T/body" '<meta name="go-import" content="git\.test/dami/greet git https://git\.test/dami/greet\.git">'18+http -s "$C/dami/my_mod-2.x?go-get=1" >"$T/body"19+has "$T/body" 'content="git\.test/dami/my_mod-2\.x git https://git\.test/dami/my_mod-2\.x\.git"'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.
| M | README.md | 2 | +- |
| M | bin/ | 13 | +++++++++++++ |
| A | test/ | 30 | ++++++++++++++++++++++++++++++ |
@@ -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)). |@@ -1076,6 +1076,19 @@ cmd_server_caddyfile() {
10761076 [ -n "$gohost" ] || gohost={host}10771077 cat <<EOF10781078 $addr {1079+ # The forge's own pages (not sites, which are anyone's and keep their1080+ # own) say not to sniff types, not to be framed elsewhere, and to send1081+ # only the origin to other sites. A response that sets one of these1082+ # itself (the private server's sign-in pages) keeps its own. HSTS is1083+ # the hostname's, so every response has it; browsers ignore it over1084+ # plain HTTP.1085+ header ?Strict-Transport-Security "max-age=31536000"1086+ @chrome not path /sites/* /previews/*1087+ header @chrome {1088+ ?X-Content-Type-Options nosniff1089+ ?Referrer-Policy strict-origin-when-cross-origin1090+ ?Content-Security-Policy "frame-ancestors 'self'; base-uri 'none'; object-src 'none'"1091+ }10791092 # Compress pages and assets, never the event stream (it would buffer),10801093 # code.db, which code search reads in byte ranges of the file as stored,10811094 # or release downloads, which are mostly compressed already.@@ -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$'