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

6 files changed, +119 -2
MREADME.md2+-
Mbin/forge20++++++++++++++++-
Mtest/lib.sh35++++++++++++++++++++++++++++++
Atest/scenarios/go-get-hostile.sh15+++++++++++++
Atest/scenarios/go-get-import.sh19++++++++++++++++
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 +19 -1
@@ -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.
@@ -1099,12 +1112,17 @@ $addr {
10991112 flush_interval -111001113 }11011114 }1115+ # Only names: the reply echoes the owner and repo from the path, which1116+ # Caddy has decoded, so a path with anything else in them (<, ", %3C)1117+ # isn't a module path and gets the page it would without go-get=1.11021118 @goget {11031119 query go-get=11104- path_regexp goget ^/([A-Za-z0-9_-][^/]*)/([^/]+?)(\.git)?(/.*)?$1120+ path_regexp goget ^/([A-Za-z0-9_-][A-Za-z0-9._-]*)/([A-Za-z0-9._-]+?)(\.git)?(/.*)?$11051121 }11061122 handle @goget {11071123 header Content-Type "text/html; charset=utf-8"1124+ header Content-Security-Policy "default-src 'none'"1125+ header X-Content-Type-Options nosniff11081126 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>" 20011091127 }11101128 EOF
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
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$'