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.
- commit
- ebe809d2761d6e5fae0ff25ad968cf28f64399a2
- parent
- a283b86ab9926aa599a4c6e2124c0a9875d1b381
- author
- cat <admin@dev.site>
- ci
- ✓ passed in 111s, signed by cat
- kind
- fix risk score 1.5 of 3, Jev's estimate
- review
- #29 Security: go get's reflected markup, and headers on the forge's pages
ci attestation
forge-attestation 1 type ci commit ebe809d2761d6e5fae0ff25ad968cf28f64399a2 tree 09c97e42cba7b832ab14e658ad34203b7f077461 script .forge/ci 8d2000a416f8160bcf710d98b72af9eb76287f58 result pass exit 0 started 2026-10-19T07:38:03Z duration 111s runner (not shown here; refs/notes/ci has it) system Darwin 25.5.0 arm64 env host log sha256:f3e5889d69b9656a7b9d670d4366b3fcec2afefe9994d89e0704f63a198e9d02 311 lines |pushed wip/i18n |a6a4c63 running .forge/ci: greet.sh: say what it is | | sh: /bin/sh | | ok greet |a6a4c63 pass in 0s, signed |pushed main |remote: review #4: /dami/greet/reviews/4/ |pushed wip/i18n |4051548 running .forge/ci: wave at the world | | sh: /bin/sh | | FAIL greet : got 'hello, world o/', want 'hello, world' |4051548 fail in 0s, signed |forge: 1 commit(s) did not pass |remote: review #6: /dami/greet/reviews/6/ |pushed ana/wave |cb0d018 running .forge/ci: update the test for the wave | | sh: /bin/sh | | ok greet |cb0d018 pass in 0s, signed |remote: review #6: /dami/greet/reviews/6/ |pushed ana/wave |warning: You appear to have cloned an empty repository. |site home: deployed 73424d2 -> /sites/ana/dotfiles/home/ |created ana/notes |remote: review #1: /ana/notes/reviews/1/ |pushed ana/todo |landing #1 A todo list: ana/todo -> main |a0d8164 running .forge/ci: web, server and Python versions of greet | | sh: /bin/sh | | ok greet |a0d8164 pass in 0s, signed |pushed main |f46e770 running .forge/ci: messages: notes for the later regions | | sh: /bin/sh | | ok greet |f46e770 pass in 0s, signed |remote: review #7: /dami/greet/reviews/7/ |pushed dami/regions |created dami/forge |warning: You appear to have cloned an empty repository. |site guide: mkdir -p guide/fonts && cp site/assets/fonts/*.woff2 guide/fonts/ |site guide: deployed 30d8dda -> /sites/dami/forge/guide/ |(node:54701) ExperimentalWarning: stripTypeScriptTypes is an experimental feature and might change at any time |(Use `node --trace-warnings ...` to show where the warning was created) |built ana/dotfiles in 1s -> /var/folders/b4/mv8w4c8j0zj0_mldppxdbdw00000gn/T/forge-ci.mrk0Aa/wt/demo/root/.forge/www |(node:54860) ExperimentalWarning: stripTypeScriptTypes is an experimental feature and might change at any time |(Use `node --trace-warnings ...` to show where the warning was created) |built ana/notes in 0s -> /var/folders/b4/mv8w4c8j0zj0_mldppxdbdw00000gn/T/forge-ci.mrk0Aa/wt/demo/root/.forge/www |(node:55031) ExperimentalWarning: stripTypeScriptTypes is an experimental feature and might change at any time |(Use `node --trace-warnings ...` to show where the warning was created) |built dami/forge in 1s -> /var/folders/b4/mv8w4c8j0zj0_mldppxdbdw00000gn/T/forge-ci.mrk0Aa/wt/demo/root/.forge/www |(node:55196) ExperimentalWarning: stripTypeScriptTypes is an experimental feature and might change at any time |(Use `node --trace-warnings ...` to show where the warning was created) |built dami/greet in 2s -> /var/folders/b4/mv8w4c8j0zj0_mldppxdbdw00000gn/T/forge-ci.mrk0Aa/wt/demo/root/.forge/www |(node:55909) ExperimentalWarning: stripTypeScriptTypes is an experimental feature and might change at any time |(Use `node --trace-warnings ...` to show where the warning was created) |built - in 0s -> /var/folders/b4/mv8w4c8j0zj0_mldppxdbdw00000gn/T/forge-ci.mrk0Aa/wt/demo/root/.forge/www |235 of 235 pages render the same without 11ty |guide: 8 files, links ok |ok
Signature: good, cat
| 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"'