Security hardening sweep: fuzzing, headers, host sandbox (#28) !28
13 files changed, +397 −10
deploy/audit.sh added +20
| @@ -0,0 +1,20 @@ | ||
| 1 | #!/bin/sh | |
| 2 | # Security checks to run before a release (and periodically). Exits non-zero | |
| 3 | # on any finding so it can gate CI. | |
| 4 | set -eu | |
| 5 | cd "$(dirname "$0")/.." | |
| 6 | ||
| 7 | echo "== go vet ==" | |
| 8 | go vet ./... | |
| 9 | ||
| 10 | echo "== govulncheck ==" | |
| 11 | go run golang.org/x/vuln/cmd/govulncheck@latest ./... | |
| 12 | ||
| 13 | echo "== fuzz smoke (10s each parser) ==" | |
| 14 | go test -run xxx -fuzz FuzzReadPktLine -fuzztime 10s ./internal/gitd/ | |
| 15 | go test -run xxx -fuzz FuzzParseCommit -fuzztime 10s ./internal/sig/ | |
| 16 | go test -run xxx -fuzz FuzzDecodeArmorAndParseSSHSig -fuzztime 10s ./internal/sig/ | |
| 17 | go test -run xxx -fuzz FuzzParsePGPKey -fuzztime 10s ./internal/sig/ | |
| 18 | go test -run xxx -fuzz FuzzTokenizeNoPanic -fuzztime 10s ./internal/protocol/ | |
| 19 | ||
| 20 | echo "== all clear ==" | |
deploy/cloud-init.yaml +89 −2
| @@ -17,6 +17,8 @@ package_update: true | ||
| 17 | 17 | packages: |
| 18 | 18 | - git |
| 19 | 19 | - ufw |
| 20 | - unattended-upgrades | |
| 21 | - fail2ban | |
| 20 | 22 | |
| 21 | 23 | write_files: |
| 22 | 24 | # Admin sshd on 2222. Ubuntu 24.04 socket-activates sshd, so the port |
| @@ -25,6 +27,74 @@ write_files: | ||
| 25 | 27 | content: | |
| 26 | 28 | Port 2222 |
| 27 | 29 | PasswordAuthentication no |
| 30 | # Throttle unauthenticated connection floods on the admin sshd | |
| 31 | # (gitbayd's own port 22 is throttled by limits.ssh_auth_rate). | |
| 32 | MaxStartups 10:30:60 | |
| 33 | MaxAuthTries 3 | |
| 34 | LoginGraceTime 20 | |
| 35 | ||
| 36 | # OS security patches applied automatically; reboot at 04:30 if needed. | |
| 37 | - path: /etc/apt/apt.conf.d/51gitbay-unattended | |
| 38 | content: | | |
| 39 | Unattended-Upgrade::Allowed-Origins { "${distro_id}:${distro_codename}-security"; }; | |
| 40 | Unattended-Upgrade::Automatic-Reboot "true"; | |
| 41 | Unattended-Upgrade::Automatic-Reboot-Time "04:30"; | |
| 42 | APT::Periodic::Update-Package-Lists "1"; | |
| 43 | APT::Periodic::Unattended-Upgrade "1"; | |
| 44 | ||
| 45 | # fail2ban watches the admin sshd for auth failures. | |
| 46 | - path: /etc/fail2ban/jail.d/gitbay.conf | |
| 47 | content: | | |
| 48 | [sshd] | |
| 49 | enabled = true | |
| 50 | port = 2222 | |
| 51 | backend = systemd | |
| 52 | maxretry = 5 | |
| 53 | bantime = 1h | |
| 54 | ||
| 55 | # Heartbeat: post disk/service/cert status to a webhook if one is set in | |
| 56 | # /etc/gitbay/monitor.url. Silent when the file is absent. | |
| 57 | - path: /usr/local/bin/gitbay-monitor.sh | |
| 58 | permissions: "0755" | |
| 59 | content: | | |
| 60 | #!/bin/sh | |
| 61 | set -eu | |
| 62 | url_file=/etc/gitbay/monitor.url | |
| 63 | [ -f "$url_file" ] || exit 0 | |
| 64 | url=$(cat "$url_file") | |
| 65 | disk=$(df -P /var/lib/gitbay | awk 'NR==2{print $5}') | |
| 66 | svc=$(systemctl is-active gitbayd || true) | |
| 67 | # Days until the ACME cert expires, if autocert cached one. | |
| 68 | cert=/var/lib/gitbay/autocert | |
| 69 | exp="n/a" | |
| 70 | if [ -d "$cert" ]; then | |
| 71 | f=$(ls -1 "$cert" 2>/dev/null | grep -v acme_account | head -1 || true) | |
| 72 | [ -n "$f" ] && exp=$(openssl x509 -enddate -noout -in "$cert/$f" 2>/dev/null | cut -d= -f2 || echo n/a) | |
| 73 | fi | |
| 74 | alert="" | |
| 75 | [ "$svc" != "active" ] && alert="gitbayd is $svc; " | |
| 76 | pct=$(echo "$disk" | tr -d '%') | |
| 77 | [ "$pct" -ge 85 ] && alert="${alert}disk ${disk}; " | |
| 78 | body=$(printf '{"disk":"%s","service":"%s","cert_expires":"%s","alert":"%s"}' "$disk" "$svc" "$exp" "$alert") | |
| 79 | curl -fsS -m 10 -H 'Content-Type: application/json' -d "$body" "$url" >/dev/null 2>&1 || true | |
| 80 | ||
| 81 | - path: /etc/systemd/system/gitbay-monitor.service | |
| 82 | content: | | |
| 83 | [Unit] | |
| 84 | Description=gitbay host heartbeat | |
| 85 | [Service] | |
| 86 | Type=oneshot | |
| 87 | ExecStart=/usr/local/bin/gitbay-monitor.sh | |
| 88 | ||
| 89 | - path: /etc/systemd/system/gitbay-monitor.timer | |
| 90 | content: | | |
| 91 | [Unit] | |
| 92 | Description=gitbay host heartbeat | |
| 93 | [Timer] | |
| 94 | OnCalendar=*-*-* *:00:00 UTC | |
| 95 | Persistent=true | |
| 96 | [Install] | |
| 97 | WantedBy=timers.target | |
| 28 | 98 | - path: /etc/systemd/system/ssh.socket.d/override.conf |
| 29 | 99 | content: | |
| 30 | 100 | [Socket] |
| @@ -77,8 +147,24 @@ write_files: | ||
| 77 | 147 | ReadWritePaths=/var/lib/gitbay /var/backups/gitbay |
| 78 | 148 | PrivateTmp=yes |
| 79 | 149 | ProtectKernelTunables=yes |
| 150 | ProtectKernelModules=yes | |
| 80 | 151 | ProtectControlGroups=yes |
| 152 | ProtectHostname=yes | |
| 153 | ProtectClock=yes | |
| 154 | ProtectKernelLogs=yes | |
| 81 | 155 | RestrictSUIDSGID=yes |
| 156 | RestrictNamespaces=yes | |
| 157 | RestrictRealtime=yes | |
| 158 | LockPersonality=yes | |
| 159 | MemoryDenyWriteExecute=yes | |
| 160 | PrivateDevices=yes | |
| 161 | # IPv4/IPv6 for listeners and outbound git/ssh; UNIX for the hook socket. | |
| 162 | RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX | |
| 163 | # Allow only ordinary service syscalls; the daemon spawns git and ssh, | |
| 164 | # so keep @process/@exec available (both are within @system-service). | |
| 165 | SystemCallFilter=@system-service | |
| 166 | SystemCallErrorNumber=EPERM | |
| 167 | SystemCallArchitectures=native | |
| 82 | 168 | |
| 83 | 169 | [Install] |
| 84 | 170 | WantedBy=multi-user.target |
| @@ -146,5 +232,6 @@ runcmd: | ||
| 146 | 232 | - ufw --force enable |
| 147 | 233 | - systemctl daemon-reload |
| 148 | 234 | - systemctl restart ssh.socket || systemctl restart ssh |
| 149 | - systemctl enable gitbayd gitbay-backup.timer gitbay-gc.timer | |
| 150 | - systemctl start gitbay-backup.timer gitbay-gc.timer | |
| 235 | - systemctl enable gitbayd gitbay-backup.timer gitbay-gc.timer gitbay-monitor.timer | |
| 236 | - systemctl start gitbay-backup.timer gitbay-gc.timer gitbay-monitor.timer | |
| 237 | - systemctl enable --now unattended-upgrades fail2ban | |
deploy/release.sh +7
| @@ -35,4 +35,11 @@ else | ||
| 35 | 35 | shasum -a 256 -- * > SHA256SUMS |
| 36 | 36 | fi |
| 37 | 37 | echo "wrote $out/SHA256SUMS" |
| 38 | ||
| 39 | # Optional detached signature over the manifest. Set MINISIGN_KEY to a | |
| 40 | # minisign secret key path to sign; downloaders verify with the public key. | |
| 41 | if [ -n "${MINISIGN_KEY:-}" ] && command -v minisign >/dev/null 2>&1; then | |
| 42 | minisign -S -s "$MINISIGN_KEY" -m SHA256SUMS | |
| 43 | echo "signed SHA256SUMS -> SHA256SUMS.minisig" | |
| 44 | fi | |
| 38 | 45 | cat SHA256SUMS |
docs/admin.org +32
| @@ -182,6 +182,38 @@ Replace the binary, restart the unit. Migrations apply automatically and | ||
| 182 | 182 | are transactional; hook scripts under =<root>/hooks= are rewritten at |
| 183 | 183 | startup to point at the current binary path. |
| 184 | 184 | |
| 185 | * Security | |
| 186 | ||
| 187 | The =docs/threat-model.org= file is the reference for what the forge | |
| 188 | trusts and refuses to do. Operational checklist: | |
| 189 | ||
| 190 | *Software checks.* =deploy/audit.sh= runs =go vet=, =govulncheck= | |
| 191 | (the module list is deliberately short — review it on each release), | |
| 192 | and a short fuzz pass over every attacker-facing parser (pkt-line, | |
| 193 | commit, SSHSIG armor, OpenPGP key, SSH tokenizer). Run it before | |
| 194 | tagging a release. | |
| 195 | *Web responses* carry a scripts-forbidden CSP, =X-Frame-Options: | |
| 196 | DENY=, =nosniff=, =no-referrer=, and HSTS when TLS is on — no | |
| 197 | configuration needed. | |
| 198 | *Host sandboxing.* The systemd unit in =deploy/cloud-init.yaml= runs | |
| 199 | gitbayd unprivileged with =ProtectSystem=strict=, =PrivateDevices=, | |
| 200 | =LockPersonality=, =MemoryDenyWriteExecute=, | |
| 201 | =SystemCallFilter=@system-service=, and =RestrictAddressFamilies= to | |
| 202 | INET/INET6/UNIX. It keeps =CAP_NET_BIND_SERVICE= only, to bind 22/80/443. | |
| 203 | *OS patches* apply via =unattended-upgrades= (security origins, | |
| 204 | auto-reboot 04:30 if required). | |
| 205 | *Admin sshd (2222)* is throttled by =MaxStartups=/=MaxAuthTries= and | |
| 206 | watched by =fail2ban=; gitbayd's own port 22 is throttled by | |
| 207 | =limits.ssh_auth_rate= (auth failures per IP per minute). | |
| 208 | *Monitoring.* =gitbay-monitor.timer= posts disk/service/cert status | |
| 209 | hourly to the webhook URL in =/etc/gitbay/monitor.url= (create the | |
| 210 | file to enable; absent = silent). Alerts fire on a stopped service or | |
| 211 | disk ≥ 85%. | |
| 212 | *Database.* =gitbay.db= and its WAL live under =/var/lib/gitbay= (mode | |
| 213 | 0750, owned by =gitbay=). The nightly archive plus provider snapshots | |
| 214 | are the recovery path; for tighter RPO, add continuous replication | |
| 215 | (litestream) against the same file — it coexists with the WAL. | |
| 216 | ||
| 185 | 217 | * Odds and ends |
| 186 | 218 | |
| 187 | 219 | - deleting a fork marks MRs sourced from it =source_gone=; their diffs |
docs/threat-model.org added +92
| @@ -0,0 +1,92 @@ | ||
| 1 | #+title: gitbay threat model | |
| 2 | ||
| 3 | What the forge trusts, what it refuses to do, and where the boundaries | |
| 4 | are. This is the reference for security review; it complements the audit | |
| 5 | log and hardening notes in =admin.org=. | |
| 6 | ||
| 7 | * What gitbay never does | |
| 8 | ||
| 9 | *Execute repository content.* Git object contents are never run. Hooks | |
| 10 | are gitbay's own binary, invoked by git; they compute facts and ask the | |
| 11 | daemon over a unix socket. Repo files are only ever read. | |
| 12 | *Hold a signing key.* There is no server-side signing key. "Verified" | |
| 13 | means a signature made by a key the *user* registered — the server | |
| 14 | never vouches for a commit it did not receive already signed. Merge | |
| 15 | commits the server creates are honestly =unsigned=. | |
| 16 | *Serve repository HTML on its own origin as active content.* Raw file | |
| 17 | serving is =text/plain= with =nosniff=. Rendered markdown/org is | |
| 18 | sanitized (bluemonday) and served under a CSP that forbids scripts. | |
| 19 | *Put secrets in argv, URLs, or logs.* Import and mirror credentials, | |
| 20 | registration invites, and API tokens travel on stdin or in request | |
| 21 | bodies, never as command arguments (visible in =/proc=) or query | |
| 22 | strings. Tokens are stored only as SHA-256 hashes. | |
| 23 | *Confirm the existence of private repositories.* Every surface answers | |
| 24 | "not found" identically for a private repo and a nonexistent one — web | |
| 25 | pages, git transport, control commands, release asset downloads. | |
| 26 | ||
| 27 | * Trust boundaries | |
| 28 | ||
| 29 | *SSH public key = identity.* The SSH username is ignored; the presented | |
| 30 | key's fingerprint resolves to an account. Key uniqueness is global. | |
| 31 | *Per-instance trust.* Email verification and key registration are local | |
| 32 | to an instance and never transfer. Account migration re-registers keys | |
| 33 | and re-verifies emails on the target by design. | |
| 34 | *The control plane is one authenticated channel* (SSH), fully usable | |
| 35 | from stock OpenSSH. The JSON API fronts the same command registry with | |
| 36 | bearer tokens minted only over SSH; git transport never runs over it. | |
| 37 | *Anonymous surfaces* — HTTPS clone of public repos, =git://= where | |
| 38 | enabled, the read-only web UI — carry no credentials and expose only | |
| 39 | public data. HTTP push is refused via a pkt-line =ERR=, never a 401. | |
| 40 | ||
| 41 | * Attacker-controlled parsers | |
| 42 | ||
| 43 | Every parser that eats bytes from a pusher, a key registrant, or an | |
| 44 | anonymous client has a fuzz target and must never panic: | |
| 45 | ||
| 46 | =internal/protocol= — the SSH command tokenizer (fuzzed against argv | |
| 47 | round-tripping). | |
| 48 | =internal/gitd= — the =git://= pkt-line reader. | |
| 49 | =internal/sig= — the commit parser, the SSHSIG armor decoder and blob | |
| 50 | parser, and the OpenPGP armored-key reader. | |
| 51 | ||
| 52 | Run =deploy/audit.sh= to exercise them plus =go vet= and =govulncheck=. | |
| 53 | ||
| 54 | * Secret handling | |
| 55 | ||
| 56 | Tokens (web sessions, login links, email verification, API bearer tokens, | |
| 57 | deploy/CI) are random 256-bit values. Only their SHA-256 hash is stored, | |
| 58 | and verification is a database index lookup on that hash — the secret | |
| 59 | itself is never compared in Go, so there is no timing oracle to exploit. | |
| 60 | Webhook payloads are signed outbound with HMAC-SHA256; the forge verifies | |
| 61 | no inbound HMAC. | |
| 62 | ||
| 63 | * Network-facing request forgery | |
| 64 | ||
| 65 | Anything that makes the *server* open an outbound connection to a | |
| 66 | user-supplied address — webhook delivery, GitHub-history import | |
| 67 | =--api-base=, mirror remotes — passes the same SSRF guard: the scheme | |
| 68 | must be http/https and, unless =webhooks.allow_local= is set, the | |
| 69 | resolved address must not be loopback, private, or link-local. The | |
| 70 | webhook dialer re-checks at connect time so a DNS answer that changes | |
| 71 | after validation still cannot reach private space. Redirects are never | |
| 72 | followed. | |
| 73 | ||
| 74 | * Web responses | |
| 75 | ||
| 76 | Every response carries =Content-Security-Policy= (no scripts, no plugins, | |
| 77 | no embedding; inline styles allowed for chroma and label chips; images | |
| 78 | from any origin so external README images render), =X-Frame-Options: | |
| 79 | DENY=, =X-Content-Type-Options: nosniff=, =Referrer-Policy: no-referrer=, | |
| 80 | and =Strict-Transport-Security= when TLS is on. The UI needs no | |
| 81 | JavaScript, so =script-src 'none'= costs nothing. | |
| 82 | ||
| 83 | * Residual risks, accepted | |
| 84 | ||
| 85 | External images in rendered READMEs load from their origin (no image | |
| 86 | proxy), which a repo author can use as a tracking pixel against a | |
| 87 | viewer. Documented; proxying is future work. | |
| 88 | Backups are consistent per the DB-snapshot-first ordering but are not a | |
| 89 | single atomic snapshot; a few orphaned git objects are possible and | |
| 90 | harmless (see =admin.org=). | |
| 91 | A global signature-verification epoch over-invalidates the cache on any | |
| 92 | trust-input change. Correct, not a leak; a performance tradeoff. | |
e2e/security_test.go added +47
| @@ -0,0 +1,47 @@ | ||
| 1 | package e2e | |
| 2 | ||
| 3 | import ( | |
| 4 | "net/http" | |
| 5 | "strings" | |
| 6 | "testing" | |
| 7 | ) | |
| 8 | ||
| 9 | func TestSecurityHeaders(t *testing.T) { | |
| 10 | inst := startInstance(t) | |
| 11 | aliceKey := inst.newKey(t, "alice") | |
| 12 | inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub") | |
| 13 | inst.ssh(t, aliceKey, "", "repo", "create", "alice/app") | |
| 14 | ||
| 15 | resp, err := http.Get(inst.base() + "/") | |
| 16 | if err != nil { | |
| 17 | t.Fatal(err) | |
| 18 | } | |
| 19 | resp.Body.Close() | |
| 20 | h := resp.Header | |
| 21 | csp := h.Get("Content-Security-Policy") | |
| 22 | for _, want := range []string{"script-src 'none'", "frame-ancestors 'none'", "object-src 'none'"} { | |
| 23 | if !strings.Contains(csp, want) { | |
| 24 | t.Errorf("CSP missing %q: %s", want, csp) | |
| 25 | } | |
| 26 | } | |
| 27 | if h.Get("X-Frame-Options") != "DENY" { | |
| 28 | t.Errorf("X-Frame-Options = %q", h.Get("X-Frame-Options")) | |
| 29 | } | |
| 30 | if h.Get("X-Content-Type-Options") != "nosniff" { | |
| 31 | t.Errorf("nosniff missing") | |
| 32 | } | |
| 33 | if h.Get("Referrer-Policy") != "no-referrer" { | |
| 34 | t.Errorf("Referrer-Policy = %q", h.Get("Referrer-Policy")) | |
| 35 | } | |
| 36 | // TLS is off in tests, so HSTS must NOT be set (it would poison | |
| 37 | // plain-HTTP clients). | |
| 38 | if h.Get("Strict-Transport-Security") != "" { | |
| 39 | t.Errorf("HSTS set without TLS") | |
| 40 | } | |
| 41 | // Headers are present on repo pages too, not just the root. | |
| 42 | resp2, _ := http.Get(inst.base() + "/alice/app") | |
| 43 | resp2.Body.Close() | |
| 44 | if resp2.Header.Get("Content-Security-Policy") == "" { | |
| 45 | t.Error("CSP missing on repo page") | |
| 46 | } | |
| 47 | } | |
go.mod +1 −1
| @@ -18,7 +18,7 @@ require ( | ||
| 18 | 18 | |
| 19 | 19 | require ( |
| 20 | 20 | github.com/aymerick/douceur v0.2.0 // indirect |
| 21 | github.com/cloudflare/circl v1.6.2 // indirect | |
| 21 | github.com/cloudflare/circl v1.6.3 // indirect | |
| 22 | 22 | github.com/cpuguy83/go-md2man/v2 v2.0.6 // indirect |
| 23 | 23 | github.com/dlclark/regexp2/v2 v2.2.1 // indirect |
| 24 | 24 | github.com/dustin/go-humanize v1.0.1 // indirect |
go.sum +2 −2
| @@ -10,8 +10,8 @@ github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs | ||
| 10 | 10 | github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= |
| 11 | 11 | github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= |
| 12 | 12 | github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= |
| 13 | github.com/cloudflare/circl v1.6.2 h1:hL7VBpHHKzrV5WTfHCaBsgx/HGbBYlgrwvNXEVDYYsQ= | |
| 14 | github.com/cloudflare/circl v1.6.2/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= | |
| 13 | github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8= | |
| 14 | github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= | |
| 15 | 15 | github.com/cpuguy83/go-md2man/v2 v2.0.6 h1:XJtiaUW6dEEqVuZiMTn1ldk455QWwEIsMIJlo5vtkx0= |
| 16 | 16 | github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= |
| 17 | 17 | github.com/dlclark/regexp2/v2 v2.2.1 h1:mf4KkFUj0gJuarK8P+LgiS+Lit7m9N1yAwEfPbee7R0= |
internal/gitd/fuzz_test.go added +24
| @@ -0,0 +1,24 @@ | ||
| 1 | package gitd | |
| 2 | ||
| 3 | import ( | |
| 4 | "bytes" | |
| 5 | "testing" | |
| 6 | ) | |
| 7 | ||
| 8 | // FuzzReadPktLine hammers the only parser in the anonymous git:// path | |
| 9 | // with attacker-controlled bytes: it must never panic and never return a | |
| 10 | // line longer than the pkt-line format allows. | |
| 11 | func FuzzReadPktLine(f *testing.F) { | |
| 12 | f.Add([]byte("003egit-upload-pack /a/b\x00host=example.com\x00")) | |
| 13 | f.Add([]byte("0000")) | |
| 14 | f.Add([]byte("0004")) | |
| 15 | f.Add([]byte("ffff" + "x")) | |
| 16 | f.Add([]byte("00zz")) | |
| 17 | f.Add([]byte("")) | |
| 18 | f.Fuzz(func(t *testing.T, data []byte) { | |
| 19 | line, err := readPktLine(bytes.NewReader(data)) | |
| 20 | if err == nil && len(line) > 65516 { | |
| 21 | t.Fatalf("line longer than pkt-line max: %d", len(line)) | |
| 22 | } | |
| 23 | }) | |
| 24 | } | |
internal/httpd/routes.go +31 −2
| @@ -113,9 +113,38 @@ func (s *Server) Handler() http.Handler { | ||
| 113 | 113 | for _, r := range s.Routes() { |
| 114 | 114 | mux.HandleFunc(r.Method+" "+r.Pattern, r.Handler) |
| 115 | 115 | } |
| 116 | if len(s.cfg.GoImport) == 0 { | |
| 117 | return mux | |
| 116 | var h http.Handler = mux | |
| 117 | if len(s.cfg.GoImport) > 0 { | |
| 118 | h = s.goImportHandler(mux) | |
| 118 | 119 | } |
| 120 | return s.securityHeaders(h) | |
| 121 | } | |
| 122 | ||
| 123 | // securityHeaders sets defensive response headers on every reply. The CSP | |
| 124 | // is strict where it can be: no scripts at all (the UI needs none), no | |
| 125 | // plugins, no embedding. Inline styles are allowed because chroma emits | |
| 126 | // inline style attributes on highlighted code and label chips carry their | |
| 127 | // color inline. Images may load from anywhere so external README images | |
| 128 | // still render; they are the one thing a reader-facing forge can't police | |
| 129 | // without a proxy. | |
| 130 | func (s *Server) securityHeaders(next http.Handler) http.Handler { | |
| 131 | const csp = "default-src 'self'; script-src 'none'; style-src 'self' 'unsafe-inline'; " + | |
| 132 | "img-src * data:; object-src 'none'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'" | |
| 133 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 134 | hd := w.Header() | |
| 135 | hd.Set("Content-Security-Policy", csp) | |
| 136 | hd.Set("X-Frame-Options", "DENY") | |
| 137 | hd.Set("X-Content-Type-Options", "nosniff") | |
| 138 | hd.Set("Referrer-Policy", "no-referrer") | |
| 139 | hd.Set("Cross-Origin-Opener-Policy", "same-origin") | |
| 140 | if s.cfg.HTTP.TLS != "off" { | |
| 141 | hd.Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains") | |
| 142 | } | |
| 143 | next.ServeHTTP(w, r) | |
| 144 | }) | |
| 145 | } | |
| 146 | ||
| 147 | func (s *Server) goImportHandler(mux http.Handler) http.Handler { | |
| 119 | 148 | // Vanity Go modules: ?go-get=1 requests under a configured module |
| 120 | 149 | // path answer with the go-import meta tag before normal routing. |
| 121 | 150 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
internal/sig/fuzz_test.go added +39
| @@ -0,0 +1,39 @@ | ||
| 1 | package sig | |
| 2 | ||
| 3 | import "testing" | |
| 4 | ||
| 5 | // Every parser here eats bytes that arrive over the wire from whoever can | |
| 6 | // push or register a key. None may panic on arbitrary input. | |
| 7 | ||
| 8 | func FuzzParseCommit(f *testing.F) { | |
| 9 | f.Add([]byte("tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904\nauthor A <a@b> 1 +0000\ncommitter A <a@b> 1 +0000\n\nmsg\n")) | |
| 10 | f.Add([]byte("tree x\ngpgsig -----BEGIN PGP SIGNATURE-----\n gibberish\n -----END PGP SIGNATURE-----\nauthor <>\n\n\n")) | |
| 11 | f.Add([]byte("\x00\x01\x02")) | |
| 12 | f.Add([]byte("")) | |
| 13 | f.Fuzz(func(t *testing.T, raw []byte) { | |
| 14 | ParseCommit(raw) | |
| 15 | }) | |
| 16 | } | |
| 17 | ||
| 18 | func FuzzDecodeArmorAndParseSSHSig(f *testing.F) { | |
| 19 | f.Add([]byte("-----BEGIN SSH SIGNATURE-----\nU1NIU0lHAAAAAQ==\n-----END SSH SIGNATURE-----\n")) | |
| 20 | f.Add([]byte("SSHSIG")) | |
| 21 | f.Add([]byte("-----BEGIN SSH SIGNATURE-----\n-----END SSH SIGNATURE-----\n")) | |
| 22 | f.Add([]byte("")) | |
| 23 | f.Fuzz(func(t *testing.T, data []byte) { | |
| 24 | if blob, err := decodeArmor(data, "SSH SIGNATURE"); err == nil { | |
| 25 | parseSSHSig(blob) | |
| 26 | } | |
| 27 | // The raw blob path too: parseSSHSig on undecoded input. | |
| 28 | parseSSHSig(data) | |
| 29 | }) | |
| 30 | } | |
| 31 | ||
| 32 | func FuzzParsePGPKey(f *testing.F) { | |
| 33 | f.Add([]byte("-----BEGIN PGP PUBLIC KEY BLOCK-----\n\nmQENBF==\n-----END PGP PUBLIC KEY BLOCK-----\n")) | |
| 34 | f.Add([]byte("")) | |
| 35 | f.Add([]byte("not a key")) | |
| 36 | f.Fuzz(func(t *testing.T, armored []byte) { | |
| 37 | ParsePGPKey(armored) | |
| 38 | }) | |
| 39 | } | |
internal/sig/sshsig.go +11 −3
| @@ -164,12 +164,20 @@ func decodeArmor(armored []byte, label string) ([]byte, error) { | ||
| 164 | 164 | end := "-----END " + label + "-----" |
| 165 | 165 | s := string(armored) |
| 166 | 166 | i := strings.Index(s, begin) |
| 167 | j := strings.Index(s, end) | |
| 168 | if i < 0 || j < i { | |
| 167 | if i < 0 { | |
| 169 | 168 | return nil, fmt.Errorf("no %s armor", label) |
| 170 | 169 | } |
| 170 | bodyStart := i + len(begin) | |
| 171 | // Search for the end marker only after the begin block, so an end | |
| 172 | // marker overlapping the begin line's trailing dashes cannot yield a | |
| 173 | // negative-length body. | |
| 174 | rel := strings.Index(s[bodyStart:], end) | |
| 175 | if rel < 0 { | |
| 176 | return nil, fmt.Errorf("no %s armor", label) | |
| 177 | } | |
| 178 | j := bodyStart + rel | |
| 171 | 179 | var b64 strings.Builder |
| 172 | for _, line := range strings.Split(s[i+len(begin):j], "\n") { | |
| 180 | for _, line := range strings.Split(s[bodyStart:j], "\n") { | |
| 173 | 181 | line = strings.TrimSpace(line) |
| 174 | 182 | if line == "" || strings.HasPrefix(line, "=") || strings.Contains(line, ":") { |
| 175 | 183 | continue // armor checksum or header line |
internal/sig/testdata/fuzz/FuzzDecodeArmorAndParseSSHSig/480252f8539b5333 added +2
| @@ -0,0 +1,2 @@ | ||
| 1 | go test fuzz v1 | |
| 2 | []byte("-----BEGIN SSH SIGNATURE-----END SSH SIGNATURE-----") | |