Follow-ups review fixes: LFS key pin, mirror numeric hosts, visibility re-auth, docs !514
21 files changed, +220 −74
Layout: unified · split
.gitbay/wiki/Admin.org +5 −1
| @@ -934,11 +934,15 @@ other repositories' builds queue until =-repos= is removed again, which | |||
| 934 | is a pause, not an outage. | 934 | is a pause, not an outage. |
| 935 | 935 | ||
| 936 | #+begin_src sh | 936 | #+begin_src sh |
| 937 | # on a machine with an admin gitbay identity (root on the host has none), | ||
| 938 | # with the runner's public key copied from the host: | ||
| 937 | gitbay repo create cmc/runner-scratch # then push a .gitbay/ci.yml naming the image | 939 | gitbay repo create cmc/runner-scratch # then push a .gitbay/ci.yml naming the image |
| 940 | scp -P 2222 root@<host>:/var/lib/gitbay-runner/.ssh/id_ed25519.pub runner.pub | ||
| 941 | gitbay repo runner add cmc/runner-scratch < runner.pub | ||
| 938 | # on the host: | 942 | # on the host: |
| 939 | gitbay repo runner add cmc/runner-scratch < /var/lib/gitbay-runner/.ssh/id_ed25519.pub | ||
| 940 | sed -i 's#^ExecStart=/usr/local/bin/gitbay-runner #&-repos cmc/runner-scratch #' /etc/systemd/system/gitbay-runner.service.d/override.conf | 943 | sed -i 's#^ExecStart=/usr/local/bin/gitbay-runner #&-repos cmc/runner-scratch #' /etc/systemd/system/gitbay-runner.service.d/override.conf |
| 941 | systemctl daemon-reload && systemctl restart gitbay-runner | 944 | systemctl daemon-reload && systemctl restart gitbay-runner |
| 945 | # back on the admin machine: | ||
| 942 | gitbay build log cmc/runner-scratch 1 # green: remove -repos, redeploy, delete the scratch repository | 946 | gitbay build log cmc/runner-scratch 1 # green: remove -repos, redeploy, delete the scratch repository |
| 943 | #+end_src | 947 | #+end_src |
| 944 | 948 | ||
.gitbay/wiki/Architecture/05-Identity-and-Access.org +2 −1
| @@ -121,7 +121,8 @@ button and the displayed status (=internal/control/mr.go=): | |||
| 121 | - Commands that create a credential (SSH, deploy and runner keys, API | 121 | - Commands that create a credential (SSH, deploy and runner keys, API |
| 122 | tokens, email verification, login links, PGP keys, device tokens) or | 122 | tokens, email verification, login links, PGP keys, device tokens) or |
| 123 | grant access (repository and organization roles, teams, transfers, | 123 | grant access (repository and organization roles, teams, transfers, |
| 124 | admin promote and enable, webhooks, secrets, mirrors) are refused | 124 | repository visibility, admin promote and enable, webhooks, secrets, |
| 125 | mirrors) are refused | ||
| 125 | from a browser session that signed in more than 15 minutes ago | 126 | from a browser session that signed in more than 15 minutes ago |
| 126 | (=control.ReauthWindow=, =Command.NeedsRecentSignIn=). The sign-in | 127 | (=control.ReauthWindow=, =Command.NeedsRecentSignIn=). The sign-in |
| 127 | time is =web_sessions.created_at=, which idle renewal does not move; | 128 | time is =web_sessions.created_at=, which idle renewal does not move; |
.gitbay/wiki/Architecture/09-Controls.org +1 −1
| @@ -75,7 +75,7 @@ chapter names of OWASP ASVS 4.0 where one fits. | |||
| 75 | 75 | ||
| 76 | | Control | Status | Evidence | | 76 | | Control | Status | Evidence | |
| 77 | |---------------------------------------------+----------+------------------------------------------------------------------| | 77 | |---------------------------------------------+----------+------------------------------------------------------------------| |
| 78 | | SSRF protection on user-supplied URLs | in place | webhooks at save and connect; mirrors at save and sync, =repo import= before its fetch, git pinned to the checked address (=internal/gitpin=) | | 78 | | SSRF protection on user-supplied URLs | partial | webhooks at save and connect; mirrors at save and sync, =repo import= before its fetch, git pinned to the checked address (=internal/gitpin=); =repo import-issues --api-base= checked once and fetched unpinned (#301) | |
| 79 | | Webhook payload integrity | in place | HMAC-SHA256 header | | 79 | | Webhook payload integrity | in place | HMAC-SHA256 header | |
| 80 | | SMTP credentials protected in transit | in place | STARTTLS required for non-local relays, implicit TLS optional (=internal/mail/mail.go=) | | 80 | | SMTP credentials protected in transit | in place | STARTTLS required for non-local relays, implicit TLS optional (=internal/mail/mail.go=) | |
| 81 | | Upload size limits | in place | per-owner storage quota at push (=internal/sshd/sshd.go=); API body 1 MiB | | 81 | | Upload size limits | in place | per-owner storage quota at push (=internal/sshd/sshd.go=); API body 1 MiB | |
.gitbay/wiki/Architecture/10-Known-Gaps.org +1 −1
| @@ -13,7 +13,7 @@ what the 2026-09-27 review found; remove a row when its issue closes. | |||
| 13 | | #259 | Recovery | No restore has been exercised; the drill is written (Admin wiki) and not yet run | high | | 13 | | #259 | Recovery | No restore has been exercised; the drill is written (Admin wiki) and not yet run | high | |
| 14 | | #260 | CI network | Builds share the runner's source address; no egress policy | medium | | 14 | | #260 | CI network | Builds share the runner's source address; no egress policy | medium | |
| 15 | | #261 | Various | Migration foreign-key check after commit; three web writes bypass dispatch; documentation drift | medium | | 15 | | #261 | Various | Migration foreign-key check after commit; three web writes bypass dispatch; documentation drift | medium | |
| 16 | | #301 | SSRF | =repo import-issues --api-base= fetches without an address check or pin | medium | | 16 | | #301 | SSRF | =repo import-issues --api-base= checks the address once; fetches unpinned | medium | |
| 17 | 17 | ||
| 18 | * Not filed | 18 | * Not filed |
| 19 | 19 | ||
.gitbay/wiki/Threat-Model.org +5 −4
| @@ -102,10 +102,11 @@ at connect time, and mirror sync and =repo import= resolve and check | |||
| 102 | immediately before running git and pin it to the checked addresses | 102 | immediately before running git and pin it to the checked addresses |
| 103 | (=internal/gitpin=), so a DNS answer that changes after validation | 103 | (=internal/gitpin=), so a DNS answer that changes after validation |
| 104 | still cannot reach private space. Redirects are never followed. | 104 | still cannot reach private space. Redirects are never followed. |
| 105 | =repo import= refuses =git://=, which cannot be pinned, and a host | 105 | =repo import= refuses =git://=, which cannot be pinned. Mirror sync |
| 106 | written as a bare number or in hex/octal (=0x7f.1=, =2130706433=, | 106 | and =repo import= refuse a host written as a bare number or in |
| 107 | =127.1=) rather than dotted decimal, since that form resolves | 107 | hex/octal (=0x7f.1=, =2130706433=, =127.1=) rather than dotted |
| 108 | differently across parsers. GitHub-history import (=repo | 108 | decimal, since that form resolves differently across parsers; =repo |
| 109 | mirror add= refuses it when the mirror is saved. GitHub-history import (=repo | ||
| 109 | import-issues --api-base=) is not yet pinned (#301). | 110 | import-issues --api-base=) is not yet pinned (#301). |
| 110 | 111 | ||
| 111 | * Rendering pushed markup | 112 | * Rendering pushed markup |
.gitbay/wiki/Users.org +2 −1
| @@ -702,7 +702,8 @@ or =--all= ends them from the terminal, which is where a lost laptop is | |||
| 702 | handled. | 702 | handled. |
| 703 | 703 | ||
| 704 | Actions that create a credential or grant access (adding a key, token | 704 | Actions that create a credential or grant access (adding a key, token |
| 705 | or email, org and repository roles, transfers) ask you to sign in again | 705 | or email, org and repository roles, transfers, a repository's |
| 706 | visibility) ask you to sign in again | ||
| 706 | when your web sign-in is older than 15 minutes. The form shows a "Sign | 707 | when your web sign-in is older than 15 minutes. The form shows a "Sign |
| 707 | in again" link and the login returns to the page. Idle renewal does not | 708 | in again" link and the login returns to the page. Idle renewal does not |
| 708 | extend this window. | 709 | extend this window. |
CHANGELOG.org +27 −16
| @@ -9,8 +9,9 @@ anything beyond "replace the binary and restart" is needed. | |||
| 9 | - =webhook add= reads the signing secret from stdin with =--secret -=; | 9 | - =webhook add= reads the signing secret from stdin with =--secret -=; |
| 10 | a value on the command line is refused, since argv shows in process | 10 | a value on the command line is refused, since argv shows in process |
| 11 | listings and shell history. A script that passed the value must pipe | 11 | listings and shell history. A script that passed the value must pipe |
| 12 | it: =printf %s "$SECRET" | gitbay webhook add <repo> <url> --secret -= | 12 | it: =printf %s "$SECRET" | gitbay webhook add <repo> <url> --secret -=. |
| 13 | (#284). | 13 | The iOS app's webhook add breaks until it sends the secret on stdin |
| 14 | (krz/gitbay-ios#21) (#284). | ||
| 14 | - =repo import --from= takes http and https URLs only; =git://= is | 15 | - =repo import --from= takes http and https URLs only; =git://= is |
| 15 | refused, since its connection cannot be held to a checked address. | 16 | refused, since its connection cannot be held to a checked address. |
| 16 | The host is resolved and checked like a mirror's, git connects only | 17 | The host is resolved and checked like a mirror's, git connects only |
| @@ -18,21 +19,16 @@ anything beyond "replace the binary and restart" is needed. | |||
| 18 | global gitconfig are ignored. A source that redirects (a renamed | 19 | global gitconfig are ignored. A source that redirects (a renamed |
| 19 | repository) fails; import from the URL it redirects to. Needs git | 20 | repository) fails; import from the URL it redirects to. Needs git |
| 20 | 2.37 or later on the server (#298). | 21 | 2.37 or later on the server (#298). |
| 21 | *Upgrade note.* =repo import= and mirror sync now refuse a =git://= | 22 | - =repo mirror add= refuses a host written numerically (=127.1=, |
| 22 | source and a =--from=/remote URL carrying a query or fragment. A | 23 | =2130706433=, =0x7f.1=) when the mirror is added, rather than at its |
| 23 | mirror or import whose host is written numerically (=127.1=, | 24 | first sync (#298). |
| 24 | =2130706433=, =0x7f.1=) rather than as a dotted address is refused | ||
| 25 | too; rewrite it before upgrading. | ||
| 26 | - A web session older than 15 minutes cannot mint a credential or grant | ||
| 27 | access — keys, PGP keys, tokens, org membership, and the admin | ||
| 28 | promote/enable actions — and the form it tried shows a sign-in link | ||
| 29 | that returns there (#297). | ||
| 30 | - A browser session creates credentials and grants access — keys, PGP | 25 | - A browser session creates credentials and grants access — keys, PGP |
| 31 | keys, tokens, verified addresses, org and repository roles, transfers, | 26 | keys, tokens, verified addresses, org and repository roles, transfers, |
| 32 | webhooks, secrets, mirrors, and the admin promote/enable actions — | 27 | repository visibility, webhooks, secrets, mirrors, and the admin |
| 33 | only within 15 minutes of signing in. An older session gets the form | 28 | promote/enable and repository-visibility actions — only within 15 |
| 34 | back with a "Sign in again" link, and the login returns to it. SSH and | 29 | minutes of signing in. An |
| 35 | API tokens are unaffected (#297). | 30 | older session gets the form back with a "Sign in again" link, and the |
| 31 | login returns to it. SSH and API tokens are unaffected (#297). | ||
| 36 | - The builds page's status badge section gives an org-mode snippet | 32 | - The builds page's status badge section gives an org-mode snippet |
| 37 | beside the Markdown one, for a README.org (#299). | 33 | beside the Markdown one, for a README.org (#299). |
| 38 | - API tokens on the settings page: create with a scope and optional | 34 | - API tokens on the settings page: create with a scope and optional |
| @@ -95,6 +91,12 @@ entries; if extracting one leaves a repository's =refs/= directory | |||
| 95 | missing, =gitbayd admin backup --verify <archive>= names it, and | 91 | missing, =gitbayd admin backup --verify <archive>= names it, and |
| 96 | =mkdir -p <root>/repos/<owner>/<name>.git/refs= fixes it. | 92 | =mkdir -p <root>/repos/<owner>/<name>.git/refs= fixes it. |
| 97 | 93 | ||
| 94 | *Upgrade note.* =repo import= now refuses a =git://= source and a | ||
| 95 | =--from= URL carrying a query or fragment. Mirrors and imports whose | ||
| 96 | host is written numerically (=127.1=, =2130706433=, =0x7f.1=) rather | ||
| 97 | than as a dotted address are refused too; rewrite the URL before | ||
| 98 | upgrading (#298). | ||
| 99 | |||
| 98 | - A token with a =--ttl= is refused on every command that creates a | 100 | - A token with a =--ttl= is refused on every command that creates a |
| 99 | credential: tokens, keys, deploy keys, runner keys, login links, | 101 | credential: tokens, keys, deploy keys, runner keys, login links, |
| 100 | invites, accounts and verified addresses (#257). | 102 | invites, accounts and verified addresses (#257). |
| @@ -294,12 +296,21 @@ missing, =gitbayd admin backup --verify <archive>= names it, and | |||
| 294 | hour (#285). *Operators:* tokens minted before the upgrade are | 296 | hour (#285). *Operators:* tokens minted before the upgrade are |
| 295 | refused. git-lfs asks =git-lfs-authenticate= for a token each time | 297 | refused. git-lfs asks =git-lfs-authenticate= for a token each time |
| 296 | it runs, so only a transfer running across the restart fails, with | 298 | it runs, so only a transfer running across the restart fails, with |
| 297 | "repository not found"; running the command again fixes it. | 299 | "repository not found"; running the command again fixes it. A |
| 300 | download from a public repository that presents a token issued | ||
| 301 | before the upgrade is refused the same way, not served anonymously, | ||
| 302 | and needs the same rerun. | ||
| 298 | - Every LFS request also repeats the repository check for the token's | 303 | - Every LFS request also repeats the repository check for the token's |
| 299 | key: a collaborator whose access is revoked or reduced, or a reader | 304 | key: a collaborator whose access is revoked or reduced, or a reader |
| 300 | of a public repository made private, loses the token's use with the | 305 | of a public repository made private, loses the token's use with the |
| 301 | access, and an upload token stops working once its repository is | 306 | access, and an upload token stops working once its repository is |
| 302 | archived (#285). | 307 | archived (#285). |
| 308 | - LFS transfer tokens carry a hash of their key's fingerprint beside | ||
| 309 | its id, and a token whose key id now belongs to another key is | ||
| 310 | refused, since SQLite gives a new key the id of the last deleted one. | ||
| 311 | The token format changed: tokens minted before the upgrade are | ||
| 312 | refused, and a transfer running across the restart needs running | ||
| 313 | again (#303). | ||
| 303 | 314 | ||
| 304 | * v1.36.0 — 2026-09-23 | 315 | * v1.36.0 — 2026-09-23 |
| 305 | 316 | ||
internal/control/admin.go +5 −4
| @@ -80,10 +80,11 @@ func init() { | |||
| 80 | Examples: []string{"admin repo unarchive alice/old-project"}, | 80 | Examples: []string{"admin repo unarchive alice/old-project"}, |
| 81 | Run: runAdminRepoUnarchive}) | 81 | Run: runAdminRepoUnarchive}) |
| 82 | register(Command{Path: []string{"admin", "repo", "visibility"}, | 82 | register(Command{Path: []string{"admin", "repo", "visibility"}, |
| 83 | Summary: "set any repository's visibility (instance admins; audited)", | 83 | NeedsRecentSignIn: true, |
| 84 | Usage: "admin repo visibility <owner/name> public|private", | 84 | Summary: "set any repository's visibility (instance admins; audited)", |
| 85 | Examples: []string{"admin repo visibility alice/secret private"}, | 85 | Usage: "admin repo visibility <owner/name> public|private", |
| 86 | Run: runAdminRepoVisibility}) | 86 | Examples: []string{"admin repo visibility alice/secret private"}, |
| 87 | Run: runAdminRepoVisibility}) | ||
| 87 | register(Command{Path: []string{"admin", "repo", "delete"}, | 88 | register(Command{Path: []string{"admin", "repo", "delete"}, |
| 88 | Summary: "delete any repository (instance admins; audited)", | 89 | Summary: "delete any repository (instance admins; audited)", |
| 89 | Usage: "admin repo delete <owner/name> --yes", | 90 | Usage: "admin repo delete <owner/name> --yes", |
internal/control/control_test.go +2 −1
| @@ -180,7 +180,8 @@ func TestStdinCommandsReadStdin(t *testing.T) { | |||
| 180 | for _, cmd := range Commands() { | 180 | for _, cmd := range Commands() { |
| 181 | u := cmd.Usage | 181 | u := cmd.Usage |
| 182 | wants := strings.Contains(u, "--file -") || strings.Contains(u, "< ") || | 182 | wants := strings.Contains(u, "--file -") || strings.Contains(u, "< ") || |
| 183 | strings.Contains(u, "stdin") || strings.Contains(u, "--key -") | 183 | strings.Contains(u, "stdin") || strings.Contains(u, "--key -") || |
| 184 | strings.Contains(u, "--secret -") | ||
| 184 | if wants && !cmd.ReadsStdin { | 185 | if wants && !cmd.ReadsStdin { |
| 185 | t.Errorf("%s: usage %q reads stdin but ReadsStdin is not set", strings.Join(cmd.Path, " "), u) | 186 | t.Errorf("%s: usage %q reads stdin but ReadsStdin is not set", strings.Join(cmd.Path, " "), u) |
| 186 | } | 187 | } |
internal/control/mirrorcmd.go +9
| @@ -5,9 +5,11 @@ import ( | |||
| 5 | "errors" | 5 | "errors" |
| 6 | "fmt" | 6 | "fmt" |
| 7 | "io" | 7 | "io" |
| 8 | "net/url" | ||
| 8 | "strconv" | 9 | "strconv" |
| 9 | "strings" | 10 | "strings" |
| 10 | 11 | ||
| 12 | "gitbay.org/gitbay/internal/gitpin" | ||
| 11 | "gitbay.org/gitbay/internal/policy" | 13 | "gitbay.org/gitbay/internal/policy" |
| 12 | "gitbay.org/gitbay/internal/protocol" | 14 | "gitbay.org/gitbay/internal/protocol" |
| 13 | "gitbay.org/gitbay/internal/store" | 15 | "gitbay.org/gitbay/internal/store" |
| @@ -54,6 +56,13 @@ func runMirrorAdd(c *Ctx, args []string) int { | |||
| 54 | if path == "" || urlArg == "" || (direction != "push" && direction != "pull") { | 56 | if path == "" || urlArg == "" || (direction != "push" && direction != "pull") { |
| 55 | return c.usage() | 57 | return c.usage() |
| 56 | } | 58 | } |
| 59 | // Sync refuses a numerically written host; say so now, before a | ||
| 60 | // resolver gets to read it. | ||
| 61 | if u, err := url.Parse(urlArg); err == nil { | ||
| 62 | if err := gitpin.CheckHost(u.Hostname()); err != nil { | ||
| 63 | return c.failInput(err) | ||
| 64 | } | ||
| 65 | } | ||
| 57 | // The worker's git process dials this URL from the server: same SSRF | 66 | // The worker's git process dials this URL from the server: same SSRF |
| 58 | // surface as a webhook target, same rules. | 67 | // surface as a webhook target, same rules. |
| 59 | if err := webhook.ValidateURL(urlArg, c.Cfg.Webhooks.AllowLocal); err != nil { | 68 | if err := webhook.ValidateURL(urlArg, c.Cfg.Webhooks.AllowLocal); err != nil { |
internal/control/mirrorcmd_test.go added +29
| @@ -0,0 +1,29 @@ | |||
| 1 | package control | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "strings" | ||
| 5 | "testing" | ||
| 6 | |||
| 7 | "gitbay.org/gitbay/internal/protocol" | ||
| 8 | ) | ||
| 9 | |||
| 10 | // mirror add refuses a host sync would refuse as numeric, on an | ||
| 11 | // instance that allows local targets or not, and stores nothing (#298). | ||
| 12 | func TestMirrorAddRefusesNumericHost(t *testing.T) { | ||
| 13 | for _, allowLocal := range []bool{true, false} { | ||
| 14 | for _, host := range []string{"127.1", "2130706433", "0x7f.1"} { | ||
| 15 | c, errOut, st, _ := importCtx(t, allowLocal) | ||
| 16 | code := Dispatch(c, []string{"repo", "mirror", "add", "alice/app", "https://" + host + "/x.git", "--direction", "pull"}) | ||
| 17 | if code != protocol.ExitUsage || !strings.Contains(errOut.String(), "numeric address") { | ||
| 18 | t.Fatalf("%s (allow_local %v): exit %d, %q", host, allowLocal, code, errOut.String()) | ||
| 19 | } | ||
| 20 | repo, err := st.RepoByPath("alice/app") | ||
| 21 | if err != nil { | ||
| 22 | t.Fatal(err) | ||
| 23 | } | ||
| 24 | if ms, err := st.ListMirrors(repo.ID); err != nil || len(ms) != 0 { | ||
| 25 | t.Fatalf("%s: mirrors %v, %v", host, ms, err) | ||
| 26 | } | ||
| 27 | } | ||
| 28 | } | ||
| 29 | } | ||
internal/control/reauth_test.go +2
| @@ -109,6 +109,7 @@ func TestNeedsRecentSignInSet(t *testing.T) { | |||
| 109 | want := []string{ | 109 | want := []string{ |
| 110 | "admin email verify", | 110 | "admin email verify", |
| 111 | "admin invite", | 111 | "admin invite", |
| 112 | "admin repo visibility", | ||
| 112 | "admin user create", | 113 | "admin user create", |
| 113 | "admin user enable", | 114 | "admin user enable", |
| 114 | "admin user promote", | 115 | "admin user promote", |
| @@ -125,6 +126,7 @@ func TestNeedsRecentSignInSet(t *testing.T) { | |||
| 125 | "repo mirror add", | 126 | "repo mirror add", |
| 126 | "repo runner add", | 127 | "repo runner add", |
| 127 | "repo secret set", | 128 | "repo secret set", |
| 129 | "repo settings visibility", | ||
| 128 | "repo transfer", | 130 | "repo transfer", |
| 129 | "token create", | 131 | "token create", |
| 130 | "web login", | 132 | "web login", |
internal/control/repo.go +3 −1
| @@ -118,7 +118,9 @@ func init() { | |||
| 118 | Summary: "set repository visibility", | 118 | Summary: "set repository visibility", |
| 119 | Usage: "repo settings visibility <owner/name> public|private", | 119 | Usage: "repo settings visibility <owner/name> public|private", |
| 120 | Examples: []string{"repo settings visibility krz/gitbay public"}, | 120 | Examples: []string{"repo settings visibility krz/gitbay public"}, |
| 121 | Run: runSetVisibility}) | 121 | // Making a repository public shows it to everyone. |
| 122 | NeedsRecentSignIn: true, | ||
| 123 | Run: runSetVisibility}) | ||
| 122 | register(Command{Path: []string{"repo", "settings", "website"}, | 124 | register(Command{Path: []string{"repo", "settings", "website"}, |
| 123 | Summary: "set the repository website", | 125 | Summary: "set the repository website", |
| 124 | Usage: "repo settings website <owner/name> <url> ('' clears)", | 126 | Usage: "repo settings website <owner/name> <url> ('' clears)", |
internal/control/webhook.go +1 −1
| @@ -87,7 +87,7 @@ func runWebhookAdd(c *Ctx, args []string) int { | |||
| 87 | if err != nil { | 87 | if err != nil { |
| 88 | return c.fail(protocol.ExitFailure, "reading secret: %v", err) | 88 | return c.fail(protocol.ExitFailure, "reading secret: %v", err) |
| 89 | } | 89 | } |
| 90 | secret = strings.TrimRight(string(raw), "\n") | 90 | secret = strings.TrimRight(string(raw), "\r\n") |
| 91 | if secret == "" { | 91 | if secret == "" { |
| 92 | return c.fail(protocol.ExitUsage, "no secret on stdin (pipe it: printf %%s SECRET | ... --secret -)") | 92 | return c.fail(protocol.ExitUsage, "no secret on stdin (pipe it: printf %%s SECRET | ... --secret -)") |
| 93 | } | 93 | } |
internal/control/webhook_test.go +7 −3
| @@ -70,11 +70,15 @@ func TestWebhookAddSecretFromStdin(t *testing.T) { | |||
| 70 | if msg, code := run("not a secret\n", "webhook", "add", repo.Path(), "http://127.0.0.1/other"); code != protocol.ExitOK { | 70 | if msg, code := run("not a secret\n", "webhook", "add", repo.Path(), "http://127.0.0.1/other"); code != protocol.ExitOK { |
| 71 | t.Fatalf("no secret: exit %d, %q", code, msg) | 71 | t.Fatalf("no secret: exit %d, %q", code, msg) |
| 72 | } | 72 | } |
| 73 | // A secret from a file with CRLF line endings loses the \r too. | ||
| 74 | if msg, code := run("crlf\r\n", "webhook", "add", repo.Path(), "http://127.0.0.1/crlf", "--secret", "-"); code != protocol.ExitOK { | ||
| 75 | t.Fatalf("CRLF secret: exit %d, %q", code, msg) | ||
| 76 | } | ||
| 73 | hooks, err := st.ListWebhooks(repo.ID) | 77 | hooks, err := st.ListWebhooks(repo.ID) |
| 74 | if err != nil || len(hooks) != 2 { | 78 | if err != nil || len(hooks) != 3 { |
| 75 | t.Fatalf("hooks: %+v %v", hooks, err) | 79 | t.Fatalf("hooks: %+v %v", hooks, err) |
| 76 | } | 80 | } |
| 77 | if hooks[0].Secret != "s3cret" || hooks[1].Secret != "" { | 81 | if hooks[0].Secret != "s3cret" || hooks[1].Secret != "" || hooks[2].Secret != "crlf" { |
| 78 | t.Fatalf("secrets: %q, %q", hooks[0].Secret, hooks[1].Secret) | 82 | t.Fatalf("secrets: %q, %q, %q", hooks[0].Secret, hooks[1].Secret, hooks[2].Secret) |
| 79 | } | 83 | } |
| 80 | } | 84 | } |
internal/gitpin/gitpin.go +13 −4
| @@ -46,10 +46,8 @@ func Resolve(ctx context.Context, lookup Lookup, raw string, allowLocal bool) (R | |||
| 46 | if host == "" { | 46 | if host == "" { |
| 47 | return Remote{}, fmt.Errorf("URL has no host") | 47 | return Remote{}, fmt.Errorf("URL has no host") |
| 48 | } | 48 | } |
| 49 | if net.ParseIP(host) == nil && numericHost(host) { | 49 | if err := CheckHost(host); err != nil { |
| 50 | // 127.1, 2130706433 and 0x7f.1 are loopback to curl's parser | 50 | return Remote{}, err |
| 51 | // but not to Go's; refuse rather than leave them to a resolver. | ||
| 52 | return Remote{}, fmt.Errorf("host %q is a numeric address in a form other than dotted decimal; write it as a.b.c.d", host) | ||
| 53 | } | 51 | } |
| 54 | ips, err := lookup(ctx, host) | 52 | ips, err := lookup(ctx, host) |
| 55 | if err != nil { | 53 | if err != nil { |
| @@ -65,6 +63,17 @@ func Resolve(ctx context.Context, lookup Lookup, raw string, allowLocal bool) (R | |||
| 65 | return Remote{URL: u, IPs: ips}, nil | 63 | return Remote{URL: u, IPs: ips}, nil |
| 66 | } | 64 | } |
| 67 | 65 | ||
| 66 | // CheckHost refuses a host written as a number in a form other than | ||
| 67 | // an IP literal: 127.1, 2130706433 and 0x7f.1 are loopback to curl's | ||
| 68 | // parser but not to Go's, so they are refused rather than left to a | ||
| 69 | // resolver. | ||
| 70 | func CheckHost(host string) error { | ||
| 71 | if net.ParseIP(host) == nil && numericHost(host) { | ||
| 72 | return fmt.Errorf("host %q is a numeric address in a form other than dotted decimal; write it as a.b.c.d", host) | ||
| 73 | } | ||
| 74 | return nil | ||
| 75 | } | ||
| 76 | |||
| 68 | // numericHost reports whether every label of host is a decimal, octal | 77 | // numericHost reports whether every label of host is a decimal, octal |
| 69 | // or hex number, the shapes inet_aton reads as an IPv4 address. | 78 | // or hex number, the shapes inet_aton reads as an IPv4 address. |
| 70 | func numericHost(host string) bool { | 79 | func numericHost(host string) bool { |
internal/httpd/lfs.go +17 −6
| @@ -41,7 +41,8 @@ func (s *Server) lfsSecret() ([]byte, error) { | |||
| 41 | // for none). A token is bound to the SSH key that obtained it and | 41 | // for none). A token is bound to the SSH key that obtained it and |
| 42 | // works only while that key is registered, unexpired and on an enabled | 42 | // works only while that key is registered, unexpired and on an enabled |
| 43 | // account, and while the key still has the access its operation needs | 43 | // account, and while the key still has the access its operation needs |
| 44 | // on the repo (#285). Without one, public repos allow anonymous | 44 | // on the repo (#285). A key that took over a deleted key's id does not |
| 45 | // match the token's fingerprint pin (#303). Without one, public repos allow anonymous | ||
| 45 | // download only. | 46 | // download only. |
| 46 | func (s *Server) lfsAuth(r *http.Request, repo store.Repo) (string, int64) { | 47 | func (s *Server) lfsAuth(r *http.Request, repo store.Repo) (string, int64) { |
| 47 | auth := r.Header.Get("Authorization") | 48 | auth := r.Header.Get("Authorization") |
| @@ -62,7 +63,7 @@ func (s *Server) lfsAuth(r *http.Request, repo store.Repo) (string, int64) { | |||
| 62 | return "", 0 | 63 | return "", 0 |
| 63 | } | 64 | } |
| 64 | live, err := s.st.LiveSSHKeys([]int64{g.KeyID}) | 65 | live, err := s.st.LiveSSHKeys([]int64{g.KeyID}) |
| 65 | if err != nil || !live[g.KeyID] || !s.lfsKeyAllows(g.KeyID, repo, g.Op == "upload") { | 66 | if err != nil || !live[g.KeyID] || !s.lfsKeyAllows(g.KeyID, g.KeyPin, repo, g.Op == "upload") { |
| 66 | return "", 0 | 67 | return "", 0 |
| 67 | } | 68 | } |
| 68 | return g.Op, g.KeyID | 69 | return g.Op, g.KeyID |
| @@ -75,13 +76,14 @@ func (s *Server) lfsAuth(r *http.Request, repo store.Repo) (string, int64) { | |||
| 75 | 76 | ||
| 76 | // lfsKeyAllows repeats git-lfs-authenticate's access check for the key | 77 | // lfsKeyAllows repeats git-lfs-authenticate's access check for the key |
| 77 | // now: a deploy key by its binding, any other key by its account's | 78 | // now: a deploy key by its binding, any other key by its account's |
| 78 | // access narrowed by the key's scope. An archived repo takes no uploads. | 79 | // access narrowed by the key's scope. The key must be the one the token |
| 79 | func (s *Server) lfsKeyAllows(keyID int64, repo store.Repo, write bool) bool { | 80 | // was minted for, by fingerprint pin. An archived repo takes no uploads. |
| 81 | func (s *Server) lfsKeyAllows(keyID int64, pin string, repo store.Repo, write bool) bool { | ||
| 80 | if write && repo.Settings.Archived { | 82 | if write && repo.Settings.Archived { |
| 81 | return false | 83 | return false |
| 82 | } | 84 | } |
| 83 | key, err := s.st.SSHKeyByID(keyID) | 85 | key, err := s.st.SSHKeyByID(keyID) |
| 84 | if err != nil { | 86 | if err != nil || lfs.KeyPin(key.Fingerprint) != pin { |
| 85 | return false | 87 | return false |
| 86 | } | 88 | } |
| 87 | if policy.IsDeployScope(key.Scope) { | 89 | if policy.IsDeployScope(key.Scope) { |
| @@ -171,7 +173,16 @@ func (s *Server) lfsBatch(w http.ResponseWriter, r *http.Request) { | |||
| 171 | lfsError(w, http.StatusInternalServerError, "lfs secret unavailable") | 173 | lfsError(w, http.StatusInternalServerError, "lfs secret unavailable") |
| 172 | return | 174 | return |
| 173 | } | 175 | } |
| 174 | transferToken := lfs.Sign(secret, repo.ID, keyID, req.Operation, time.Now()) | 176 | fingerprint := "" |
| 177 | if keyID != 0 { | ||
| 178 | key, err := s.st.SSHKeyByID(keyID) | ||
| 179 | if err != nil { | ||
| 180 | lfsError(w, http.StatusNotFound, "repository not found") | ||
| 181 | return | ||
| 182 | } | ||
| 183 | fingerprint = key.Fingerprint | ||
| 184 | } | ||
| 185 | transferToken := lfs.Sign(secret, repo.ID, keyID, fingerprint, req.Operation, time.Now()) | ||
| 175 | base := fmt.Sprintf("%s/%s/%s.git/info/lfs/objects", | 186 | base := fmt.Sprintf("%s/%s/%s.git/info/lfs/objects", |
| 176 | strings.TrimSuffix(s.cfg.Server.SiteURL, "/"), repo.OwnerName, repo.Name) | 187 | strings.TrimSuffix(s.cfg.Server.SiteURL, "/"), repo.OwnerName, repo.Name) |
| 177 | authHeader := map[string]string{"Authorization": "Bearer " + transferToken} | 188 | authHeader := map[string]string{"Authorization": "Bearer " + transferToken} |
internal/httpd/lfsauth_test.go +44 −16
| @@ -54,14 +54,14 @@ func TestLFSTokenNeedsALiveKey(t *testing.T) { | |||
| 54 | } | 54 | } |
| 55 | 55 | ||
| 56 | live := addKey("SHA256:live", nil) | 56 | live := addKey("SHA256:live", nil) |
| 57 | tok := lfs.Sign(secret, repo.ID, live, "upload", time.Now()) | 57 | tok := lfs.Sign(secret, repo.ID, live, "SHA256:live", "upload", time.Now()) |
| 58 | if op, key := s.lfsAuth(lfsRequest(tok), repo); op != "upload" || key != live { | 58 | if op, key := s.lfsAuth(lfsRequest(tok), repo); op != "upload" || key != live { |
| 59 | t.Fatalf("live key: %q, %d", op, key) | 59 | t.Fatalf("live key: %q, %d", op, key) |
| 60 | } | 60 | } |
| 61 | 61 | ||
| 62 | past := time.Now().Add(-time.Minute) | 62 | past := time.Now().Add(-time.Minute) |
| 63 | expired := addKey("SHA256:expired", &past) | 63 | expired := addKey("SHA256:expired", &past) |
| 64 | if op, _ := s.lfsAuth(lfsRequest(lfs.Sign(secret, repo.ID, expired, "upload", time.Now())), repo); op != "" { | 64 | if op, _ := s.lfsAuth(lfsRequest(lfs.Sign(secret, repo.ID, expired, "SHA256:expired", "upload", time.Now())), repo); op != "" { |
| 65 | t.Errorf("expired key: %q", op) | 65 | t.Errorf("expired key: %q", op) |
| 66 | } | 66 | } |
| 67 | 67 | ||
| @@ -73,7 +73,7 @@ func TestLFSTokenNeedsALiveKey(t *testing.T) { | |||
| 73 | } | 73 | } |
| 74 | 74 | ||
| 75 | other := addKey("SHA256:other", nil) | 75 | other := addKey("SHA256:other", nil) |
| 76 | otherTok := lfs.Sign(secret, repo.ID, other, "download", time.Now()) | 76 | otherTok := lfs.Sign(secret, repo.ID, other, "SHA256:other", "download", time.Now()) |
| 77 | if op, _ := s.lfsAuth(lfsRequest(otherTok), repo); op != "download" { | 77 | if op, _ := s.lfsAuth(lfsRequest(otherTok), repo); op != "download" { |
| 78 | t.Fatalf("second key before disable: %q", op) | 78 | t.Fatalf("second key before disable: %q", op) |
| 79 | } | 79 | } |
| @@ -97,13 +97,13 @@ func TestLFSAnonymousTokenOnlyDownloadsPublic(t *testing.T) { | |||
| 97 | t.Fatal(err) | 97 | t.Fatal(err) |
| 98 | } | 98 | } |
| 99 | now := time.Now() | 99 | now := time.Now() |
| 100 | if op, _ := s.lfsAuth(lfsRequest(lfs.Sign(secret, pub.ID, 0, "download", now)), pub); op != "download" { | 100 | if op, _ := s.lfsAuth(lfsRequest(lfs.Sign(secret, pub.ID, 0, "", "download", now)), pub); op != "download" { |
| 101 | t.Errorf("public download: %q", op) | 101 | t.Errorf("public download: %q", op) |
| 102 | } | 102 | } |
| 103 | if op, _ := s.lfsAuth(lfsRequest(lfs.Sign(secret, pub.ID, 0, "upload", now)), pub); op != "" { | 103 | if op, _ := s.lfsAuth(lfsRequest(lfs.Sign(secret, pub.ID, 0, "", "upload", now)), pub); op != "" { |
| 104 | t.Errorf("anonymous upload: %q", op) | 104 | t.Errorf("anonymous upload: %q", op) |
| 105 | } | 105 | } |
| 106 | if op, _ := s.lfsAuth(lfsRequest(lfs.Sign(secret, priv.ID, 0, "download", now)), priv); op != "" { | 106 | if op, _ := s.lfsAuth(lfsRequest(lfs.Sign(secret, priv.ID, 0, "", "download", now)), priv); op != "" { |
| 107 | t.Errorf("private download: %q", op) | 107 | t.Errorf("private download: %q", op) |
| 108 | } | 108 | } |
| 109 | } | 109 | } |
| @@ -140,7 +140,7 @@ func TestLFSTokenNeedsCurrentAccess(t *testing.T) { | |||
| 140 | t.Fatal(err) | 140 | t.Fatal(err) |
| 141 | } | 141 | } |
| 142 | bobKey := lfsTestKey(t, st, bob, "SHA256:bob", "full") | 142 | bobKey := lfsTestKey(t, st, bob, "SHA256:bob", "full") |
| 143 | up := lfs.Sign(secret, repo.ID, bobKey, "upload", now) | 143 | up := lfs.Sign(secret, repo.ID, bobKey, "SHA256:bob", "upload", now) |
| 144 | if op, _ := s.lfsAuth(lfsRequest(up), repo); op != "upload" { | 144 | if op, _ := s.lfsAuth(lfsRequest(up), repo); op != "upload" { |
| 145 | t.Fatalf("collaborator upload: %q", op) | 145 | t.Fatalf("collaborator upload: %q", op) |
| 146 | } | 146 | } |
| @@ -153,7 +153,7 @@ func TestLFSTokenNeedsCurrentAccess(t *testing.T) { | |||
| 153 | if err := st.RevokeAccess(repo.ID, bob); err != nil { | 153 | if err := st.RevokeAccess(repo.ID, bob); err != nil { |
| 154 | t.Fatal(err) | 154 | t.Fatal(err) |
| 155 | } | 155 | } |
| 156 | if op, _ := s.lfsAuth(lfsRequest(lfs.Sign(secret, repo.ID, bobKey, "download", now)), repo); op != "" { | 156 | if op, _ := s.lfsAuth(lfsRequest(lfs.Sign(secret, repo.ID, bobKey, "SHA256:bob", "download", now)), repo); op != "" { |
| 157 | t.Errorf("download after access was revoked: %q", op) | 157 | t.Errorf("download after access was revoked: %q", op) |
| 158 | } | 158 | } |
| 159 | 159 | ||
| @@ -163,7 +163,7 @@ func TestLFSTokenNeedsCurrentAccess(t *testing.T) { | |||
| 163 | t.Fatal(err) | 163 | t.Fatal(err) |
| 164 | } | 164 | } |
| 165 | carolKey := lfsTestKey(t, st, carol, "SHA256:carol", "full") | 165 | carolKey := lfsTestKey(t, st, carol, "SHA256:carol", "full") |
| 166 | down := lfs.Sign(secret, pub.ID, carolKey, "download", now) | 166 | down := lfs.Sign(secret, pub.ID, carolKey, "SHA256:carol", "download", now) |
| 167 | if op, _ := s.lfsAuth(lfsRequest(down), pub); op != "download" { | 167 | if op, _ := s.lfsAuth(lfsRequest(down), pub); op != "download" { |
| 168 | t.Fatalf("public download: %q", op) | 168 | t.Fatalf("public download: %q", op) |
| 169 | } | 169 | } |
| @@ -194,12 +194,12 @@ func TestLFSDeployKeyToken(t *testing.T) { | |||
| 194 | ro := fmt.Sprintf("deploy:%d:ro", repo.ID) | 194 | ro := fmt.Sprintf("deploy:%d:ro", repo.ID) |
| 195 | 195 | ||
| 196 | live := lfsTestKey(t, st, u.ID, "SHA256:deploy-live", rw) | 196 | live := lfsTestKey(t, st, u.ID, "SHA256:deploy-live", rw) |
| 197 | if op, key := s.lfsAuth(lfsRequest(lfs.Sign(secret, repo.ID, live, "upload", now)), repo); op != "upload" || key != live { | 197 | if op, key := s.lfsAuth(lfsRequest(lfs.Sign(secret, repo.ID, live, "SHA256:deploy-live", "upload", now)), repo); op != "upload" || key != live { |
| 198 | t.Fatalf("live deploy key: %q, %d", op, key) | 198 | t.Fatalf("live deploy key: %q, %d", op, key) |
| 199 | } | 199 | } |
| 200 | 200 | ||
| 201 | removed := lfsTestKey(t, st, u.ID, "SHA256:deploy-removed", rw) | 201 | removed := lfsTestKey(t, st, u.ID, "SHA256:deploy-removed", rw) |
| 202 | tok := lfs.Sign(secret, repo.ID, removed, "download", now) | 202 | tok := lfs.Sign(secret, repo.ID, removed, "SHA256:deploy-removed", "download", now) |
| 203 | if err := st.RemoveDeployKey(repo.ID, "SHA256:deploy-removed"); err != nil { | 203 | if err := st.RemoveDeployKey(repo.ID, "SHA256:deploy-removed"); err != nil { |
| 204 | t.Fatal(err) | 204 | t.Fatal(err) |
| 205 | } | 205 | } |
| @@ -208,17 +208,17 @@ func TestLFSDeployKeyToken(t *testing.T) { | |||
| 208 | } | 208 | } |
| 209 | 209 | ||
| 210 | readOnly := lfsTestKey(t, st, u.ID, "SHA256:deploy-ro", ro) | 210 | readOnly := lfsTestKey(t, st, u.ID, "SHA256:deploy-ro", ro) |
| 211 | if op, _ := s.lfsAuth(lfsRequest(lfs.Sign(secret, repo.ID, readOnly, "download", now)), repo); op != "download" { | 211 | if op, _ := s.lfsAuth(lfsRequest(lfs.Sign(secret, repo.ID, readOnly, "SHA256:deploy-ro", "download", now)), repo); op != "download" { |
| 212 | t.Errorf("read-only deploy key download: %q", op) | 212 | t.Errorf("read-only deploy key download: %q", op) |
| 213 | } | 213 | } |
| 214 | if op, _ := s.lfsAuth(lfsRequest(lfs.Sign(secret, repo.ID, readOnly, "upload", now)), repo); op != "" { | 214 | if op, _ := s.lfsAuth(lfsRequest(lfs.Sign(secret, repo.ID, readOnly, "SHA256:deploy-ro", "upload", now)), repo); op != "" { |
| 215 | t.Errorf("read-only deploy key upload: %q", op) | 215 | t.Errorf("read-only deploy key upload: %q", op) |
| 216 | } | 216 | } |
| 217 | 217 | ||
| 218 | if err := st.SetUserDisabled(u.ID, true); err != nil { | 218 | if err := st.SetUserDisabled(u.ID, true); err != nil { |
| 219 | t.Fatal(err) | 219 | t.Fatal(err) |
| 220 | } | 220 | } |
| 221 | if op, _ := s.lfsAuth(lfsRequest(lfs.Sign(secret, repo.ID, live, "download", now)), repo); op != "" { | 221 | if op, _ := s.lfsAuth(lfsRequest(lfs.Sign(secret, repo.ID, live, "SHA256:deploy-live", "download", now)), repo); op != "" { |
| 222 | t.Errorf("deploy key of a disabled account: %q", op) | 222 | t.Errorf("deploy key of a disabled account: %q", op) |
| 223 | } | 223 | } |
| 224 | } | 224 | } |
| @@ -234,7 +234,7 @@ func TestLFSUploadTokenRefusedOnceArchived(t *testing.T) { | |||
| 234 | } | 234 | } |
| 235 | key := lfsTestKey(t, st, u.ID, "SHA256:owner", "full") | 235 | key := lfsTestKey(t, st, u.ID, "SHA256:owner", "full") |
| 236 | now := time.Now() | 236 | now := time.Now() |
| 237 | up := lfs.Sign(secret, repo.ID, key, "upload", now) | 237 | up := lfs.Sign(secret, repo.ID, key, "SHA256:owner", "upload", now) |
| 238 | if op, _ := s.lfsAuth(lfsRequest(up), repo); op != "upload" { | 238 | if op, _ := s.lfsAuth(lfsRequest(up), repo); op != "upload" { |
| 239 | t.Fatalf("upload before archiving: %q", op) | 239 | t.Fatalf("upload before archiving: %q", op) |
| 240 | } | 240 | } |
| @@ -248,7 +248,35 @@ func TestLFSUploadTokenRefusedOnceArchived(t *testing.T) { | |||
| 248 | if op, _ := s.lfsAuth(lfsRequest(up), repo); op != "" { | 248 | if op, _ := s.lfsAuth(lfsRequest(up), repo); op != "" { |
| 249 | t.Errorf("upload after archiving: %q", op) | 249 | t.Errorf("upload after archiving: %q", op) |
| 250 | } | 250 | } |
| 251 | if op, _ := s.lfsAuth(lfsRequest(lfs.Sign(secret, repo.ID, key, "download", now)), repo); op != "download" { | 251 | if op, _ := s.lfsAuth(lfsRequest(lfs.Sign(secret, repo.ID, key, "SHA256:owner", "download", now)), repo); op != "download" { |
| 252 | t.Errorf("download after archiving: %q", op) | 252 | t.Errorf("download after archiving: %q", op) |
| 253 | } | 253 | } |
| 254 | } | 254 | } |
| 255 | |||
| 256 | // SQLite gives a new key the id of the highest deleted one. A token | ||
| 257 | // minted for the deleted key is refused when presented against the new | ||
| 258 | // key; the new key's own token works (#303). | ||
| 259 | func TestLFSTokenRefusedOnReusedKeyID(t *testing.T) { | ||
| 260 | s, st, u := newTokenTestServer(t) | ||
| 261 | repo := lfsTestRepo(t, st, u.ID, "app", "private") | ||
| 262 | secret, err := s.lfsSecret() | ||
| 263 | if err != nil { | ||
| 264 | t.Fatal(err) | ||
| 265 | } | ||
| 266 | now := time.Now() | ||
| 267 | oldID := lfsTestKey(t, st, u.ID, "SHA256:old", "full") | ||
| 268 | old := lfs.Sign(secret, repo.ID, oldID, "SHA256:old", "upload", now) | ||
| 269 | if err := st.RemoveSSHKey(u.ID, "SHA256:old"); err != nil { | ||
| 270 | t.Fatal(err) | ||
| 271 | } | ||
| 272 | newID := lfsTestKey(t, st, u.ID, "SHA256:new", "full") | ||
| 273 | if newID != oldID { | ||
| 274 | t.Fatalf("new key got id %d, want the reused %d", newID, oldID) | ||
| 275 | } | ||
| 276 | if op, _ := s.lfsAuth(lfsRequest(old), repo); op != "" { | ||
| 277 | t.Errorf("old key's token on the reused id: %q", op) | ||
| 278 | } | ||
| 279 | if op, _ := s.lfsAuth(lfsRequest(lfs.Sign(secret, repo.ID, newID, "SHA256:new", "upload", now)), repo); op != "upload" { | ||
| 280 | t.Errorf("new key's own token: %q", op) | ||
| 281 | } | ||
| 282 | } | ||
internal/lfs/lfs.go +27 −9
| @@ -129,24 +129,39 @@ const TokenTTL = time.Hour | |||
| 129 | 129 | ||
| 130 | // Sign mints a token for op ("download" or "upload") on repoID, bound | 130 | // Sign mints a token for op ("download" or "upload") on repoID, bound |
| 131 | // to keyID: the SSH key, user or deploy, that asked for it, or 0 for an | 131 | // to keyID: the SSH key, user or deploy, that asked for it, or 0 for an |
| 132 | // anonymous download of a public repository. | 132 | // anonymous download of a public repository. fingerprint is that key's |
| 133 | func Sign(secret []byte, repoID, keyID int64, op string, now time.Time) string { | 133 | // fingerprint, "" for key 0. SQLite reuses the id of a deleted key, so |
| 134 | payload := fmt.Sprintf("%d:%d:%s:%d", repoID, keyID, op, now.Add(TokenTTL).Unix()) | 134 | // the token carries a hash of the fingerprint as well and a new key |
| 135 | // given the old id does not inherit the old key's tokens (#303). | ||
| 136 | func Sign(secret []byte, repoID, keyID int64, fingerprint, op string, now time.Time) string { | ||
| 137 | payload := fmt.Sprintf("%d:%d:%s:%s:%d", repoID, keyID, KeyPin(fingerprint), op, now.Add(TokenTTL).Unix()) | ||
| 135 | mac := hmac.New(sha256.New, secret) | 138 | mac := hmac.New(sha256.New, secret) |
| 136 | mac.Write([]byte(payload)) | 139 | mac.Write([]byte(payload)) |
| 137 | return base64.RawURLEncoding.EncodeToString([]byte(payload)) + "." + | 140 | return base64.RawURLEncoding.EncodeToString([]byte(payload)) + "." + |
| 138 | base64.RawURLEncoding.EncodeToString(mac.Sum(nil)) | 141 | base64.RawURLEncoding.EncodeToString(mac.Sum(nil)) |
| 139 | } | 142 | } |
| 140 | 143 | ||
| 144 | // KeyPin is the fingerprint's form in a token: the first 16 hex | ||
| 145 | // characters of its SHA-256, or "" for no key. | ||
| 146 | func KeyPin(fingerprint string) string { | ||
| 147 | if fingerprint == "" { | ||
| 148 | return "" | ||
| 149 | } | ||
| 150 | sum := sha256.Sum256([]byte(fingerprint)) | ||
| 151 | return hex.EncodeToString(sum[:8]) | ||
| 152 | } | ||
| 153 | |||
| 141 | // Grant is what a verified token authorizes. | 154 | // Grant is what a verified token authorizes. |
| 142 | type Grant struct { | 155 | type Grant struct { |
| 143 | RepoID int64 | 156 | RepoID int64 |
| 144 | KeyID int64 // 0: an anonymous download of a public repository | 157 | KeyID int64 // 0: an anonymous download of a public repository |
| 158 | KeyPin string // KeyPin of the key's fingerprint; "" when KeyID is 0 | ||
| 145 | Op string | 159 | Op string |
| 146 | } | 160 | } |
| 147 | 161 | ||
| 148 | // Verify checks a token's MAC, shape and expiry. A token from before | 162 | // Verify checks a token's MAC, shape and expiry. A token from before |
| 149 | // tokens named their key does not verify. | 163 | // tokens named their key, or before they carried its fingerprint, does |
| 164 | // not verify. | ||
| 150 | func Verify(secret []byte, token string, now time.Time) (Grant, bool) { | 165 | func Verify(secret []byte, token string, now time.Time) (Grant, bool) { |
| 151 | payloadB64, macB64, found := strings.Cut(token, ".") | 166 | payloadB64, macB64, found := strings.Cut(token, ".") |
| 152 | if !found { | 167 | if !found { |
| @@ -166,19 +181,22 @@ func Verify(secret []byte, token string, now time.Time) (Grant, bool) { | |||
| 166 | return Grant{}, false | 181 | return Grant{}, false |
| 167 | } | 182 | } |
| 168 | parts := strings.Split(string(payload), ":") | 183 | parts := strings.Split(string(payload), ":") |
| 169 | if len(parts) != 4 { | 184 | if len(parts) != 5 { |
| 170 | return Grant{}, false | 185 | return Grant{}, false |
| 171 | } | 186 | } |
| 172 | repoID, err1 := strconv.ParseInt(parts[0], 10, 64) | 187 | repoID, err1 := strconv.ParseInt(parts[0], 10, 64) |
| 173 | keyID, err2 := strconv.ParseInt(parts[1], 10, 64) | 188 | keyID, err2 := strconv.ParseInt(parts[1], 10, 64) |
| 174 | exp, err3 := strconv.ParseInt(parts[3], 10, 64) | 189 | exp, err3 := strconv.ParseInt(parts[4], 10, 64) |
| 175 | if err1 != nil || err2 != nil || err3 != nil || keyID < 0 || now.Unix() > exp { | 190 | if err1 != nil || err2 != nil || err3 != nil || keyID < 0 || now.Unix() > exp { |
| 176 | return Grant{}, false | 191 | return Grant{}, false |
| 177 | } | 192 | } |
| 178 | if parts[2] != "download" && parts[2] != "upload" { | 193 | if (keyID == 0) != (parts[2] == "") { |
| 194 | return Grant{}, false | ||
| 195 | } | ||
| 196 | if parts[3] != "download" && parts[3] != "upload" { | ||
| 179 | return Grant{}, false | 197 | return Grant{}, false |
| 180 | } | 198 | } |
| 181 | return Grant{RepoID: repoID, KeyID: keyID, Op: parts[2]}, true | 199 | return Grant{RepoID: repoID, KeyID: keyID, KeyPin: parts[2], Op: parts[3]}, true |
| 182 | } | 200 | } |
| 183 | 201 | ||
| 184 | // NewSecret returns 32 random bytes, hex-encoded for the settings table. | 202 | // NewSecret returns 32 random bytes, hex-encoded for the settings table. |
internal/lfs/lfs_test.go +17 −3
| @@ -12,9 +12,9 @@ import ( | |||
| 12 | func TestTokenCarriesTheKey(t *testing.T) { | 12 | func TestTokenCarriesTheKey(t *testing.T) { |
| 13 | secret := []byte("secret") | 13 | secret := []byte("secret") |
| 14 | now := time.Now() | 14 | now := time.Now() |
| 15 | tok := Sign(secret, 7, 42, "upload", now) | 15 | tok := Sign(secret, 7, 42, "SHA256:k", "upload", now) |
| 16 | g, ok := Verify(secret, tok, now) | 16 | g, ok := Verify(secret, tok, now) |
| 17 | if !ok || g != (Grant{RepoID: 7, KeyID: 42, Op: "upload"}) { | 17 | if !ok || g != (Grant{RepoID: 7, KeyID: 42, KeyPin: KeyPin("SHA256:k"), Op: "upload"}) { |
| 18 | t.Fatalf("Verify = %+v, %v", g, ok) | 18 | t.Fatalf("Verify = %+v, %v", g, ok) |
| 19 | } | 19 | } |
| 20 | if _, ok := Verify(secret, tok, now.Add(TokenTTL+time.Second)); ok { | 20 | if _, ok := Verify(secret, tok, now.Add(TokenTTL+time.Second)); ok { |
| @@ -23,7 +23,7 @@ func TestTokenCarriesTheKey(t *testing.T) { | |||
| 23 | if _, ok := Verify([]byte("other"), tok, now); ok { | 23 | if _, ok := Verify([]byte("other"), tok, now); ok { |
| 24 | t.Error("a token verified under another secret") | 24 | t.Error("a token verified under another secret") |
| 25 | } | 25 | } |
| 26 | if g, ok := Verify(secret, Sign(secret, 7, 0, "download", now), now); !ok || g.KeyID != 0 { | 26 | if g, ok := Verify(secret, Sign(secret, 7, 0, "", "download", now), now); !ok || g.KeyID != 0 || g.KeyPin != "" { |
| 27 | t.Errorf("anonymous grant = %+v, %v", g, ok) | 27 | t.Errorf("anonymous grant = %+v, %v", g, ok) |
| 28 | } | 28 | } |
| 29 | } | 29 | } |
| @@ -41,3 +41,17 @@ func TestUnboundTokenRefused(t *testing.T) { | |||
| 41 | t.Fatalf("a pre-upgrade token verified: %+v", g) | 41 | t.Fatalf("a pre-upgrade token verified: %+v", g) |
| 42 | } | 42 | } |
| 43 | } | 43 | } |
| 44 | |||
| 45 | // A token minted before tokens carried the key's fingerprint has four | ||
| 46 | // fields. It is refused (#303). | ||
| 47 | func TestUnpinnedTokenRefused(t *testing.T) { | ||
| 48 | secret := []byte("secret") | ||
| 49 | payload := fmt.Sprintf("%d:%d:%s:%d", 7, 42, "upload", time.Now().Add(TokenTTL).Unix()) | ||
| 50 | mac := hmac.New(sha256.New, secret) | ||
| 51 | mac.Write([]byte(payload)) | ||
| 52 | tok := base64.RawURLEncoding.EncodeToString([]byte(payload)) + "." + | ||
| 53 | base64.RawURLEncoding.EncodeToString(mac.Sum(nil)) | ||
| 54 | if g, ok := Verify(secret, tok, time.Now()); ok { | ||
| 55 | t.Fatalf("an unpinned token verified: %+v", g) | ||
| 56 | } | ||
| 57 | } | ||
internal/sshd/lfs.go +1 −1
| @@ -69,7 +69,7 @@ func runLFSAuthenticate(cfg config.Config, st *store.Store, user store.User, key | |||
| 69 | fmt.Fprintln(stderr, "internal error") | 69 | fmt.Fprintln(stderr, "internal error") |
| 70 | return protocol.ExitFailure | 70 | return protocol.ExitFailure |
| 71 | } | 71 | } |
| 72 | token := lfs.Sign([]byte(secret), repo.ID, key.ID, op, time.Now()) | 72 | token := lfs.Sign([]byte(secret), repo.ID, key.ID, key.Fingerprint, op, time.Now()) |
| 73 | json.NewEncoder(stdout).Encode(map[string]any{ | 73 | json.NewEncoder(stdout).Encode(map[string]any{ |
| 74 | "href": fmt.Sprintf("%s/%s/%s.git/info/lfs", | 74 | "href": fmt.Sprintf("%s/%s/%s.git/info/lfs", |
| 75 | cfg.Server.SiteURL, repo.OwnerName, repo.Name), | 75 | cfg.Server.SiteURL, repo.OwnerName, repo.Name), |