A CLI-first git forge.

cli forge git self-hosted

https://gitbay.org

Commit d38eddbbf4

d38eddbbf4297aeaf3021580f9e8ef40210bbe46

parent: 4a92b36565

Verified · cmc

cmc <hello@cleberg.net> · 2026-08-25T00:42:17Z

Security hardening: fuzzing, headers, threat model, host sandbox

Refs #28.

Software:
- Fuzz targets for every attacker-facing parser: pkt-line reader
  (gitd), commit parser, SSHSIG armor decoder and blob parser, OpenPGP
  key reader (internal/sig). The armor fuzzer found a real bug —
  decodeArmor sliced a negative range when the END marker overlapped
  the BEGIN line's trailing dashes; fixed by searching for END only
  after the BEGIN block. The crasher is kept as a regression seed.
- Web security headers on every response: a scripts-forbidden CSP
  (inline styles allowed for chroma/label chips, images from any
  origin for README content), X-Frame-Options DENY, nosniff,
  no-referrer, and HSTS when TLS is on.
- govulncheck flagged CIRCL GO-2026-4550 (secp384r1) reachable via
  OpenPGP key parsing; bumped circl 1.6.2 -> 1.6.3, scan now clean.
- deploy/audit.sh runs vet + govulncheck + a fuzz smoke pass.
- docs/threat-model.org: what the forge trusts and never does, the
  parser inventory, secret handling (hash-lookup, no Go-level compare),
  SSRF surfaces, headers, accepted residual risks.
- release.sh optionally minisigns SHA256SUMS.

Host (cloud-init):
- systemd sandbox: SystemCallFilter=@system-service,
  RestrictAddressFamilies, PrivateDevices, LockPersonality,
  MemoryDenyWriteExecute, and more, keeping only CAP_NET_BIND_SERVICE.
- unattended-upgrades for OS security patches; fail2ban and
  MaxStartups/MaxAuthTries on the admin sshd (2222).
- gitbay-monitor.timer posts disk/service/cert status hourly to an
  optional webhook.
- admin.org gains a Security operational checklist.
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.
4set -eu
5cd "$(dirname "$0")/.."
6
7echo "== go vet =="
8go vet ./...
9
10echo "== govulncheck =="
11go run golang.org/x/vuln/cmd/govulncheck@latest ./...
12
13echo "== fuzz smoke (10s each parser) =="
14go test -run xxx -fuzz FuzzReadPktLine -fuzztime 10s ./internal/gitd/
15go test -run xxx -fuzz FuzzParseCommit -fuzztime 10s ./internal/sig/
16go test -run xxx -fuzz FuzzDecodeArmorAndParseSSHSig -fuzztime 10s ./internal/sig/
17go test -run xxx -fuzz FuzzParsePGPKey -fuzztime 10s ./internal/sig/
18go test -run xxx -fuzz FuzzTokenizeNoPanic -fuzztime 10s ./internal/protocol/
19
20echo "== all clear =="
deploy/cloud-init.yaml +89 −2
@@ -17,6 +17,8 @@ package_update: true
1717 packages:
1818 - git
1919 - ufw
20 - unattended-upgrades
21 - fail2ban
2022
2123 write_files:
2224 # Admin sshd on 2222. Ubuntu 24.04 socket-activates sshd, so the port
@@ -25,6 +27,74 @@ write_files:
2527 content: |
2628 Port 2222
2729 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
2898 - path: /etc/systemd/system/ssh.socket.d/override.conf
2999 content: |
30100 [Socket]
@@ -77,8 +147,24 @@ write_files:
77147 ReadWritePaths=/var/lib/gitbay /var/backups/gitbay
78148 PrivateTmp=yes
79149 ProtectKernelTunables=yes
150 ProtectKernelModules=yes
80151 ProtectControlGroups=yes
152 ProtectHostname=yes
153 ProtectClock=yes
154 ProtectKernelLogs=yes
81155 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
82168
83169 [Install]
84170 WantedBy=multi-user.target
@@ -146,5 +232,6 @@ runcmd:
146232 - ufw --force enable
147233 - systemctl daemon-reload
148234 - 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
3535 shasum -a 256 -- * > SHA256SUMS
3636 fi
3737 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.
41if [ -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"
44fi
3845 cat SHA256SUMS
docs/admin.org +32
@@ -182,6 +182,38 @@ Replace the binary, restart the unit. Migrations apply automatically and
182182 are transactional; hook scripts under =<root>/hooks= are rewritten at
183183 startup to point at the current binary path.
184184
185* Security
186
187The =docs/threat-model.org= file is the reference for what the forge
188trusts 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
185217 * Odds and ends
186218
187219 - 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
3What the forge trusts, what it refuses to do, and where the boundaries
4are. This is the reference for security review; it complements the audit
5log 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
43Every parser that eats bytes from a pusher, a key registrant, or an
44anonymous 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
52Run =deploy/audit.sh= to exercise them plus =go vet= and =govulncheck=.
53
54* Secret handling
55
56Tokens (web sessions, login links, email verification, API bearer tokens,
57deploy/CI) are random 256-bit values. Only their SHA-256 hash is stored,
58and verification is a database index lookup on that hash — the secret
59itself is never compared in Go, so there is no timing oracle to exploit.
60Webhook payloads are signed outbound with HMAC-SHA256; the forge verifies
61no inbound HMAC.
62
63* Network-facing request forgery
64
65Anything that makes the *server* open an outbound connection to a
66user-supplied address — webhook delivery, GitHub-history import
67=--api-base=, mirror remotes — passes the same SSRF guard: the scheme
68must be http/https and, unless =webhooks.allow_local= is set, the
69resolved address must not be loopback, private, or link-local. The
70webhook dialer re-checks at connect time so a DNS answer that changes
71after validation still cannot reach private space. Redirects are never
72followed.
73
74* Web responses
75
76Every response carries =Content-Security-Policy= (no scripts, no plugins,
77no embedding; inline styles allowed for chroma and label chips; images
78from any origin so external README images render), =X-Frame-Options:
79DENY=, =X-Content-Type-Options: nosniff=, =Referrer-Policy: no-referrer=,
80and =Strict-Transport-Security= when TLS is on. The UI needs no
81JavaScript, 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 @@
1package e2e
2
3import (
4 "net/http"
5 "strings"
6 "testing"
7)
8
9func 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 (
1818
1919 require (
2020 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
2222 github.com/cpuguy83/go-md2man/v2 v2.0.6 // indirect
2323 github.com/dlclark/regexp2/v2 v2.2.1 // indirect
2424 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
1010github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
1111github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
1212github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
13github.com/cloudflare/circl v1.6.2 h1:hL7VBpHHKzrV5WTfHCaBsgx/HGbBYlgrwvNXEVDYYsQ=
14github.com/cloudflare/circl v1.6.2/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
13github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8=
14github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
1515github.com/cpuguy83/go-md2man/v2 v2.0.6 h1:XJtiaUW6dEEqVuZiMTn1ldk455QWwEIsMIJlo5vtkx0=
1616github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
1717github.com/dlclark/regexp2/v2 v2.2.1 h1:mf4KkFUj0gJuarK8P+LgiS+Lit7m9N1yAwEfPbee7R0=
internal/gitd/fuzz_test.go added +24
@@ -0,0 +1,24 @@
1package gitd
2
3import (
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.
11func 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 {
113113 for _, r := range s.Routes() {
114114 mux.HandleFunc(r.Method+" "+r.Pattern, r.Handler)
115115 }
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)
118119 }
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.
130func (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
147func (s *Server) goImportHandler(mux http.Handler) http.Handler {
119148 // Vanity Go modules: ?go-get=1 requests under a configured module
120149 // path answer with the go-import meta tag before normal routing.
121150 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
internal/sig/fuzz_test.go added +39
@@ -0,0 +1,39 @@
1package sig
2
3import "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
8func 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
18func 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
32func 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) {
164164 end := "-----END " + label + "-----"
165165 s := string(armored)
166166 i := strings.Index(s, begin)
167 j := strings.Index(s, end)
168 if i < 0 || j < i {
167 if i < 0 {
169168 return nil, fmt.Errorf("no %s armor", label)
170169 }
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
171179 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") {
173181 line = strings.TrimSpace(line)
174182 if line == "" || strings.HasPrefix(line, "=") || strings.Contains(line, ":") {
175183 continue // armor checksum or header line
internal/sig/testdata/fuzz/FuzzDecodeArmorAndParseSSHSig/480252f8539b5333 added +2
@@ -0,0 +1,2 @@
1go test fuzz v1
2[]byte("-----BEGIN SSH SIGNATURE-----END SSH SIGNATURE-----")