From ff7a0cc0da5d1d08a58424da0062cb4667c3a083 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 27 Sep 2026 03:32:37 +0530 Subject: [PATCH 01/30] fix(git): allow anonymous clone and fetch of public repositories Anonymous access was granted only to GET requests, so the POST git-upload-pack negotiation that every smart-HTTP clone and fetch needs returned 401 and public repositories were effectively web-only. Allow both upload-pack requests (GET info/refs?service=git-upload-pack and POST git-upload-pack) for public, non-archived repositories, also for signed-in users without a grant. Receive-pack still always requires an authenticated writer. The regression test runs real `git clone`/`git fetch` with credential helpers and prompts disabled over protocol v0 and v2, and checks that private and archived repositories and anonymous pushes stay refused. Finding: F076 --- cmd/trace/main.go | 31 ++++++++++--- cmd/trace/visibility_test.go | 87 ++++++++++++++++++++++++++++++++++++ 2 files changed, 111 insertions(+), 7 deletions(-) diff --git a/cmd/trace/main.go b/cmd/trace/main.go index 925520c..9f60bd7 100644 --- a/cmd/trace/main.go +++ b/cmd/trace/main.go @@ -1056,20 +1056,22 @@ func (a *app) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } if strings.HasPrefix(r.URL.Path, "/git/") { - name, pass, hasAuth := r.BasicAuth() + name, pass, _ := r.BasicAuth() u, valid := db.authenticate(name, pass) if valid { u = a.store.expandUser(name, u) } publicRead := false - if !valid { - if _, ok := publicGitRepo(a.store, r.URL.Path); ok && r.Method == http.MethodGet && r.URL.Query().Get("service") != "git-receive-pack" { - name, u, publicRead = "anonymous", userRecord{}, true - } else { - valid = false + // Public, non-archived repositories serve upload-pack to everyone, + // including signed-in users without a grant. Receive-pack never + // qualifies, so writes always need an authenticated writer. + if repo, ok := publicGitRepo(a.store, r.URL.Path); ok && anonymousGitRead(r) && (!valid || !u.canRead(repo)) { + if !valid { + name, u = "anonymous", userRecord{} } + publicRead = true } - if !hasAuth && !publicRead || (!valid && !publicRead) { + if !valid && !publicRead { w.Header().Set("WWW-Authenticate", `Basic realm="Trace Git"`) http.Error(w, "authentication required", http.StatusUnauthorized) return @@ -1240,6 +1242,21 @@ func publicWebRepo(s *store, requestPath string) (string, bool) { return name, err == nil && isPublic(path) && !isArchived(path) } +// anonymousGitRead reports whether r is one of the two smart-HTTP requests a +// read-only clone or fetch needs: the upload-pack ref advertisement (GET +// info/refs?service=git-upload-pack) and the upload-pack negotiation (POST +// git-upload-pack). Every receive-pack request stays authenticated. +func anonymousGitRead(r *http.Request) bool { + switch { + case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/info/refs"): + return r.URL.Query().Get("service") == "git-upload-pack" + case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/git-upload-pack"): + return true + default: + return false + } +} + func publicGitRepo(s *store, requestPath string) (string, bool) { parts := strings.Split(strings.TrimPrefix(requestPath, "/git/"), "/") if len(parts) < 3 || !strings.HasSuffix(parts[1], ".git") { diff --git a/cmd/trace/visibility_test.go b/cmd/trace/visibility_test.go index c4f7782..7d9002e 100644 --- a/cmd/trace/visibility_test.go +++ b/cmd/trace/visibility_test.go @@ -3,6 +3,10 @@ package main import ( "net/http" "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "strings" "testing" ) @@ -40,3 +44,86 @@ func TestPublicRepositoryAllowsAnonymousGitReadOnly(t *testing.T) { t.Fatalf("anonymous public web page: %d %s", pageW.Code, pageW.Body.String()) } } + +// TestPublicRepositoryAnonymousGitCloneAndFetch exercises the full smart-HTTP +// exchange (GET info/refs followed by POST git-upload-pack) with credential +// helpers and prompts disabled, so only genuinely anonymous access can pass. +func TestPublicRepositoryAnonymousGitCloneAndFetch(t *testing.T) { + root := t.TempDir() + if err := initData(root); err != nil { + t.Fatal(err) + } + a, err := newApp(root) + if err != nil { + t.Fatal(err) + } + for _, name := range []string{"team/public", "team/private"} { + if err := a.store.createRepo(name, false); err != nil { + t.Fatal(err) + } + } + if err := a.store.setPublic("team/public", true); err != nil { + t.Fatal(err) + } + server := httptest.NewServer(a) + defer server.Close() + token := adminToken(t, root) + work := t.TempDir() + gitTest(t, work, "init", "--initial-branch=main") + gitTest(t, work, "config", "user.name", "Admin") + gitTest(t, work, "config", "user.email", "admin@example.invalid") + if err := os.WriteFile(filepath.Join(work, "README.md"), []byte("public\n"), 0600); err != nil { + t.Fatal(err) + } + gitTest(t, work, "add", ".") + gitTest(t, work, "commit", "-m", "public") + for _, name := range []string{"team/public", "team/private"} { + gitTest(t, work, "push", strings.Replace(server.URL, "http://", "http://admin:"+token+"@", 1)+"/git/"+name+".git", "main") + } + + anonymousGit := func(dir string, args ...string) (string, error) { + cmd := exec.Command("git", append([]string{"-c", "credential.helper=", "-c", "core.askPass="}, args...)...) + cmd.Dir = dir + cmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0", "GIT_ASKPASS=", "SSH_ASKPASS=") + out, err := cmd.CombinedOutput() + return string(out), err + } + for _, protocol := range []string{"version=2", "version=0"} { + clone := filepath.Join(t.TempDir(), "clone") + if out, err := anonymousGit(t.TempDir(), "-c", "protocol."+protocol, "clone", server.URL+"/git/team/public.git", clone); err != nil { + t.Fatalf("anonymous clone (%s) of a public repository failed: %v\n%s", protocol, err, out) + } + if b, err := os.ReadFile(filepath.Join(clone, "README.md")); err != nil || string(b) != "public\n" { + t.Fatalf("anonymous clone content: %q %v", b, err) + } + if out, err := anonymousGit(clone, "fetch", "origin"); err != nil { + t.Fatalf("anonymous fetch (%s) of a public repository failed: %v\n%s", protocol, err, out) + } + } + if out, err := anonymousGit(t.TempDir(), "clone", server.URL+"/git/team/private.git", "private"); err == nil { + t.Fatalf("anonymous clone of a private repository unexpectedly succeeded:\n%s", out) + } + pushWork := filepath.Join(t.TempDir(), "push") + if out, err := anonymousGit(t.TempDir(), "clone", server.URL+"/git/team/public.git", pushWork); err != nil { + t.Fatalf("clone for push attempt: %v\n%s", err, out) + } + gitTest(t, pushWork, "config", "user.name", "Anonymous") + gitTest(t, pushWork, "config", "user.email", "anonymous@example.invalid") + gitTest(t, pushWork, "commit", "--allow-empty", "-m", "anonymous write") + if out, err := anonymousGit(pushWork, "push", "origin", "HEAD:refs/heads/anonymous"); err == nil { + t.Fatalf("anonymous push to a public repository unexpectedly succeeded:\n%s", out) + } + upload := httptest.NewRequest(http.MethodPost, "/git/team/public.git/git-receive-pack", strings.NewReader("0000")) + upload.Header.Set("Content-Type", "application/x-git-receive-pack-request") + uploadW := httptest.NewRecorder() + a.ServeHTTP(uploadW, upload) + if uploadW.Code == http.StatusOK { + t.Fatal("anonymous POST git-receive-pack unexpectedly allowed") + } + if err := a.store.setArchived("team/public", true); err != nil { + t.Fatal(err) + } + if out, err := anonymousGit(t.TempDir(), "clone", server.URL+"/git/team/public.git", "archived"); err == nil { + t.Fatalf("anonymous clone of an archived repository unexpectedly succeeded:\n%s", out) + } +} From dbaccc6630c111f9ed5ef42a4dfe4b4c2f1a0b64 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 27 Sep 2026 03:33:50 +0530 Subject: [PATCH 02/30] fix(actions): confine artifact collection to the job workspace collectActionArtifacts ran in the unsandboxed Trace process and used os.Stat/os.Open, which follow symlinks. Any repository writer could run `ln -s /admin-token coverage.out` in a job and download the server admin token (or secrets.json, oidc.json, users.json, the SSH host key) as an artifact, even when the job itself ran in Docker or sandbox-exec. A FIFO named as an artifact also blocked the runner goroutine forever. Resolve every artifact through an os.Root bound to the workspace so no final or intermediate symlink can leave it, refuse symlinked artifacts, copy only regular files (re-checked with fstat after a non-blocking, no-follow open), and record the bytes actually copied. Finding: F077 --- cmd/trace/actions.go | 70 +++++++++++++++++++++-------- cmd/trace/actions_artifacts_test.go | 66 +++++++++++++++++++++++++++ 2 files changed, 118 insertions(+), 18 deletions(-) create mode 100644 cmd/trace/actions_artifacts_test.go diff --git a/cmd/trace/actions.go b/cmd/trace/actions.go index 8bf7e74..ca86ccd 100644 --- a/cmd/trace/actions.go +++ b/cmd/trace/actions.go @@ -20,6 +20,7 @@ import ( "strconv" "strings" "sync" + "syscall" "time" ) @@ -610,40 +611,73 @@ func contextWithTimeout(d time.Duration) (context.Context, context.CancelFunc) { return context.WithTimeout(context.Background(), d) } +const maxActionArtifactSize = 50 << 20 + +// collectActionArtifacts copies declared artifacts out of a job workspace. +// The workspace is writable by the job, and this function runs in the +// unsandboxed Trace process, so every lookup goes through an os.Root bound to +// the workspace: symlinks (final or intermediate) cannot reach files outside +// it, symlinked artifacts are refused outright, and only regular files are +// copied. Files are opened non-blocking so a FIFO cannot stall the runner. func collectActionArtifacts(root string, runID, jobID int, workspace string, patterns []string) []actionArtifact { + ws, err := os.OpenRoot(workspace) + if err != nil { + return nil + } + defer ws.Close() var out []actionArtifact for _, pattern := range patterns { pattern = filepath.Clean(pattern) - if pattern == "." || filepath.IsAbs(pattern) || strings.HasPrefix(pattern, ".."+string(filepath.Separator)) || strings.Contains(pattern, string(filepath.Separator)+".."+string(filepath.Separator)) { + if pattern == "." || filepath.IsAbs(pattern) || pattern == ".." || strings.HasPrefix(pattern, ".."+string(filepath.Separator)) || strings.Contains(pattern, string(filepath.Separator)+".."+string(filepath.Separator)) { continue } matches, _ := filepath.Glob(filepath.Join(workspace, pattern)) for _, source := range matches { - info, err := os.Stat(source) - if err != nil || info.IsDir() || info.Size() > 50<<20 { + rel, err := filepath.Rel(workspace, source) + if err != nil || rel == "." || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { continue } - name := filepath.ToSlash(strings.TrimPrefix(source, workspace+string(filepath.Separator))) - dest := filepath.Join(root, "artifacts", fmt.Sprint(runID), fmt.Sprint(jobID), filepath.FromSlash(name)) - if err := os.MkdirAll(filepath.Dir(dest), 0700); err != nil { - continue + artifact, ok := copyActionArtifact(ws, root, runID, jobID, rel) + if ok { + out = append(out, artifact) } - in, err := os.Open(source) - if err != nil { - continue - } - outFile, err := os.OpenFile(dest, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600) - if err == nil { - _, _ = io.Copy(outFile, io.LimitReader(in, 50<<20)) - _ = outFile.Close() - } - _ = in.Close() - out = append(out, actionArtifact{Name: name, Size: info.Size(), Path: dest}) } } return out } +func copyActionArtifact(ws *os.Root, root string, runID, jobID int, rel string) (actionArtifact, bool) { + info, err := ws.Lstat(rel) + if err != nil || !info.Mode().IsRegular() || info.Size() > maxActionArtifactSize { + return actionArtifact{}, false + } + in, err := ws.OpenFile(rel, os.O_RDONLY|syscall.O_NONBLOCK|syscall.O_NOFOLLOW, 0) + if err != nil { + return actionArtifact{}, false + } + defer in.Close() + opened, err := in.Stat() + if err != nil || !opened.Mode().IsRegular() || !os.SameFile(info, opened) || opened.Size() > maxActionArtifactSize { + return actionArtifact{}, false + } + name := filepath.ToSlash(rel) + dest := filepath.Join(root, "artifacts", fmt.Sprint(runID), fmt.Sprint(jobID), filepath.FromSlash(name)) + if err := os.MkdirAll(filepath.Dir(dest), 0700); err != nil { + return actionArtifact{}, false + } + outFile, err := os.OpenFile(dest, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600) + if err != nil { + return actionArtifact{}, false + } + written, copyErr := io.Copy(outFile, io.LimitReader(in, maxActionArtifactSize)) + closeErr := outFile.Close() + if copyErr != nil || closeErr != nil { + _ = os.Remove(dest) + return actionArtifact{}, false + } + return actionArtifact{Name: name, Size: written, Path: dest}, true +} + func (s *store) finishActionRun(id int, status string, jobs []actionJob) { actionRunMu.Lock() defer actionRunMu.Unlock() diff --git a/cmd/trace/actions_artifacts_test.go b/cmd/trace/actions_artifacts_test.go new file mode 100644 index 0000000..250a8d5 --- /dev/null +++ b/cmd/trace/actions_artifacts_test.go @@ -0,0 +1,66 @@ +package main + +import ( + "os" + "path/filepath" + "syscall" + "testing" + "time" +) + +// TestActionArtifactsRefuseSymlinksAndSpecialFiles guards against a job +// planting a symlink (or a symlinked directory) in its workspace so that the +// unsandboxed Trace process copies a server file such as data/admin-token into +// the downloadable artifact store. +func TestActionArtifactsRefuseSymlinksAndSpecialFiles(t *testing.T) { + root := t.TempDir() + outside := t.TempDir() + secret := filepath.Join(outside, "admin-token") + if err := os.WriteFile(secret, []byte("server secret"), 0600); err != nil { + t.Fatal(err) + } + workspace := filepath.Join(t.TempDir(), "workspace") + if err := os.MkdirAll(filepath.Join(workspace, "reports"), 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(workspace, "reports", "result.txt"), []byte("ok"), 0600); err != nil { + t.Fatal(err) + } + for link, target := range map[string]string{ + "coverage.out": secret, + "linkdir": outside, + "inner.txt": filepath.Join(workspace, "reports", "result.txt"), + } { + if err := os.Symlink(target, filepath.Join(workspace, link)); err != nil { + t.Fatal(err) + } + } + if err := syscall.Mkfifo(filepath.Join(workspace, "fifo.out"), 0600); err != nil { + t.Fatal(err) + } + done := make(chan []actionArtifact, 1) + go func() { + done <- collectActionArtifacts(root, 1, 1, workspace, []string{"coverage.out", "linkdir/admin-token", "linkdir/*", "inner.txt", "fifo.out", "reports/*"}) + }() + var artifacts []actionArtifact + select { + case artifacts = <-done: + case <-time.After(10 * time.Second): + t.Fatal("artifact collection blocked on a special file") + } + if len(artifacts) != 1 || artifacts[0].Name != "reports/result.txt" { + t.Fatalf("unexpected artifacts: %+v", artifacts) + } + b, err := os.ReadFile(artifacts[0].Path) + if err != nil || string(b) != "ok" { + t.Fatalf("collected artifact: %q %v", b, err) + } + _ = filepath.Walk(filepath.Join(root, "artifacts"), func(path string, info os.FileInfo, err error) error { + if err == nil && info.Mode().IsRegular() { + if content, readErr := os.ReadFile(path); readErr == nil && string(content) == "server secret" { + t.Fatalf("server file was copied into the artifact store at %s", path) + } + } + return nil + }) +} From a380ab1daf1018f18d3eedb8ac217b7cd8256721 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 27 Sep 2026 03:35:43 +0530 Subject: [PATCH 03/30] fix(actions): initialize schedule state on a fresh node loadActions returned an actionDB without its LastScheduledAt map when actions.json did not exist yet. On a node whose first run is scheduled, scheduleActionRuns then assigned into a nil map and panicked inside the ticker goroutine, which terminates the whole trace serve process. Found while adding the regression tests for F078. --- cmd/trace/actions.go | 2 +- cmd/trace/actions_schedule_test.go | 65 ++++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+), 1 deletion(-) create mode 100644 cmd/trace/actions_schedule_test.go diff --git a/cmd/trace/actions.go b/cmd/trace/actions.go index ca86ccd..d32150d 100644 --- a/cmd/trace/actions.go +++ b/cmd/trace/actions.go @@ -187,7 +187,7 @@ func (a *app) apiActions(w http.ResponseWriter, r *http.Request, u userRecord, u func (s *store) loadActions() (actionDB, error) { b, err := os.ReadFile(filepath.Join(s.root, "actions.json")) if errors.Is(err, os.ErrNotExist) { - return actionDB{NextRunID: 1}, nil + return actionDB{NextRunID: 1, LastScheduledAt: map[string]time.Time{}}, nil } if err != nil { return actionDB{}, err diff --git a/cmd/trace/actions_schedule_test.go b/cmd/trace/actions_schedule_test.go new file mode 100644 index 0000000..b588926 --- /dev/null +++ b/cmd/trace/actions_schedule_test.go @@ -0,0 +1,65 @@ +package main + +import ( + "os" + "os/exec" + "path/filepath" + "testing" + "time" +) + +// TestScheduleOnFreshNodeDoesNotPanic covers a node whose first action run is +// a scheduled one: actions.json does not exist yet, and the scheduler must not +// write into a nil map (a panic there terminates the whole server process). +func TestScheduleOnFreshNodeDoesNotPanic(t *testing.T) { + root := filepath.Join(t.TempDir(), "node") + if err := initData(root); err != nil { + t.Fatal(err) + } + s, err := openStore(root) + if err != nil { + t.Fatal(err) + } + if err := s.createRepo("team/nightly", false); err != nil { + t.Fatal(err) + } + work := t.TempDir() + gitTest(t, work, "init", "--initial-branch=main") + gitTest(t, work, "config", "user.name", "Admin") + gitTest(t, work, "config", "user.email", "admin@example.invalid") + if err := os.MkdirAll(filepath.Join(work, ".trace"), 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(work, ".trace", "workflow.json"), []byte(`{"name":"nightly","schedule":"1m","jobs":[{"name":"test","run":["true"]}]}`+"\n"), 0600); err != nil { + t.Fatal(err) + } + gitTest(t, work, "add", ".") + gitTest(t, work, "commit", "-m", "nightly") + repoPath, _ := s.repoPath("team/nightly") + push := exec.Command("git", "-C", work, "push", repoPath, "main") + push.Env = append(os.Environ(), "TRACE_ADMIN=1") + if out, err := push.CombinedOutput(); err != nil { + t.Fatalf("seed repository: %v\n%s", err, out) + } + if _, err := os.Stat(filepath.Join(root, "actions.json")); !os.IsNotExist(err) { + t.Fatalf("precondition: actions.json should not exist yet: %v", err) + } + if err := s.scheduleActionRuns(); err != nil { + t.Fatal(err) + } + runs, err := s.listActionRuns("team/nightly") + if err != nil || len(runs) != 1 || runs[0].TriggeredBy != "scheduler" { + t.Fatalf("scheduler did not queue exactly one run: %+v %v", runs, err) + } + for i := 0; i < 400; i++ { + run, err := actionRunByID(s, runs[0].ID) + if err != nil { + t.Fatal(err) + } + if run.Status != "queued" && run.Status != "running" { + return + } + time.Sleep(25 * time.Millisecond) + } + t.Fatal("scheduled run did not finish") +} From 5ac64f9458e9a6627cfb72eb609cd1d327849c52 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 27 Sep 2026 03:39:08 +0530 Subject: [PATCH 04/30] feat(actions)!: let the operator decide which workflows may run Whether a job ran sandboxed was chosen by `.trace/workflow.json`, which is repository content any writer can change, and every push auto-queued the workflow. Any writer of any repository could therefore run arbitrary commands as the Trace service account by omitting "sandbox":true, and the operator had no switch to prevent it or to turn the runner off. Add `trace serve -actions off|sandboxed|trusted`: - sandboxed (default): only workflows with "sandbox":true are accepted; others are refused on push, manual trigger, and schedule; - off: no workflow runs; - trusted: unsandboxed workflows also run (previous behaviour). An unset store policy resolves to sandboxed, and the scheduler skips workflows the policy refuses before recording schedule state. BREAKING CHANGE: unsandboxed workflows no longer run unless the operator starts the server with `-actions trusted`. Finding: F078 --- FEATURES.md | 2 +- README.md | 16 +++- cmd/trace/actions.go | 56 ++++++++++++- cmd/trace/actions_policy_test.go | 126 +++++++++++++++++++++++++++++ cmd/trace/actions_schedule_test.go | 1 + cmd/trace/actions_test.go | 4 + cmd/trace/main.go | 26 +++++- 7 files changed, 224 insertions(+), 7 deletions(-) create mode 100644 cmd/trace/actions_policy_test.go diff --git a/FEATURES.md b/FEATURES.md index b123da1..0268787 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -30,7 +30,7 @@ This is the honest baseline for the current monorepo. “All features from the t | API repository creation | Implemented for admins | `POST /api/v1/repos` | | Pull requests, drafts, labels, assignees, requested reviewers, review comments, approvals, code owners, merge queues, and merge strategies | Implemented with recursive CODEOWNERS path matching and an operator-triggered single-node queue | Durable local PR store; API, CLI, and browser review page; draft-to-ready lifecycle; labels/assignees/reviewer requests with inbox notifications; line comments anchor to a text file/line and commit; approval/check/code-owner policies are enforced; serialized queue rechecks policy before merging; merge supports fast-forward, squash, and explicit merge commits; unsupported CODEOWNERS syntax and distributed queue workers are not implemented | | Issues, labels, milestones, notifications | Issues, labels, milestones, and local inbox implemented; email/push missing | Atomic `issues.json` store, notification inbox, API, CLI, and dedicated issue web page | -| CI runners and workflow files | Implemented, local, bounded queue, with opt-in macOS or Docker sandboxing | `.trace/workflow.json`, automatic push triggers, manual reruns, persisted runs, four-worker local queue, cancellation, logs, artifacts, repository-scoped secrets, opt-in persisted schedules, macOS `sandbox-exec`, and Docker isolation with no network/read-only root/capability drop; unsandboxed workflows remain trusted-only and hosted runners are not implemented | +| CI runners and workflow files | Implemented, local, bounded queue; the operator's `trace serve -actions` policy (default `sandboxed`) decides whether workflows run and whether they must request macOS or Docker sandboxing | `.trace/workflow.json`, automatic push triggers, manual reruns, persisted runs, four-worker local queue, cancellation, logs, artifacts, repository-scoped secrets, opt-in persisted schedules, macOS `sandbox-exec`, and Docker isolation with no network/read-only root/capability drop; unsandboxed workflows run only under `-actions trusted`, and hosted runners are not implemented | | Webhooks | Implemented, signed delivery with retries/history | Admin API/CLI configuration; HTTPS or loopback HTTP; HMAC-SHA256 signatures; three attempts and persisted delivery records | | Releases, assets, archive downloads, and Pages | Implemented | Tag-backed releases support bounded 100 MiB asset upload/list/download, gzip archives use `git archive`, and Pages serves committed branch files with public/private access and path validation | | Packages, registries, Git LFS | Generic artifacts, basic npm/PyPI interoperability, and basic Git LFS implemented | Authenticated package publish/list/download, one-attachment npm publish, basic PyPI multipart upload and simple-index reads, and LFS batch/upload/download; SHA-256 verification and 100 MiB object caps; no wheel metadata or distributed object store | diff --git a/README.md b/README.md index 57ae092..9ad8ab2 100644 --- a/README.md +++ b/README.md @@ -379,7 +379,7 @@ The manifest lets another node verify which Trace identity signed the observed r A repository can define a manually triggered local workflow at `.trace/workflow.json`: ```json -{"name":"checks","sandbox":true,"sandbox_runtime":"docker","sandbox_image":"alpine:3.20","jobs":[{"name":"test","run":["go test ./..."],"artifacts":["coverage.out"]}]} +{"name":"checks","sandbox":true,"sandbox_runtime":"docker","sandbox_image":"golang:1.26-alpine","jobs":[{"name":"test","run":["go test ./..."],"artifacts":["coverage.out"]}]} ``` Trigger and inspect runs through the API or CLI: @@ -393,6 +393,16 @@ Trigger and inspect runs through the API or CLI: Pushing a branch through Trace automatically queues the workflow for that changed branch when `.trace/workflow.json` is present. Manual triggering remains available for reruns and diagnostics. +The operator, not the workflow file, decides what may run. `trace serve -actions MODE` accepts: + +- `sandboxed` (the default): only workflows that set `"sandbox":true` run; any other workflow is refused when it is pushed, triggered, or scheduled. +- `off`: no workflow runs on this node. +- `trusted`: workflows without `"sandbox":true` also run, as the Trace service account. Use this only when every repository writer on the node is trusted. + +```sh +./trace serve -data ./data -actions off +``` + Queued or running jobs can be cancelled with `POST /api/v1/repos/OWNER/NAME/actions/runs/ID/cancel` or: ```sh @@ -401,7 +411,7 @@ Queued or running jobs can be cancelled with `POST /api/v1/repos/OWNER/NAME/acti Cancellation terminates the runner context and records a `cancelled` run status. The runner is still local process execution, not a hardened container or VM sandbox. -The runner checks out the requested commit, limits each command to 15 minutes and each log to 1 MiB, and stores matching artifacts under `data/artifacts`. A node runs at most four jobs concurrently; additional runs remain queued and can be cancelled. Workflows without `"sandbox":true` execute with the Trace service account and are suitable only for trusted repositories. On macOS, `"sandbox":true` uses `sandbox-exec` with a restricted filesystem and no network access. For portable isolation, set `"sandbox_runtime":"docker"` and an explicit `"sandbox_image"`; Trace runs Docker with no network, a read-only root, dropped capabilities, no-new-privileges, a process limit, and only the checked-out workspace writable. Trace refuses to fall back to unsandboxed execution when sandboxing is requested. Docker still depends on the host daemon and image supply chain. +The runner checks out the requested commit, limits each command to 15 minutes and each log to 1 MiB, and stores matching artifacts under `data/artifacts`. A node runs at most four jobs concurrently; additional runs remain queued and can be cancelled. Workflows without `"sandbox":true` run only under `-actions trusted` and then execute with the Trace service account. On macOS, `"sandbox":true` uses `sandbox-exec` with a restricted filesystem and no network access. For portable isolation, set `"sandbox_runtime":"docker"` and an explicit `"sandbox_image"`; Trace runs Docker with no network, a read-only root, dropped capabilities, no-new-privileges, a process limit, and only the checked-out workspace writable. Trace refuses to fall back to unsandboxed execution when sandboxing is requested. Docker still depends on the host daemon and image supply chain. Repository-scoped CI secrets can be managed through the dashboard, API, or CLI. Secret values are stored in `data/secrets.json` with owner-only permissions and are injected only into jobs as `TRACE_SECRET_` environment variables; list operations return names, never values: @@ -417,7 +427,7 @@ The local runner is still not an isolation boundary. Do not run untrusted workfl Workflows may opt into a recurring schedule with a duration between one minute and 24 hours: ```json -{"name":"nightly","schedule":"6h","jobs":[{"name":"test","run":["go test ./..."]}]} +{"name":"nightly","schedule":"6h","sandbox":true,"sandbox_runtime":"docker","sandbox_image":"golang:1.26-alpine","jobs":[{"name":"test","run":["go test ./..."]}]} ``` Start the scheduler explicitly with the server (it is disabled by default): diff --git a/cmd/trace/actions.go b/cmd/trace/actions.go index d32150d..69d46af 100644 --- a/cmd/trace/actions.go +++ b/cmd/trace/actions.go @@ -81,6 +81,51 @@ type actionDB struct { LastScheduledAt map[string]time.Time `json:"last_scheduled_at,omitempty"` } +// CI runner policies selected by the operator with `trace serve -actions`. +// Workflow files are repository content that any writer can change, so they +// may opt into a sandbox but can never opt out of the operator's policy. +const ( + actionsModeOff = "off" + actionsModeSandboxed = "sandboxed" + actionsModeTrusted = "trusted" +) + +func parseActionsMode(value string) (string, error) { + switch strings.ToLower(strings.TrimSpace(value)) { + case actionsModeOff: + return actionsModeOff, nil + case actionsModeSandboxed, "": + return actionsModeSandboxed, nil + case actionsModeTrusted: + return actionsModeTrusted, nil + default: + return "", errors.New("-actions must be off, sandboxed, or trusted") + } +} + +// actionsPolicy returns the effective runner policy; an unset store defaults +// to sandboxed so no code path runs unsandboxed jobs without operator consent. +func (s *store) actionsPolicy() string { + mode, err := parseActionsMode(s.actionsMode) + if err != nil { + return actionsModeSandboxed + } + return mode +} + +// checkActionsPolicy refuses a workflow that the operator's policy forbids. +func (s *store) checkActionsPolicy(config workflowConfig) error { + switch s.actionsPolicy() { + case actionsModeOff: + return errors.New("CI actions are disabled on this node (trace serve -actions off)") + case actionsModeSandboxed: + if !config.Sandbox { + return errors.New("this node runs only sandboxed workflows: set \"sandbox\":true in .trace/workflow.json, or an operator can start trace serve -actions trusted for trusted repositories") + } + } + return nil +} + var actionRunMu sync.Mutex var actionCancelMu sync.Mutex var actionCancels = map[int]context.CancelFunc{} @@ -259,6 +304,9 @@ func (s *store) actionRun(repo, ref, actor string) (actionRun, error) { if _, err := os.Stat(path); err != nil { return actionRun{}, errors.New("repository not found") } + if s.actionsPolicy() == actionsModeOff { + return actionRun{}, s.checkActionsPolicy(workflowConfig{}) + } ref = strings.TrimSpace(ref) if ref == "" { ref = "main" @@ -274,6 +322,9 @@ func (s *store) actionRun(repo, ref, actor string) (actionRun, error) { if len(config.Jobs) == 0 { return actionRun{}, errors.New("workflow has no jobs") } + if err := s.checkActionsPolicy(config); err != nil { + return actionRun{}, err + } secrets, err := s.actionSecrets(repo) if err != nil { return actionRun{}, fmt.Errorf("load action secrets: %w", err) @@ -305,6 +356,9 @@ func (s *store) actionRun(repo, ref, actor string) (actionRun, error) { // main branch. It records the last evaluation before starting a run so a // slow or repeatedly ticking scheduler cannot enqueue duplicates. func (s *store) scheduleActionRuns() error { + if s.actionsPolicy() == actionsModeOff { + return nil + } now := time.Now().UTC() type candidate struct { repo string @@ -344,7 +398,7 @@ func (s *store) scheduleActionRuns() error { continue } config, readErr := readWorkflow(path, commit) - if readErr != nil || strings.TrimSpace(config.Schedule) == "" { + if readErr != nil || strings.TrimSpace(config.Schedule) == "" || s.checkActionsPolicy(config) != nil { continue } d, parseErr := time.ParseDuration(config.Schedule) diff --git a/cmd/trace/actions_policy_test.go b/cmd/trace/actions_policy_test.go new file mode 100644 index 0000000..f5f6a45 --- /dev/null +++ b/cmd/trace/actions_policy_test.go @@ -0,0 +1,126 @@ +package main + +import ( + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestParseActionsMode(t *testing.T) { + for input, want := range map[string]string{"": actionsModeSandboxed, "sandboxed": actionsModeSandboxed, "OFF": actionsModeOff, " trusted ": actionsModeTrusted} { + got, err := parseActionsMode(input) + if err != nil || got != want { + t.Fatalf("parseActionsMode(%q) = %q, %v; want %q", input, got, err, want) + } + } + if _, err := parseActionsMode("yes"); err == nil { + t.Fatal("unknown actions mode accepted") + } + if (&store{}).actionsPolicy() != actionsModeSandboxed { + t.Fatal("an unset actions mode must default to sandboxed") + } + if err := run([]string{"serve", "-data", t.TempDir(), "-actions", "always"}); err == nil || !strings.Contains(err.Error(), "-actions") { + t.Fatalf("serve accepted an invalid -actions value: %v", err) + } +} + +// pushWorkflow commits a workflow file and pushes it through Trace's HTTP Git +// endpoint, which is what auto-triggers runs. +func pushWorkflow(t *testing.T, a *app, root, repo, workflow string) { + t.Helper() + server := httptest.NewServer(a) + defer server.Close() + work := t.TempDir() + gitTest(t, work, "init", "--initial-branch=main") + gitTest(t, work, "config", "user.name", "Admin") + gitTest(t, work, "config", "user.email", "admin@example.invalid") + if err := os.MkdirAll(filepath.Join(work, ".trace"), 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(work, ".trace", "workflow.json"), []byte(workflow+"\n"), 0600); err != nil { + t.Fatal(err) + } + gitTest(t, work, "add", ".") + gitTest(t, work, "commit", "-m", "workflow") + gitTest(t, work, "push", strings.Replace(server.URL, "http://", "http://admin:"+adminToken(t, root)+"@", 1)+"/git/"+repo+".git", "main") +} + +// TestActionsPolicyIsOperatorControlled checks that repository content cannot +// opt out of the operator's runner policy: by default only sandboxed +// workflows run, and `-actions off` disables push, manual, and scheduled runs. +func TestActionsPolicyIsOperatorControlled(t *testing.T) { + root := filepath.Join(t.TempDir(), "node") + if err := initData(root); err != nil { + t.Fatal(err) + } + a, err := newApp(root) + if err != nil { + t.Fatal(err) + } + if err := a.store.createRepo("team/untrusted", false); err != nil { + t.Fatal(err) + } + pushWorkflow(t, a, root, "team/untrusted", `{"name":"escape","schedule":"1m","jobs":[{"name":"pwn","run":["id > pwned.txt"]}]}`) + if runs, err := a.store.listActionRuns("team/untrusted"); err != nil || len(runs) != 0 { + t.Fatalf("default policy queued an unsandboxed workflow on push: %+v %v", runs, err) + } + trigger := func() *httptest.ResponseRecorder { + req := httptest.NewRequest(http.MethodPost, "/api/v1/repos/team/untrusted/actions/runs", strings.NewReader(`{"ref":"main"}`)) + req.SetBasicAuth("admin", adminToken(t, root)) + res := httptest.NewRecorder() + a.ServeHTTP(res, req) + return res + } + if res := trigger(); res.Code != http.StatusBadRequest || !strings.Contains(res.Body.String(), "sandboxed") { + t.Fatalf("manual unsandboxed run under the default policy: %d %s", res.Code, res.Body.String()) + } + if err := a.store.scheduleActionRuns(); err != nil { + t.Fatal(err) + } + if runs, _ := a.store.listActionRuns("team/untrusted"); len(runs) != 0 { + t.Fatalf("scheduler queued an unsandboxed workflow under the default policy: %+v", runs) + } + + a.store.actionsMode = actionsModeOff + if err := a.store.createRepo("team/sandboxed", false); err != nil { + t.Fatal(err) + } + pushWorkflow(t, a, root, "team/sandboxed", `{"name":"boxed","sandbox":true,"sandbox_runtime":"macos","jobs":[{"name":"test","run":["true"]}]}`) + if runs, err := a.store.listActionRuns("team/sandboxed"); err != nil || len(runs) != 0 { + t.Fatalf("actions off still queued a run on push: %+v %v", runs, err) + } + if _, err := a.store.actionRun("team/sandboxed", "main", "admin"); err == nil || !strings.Contains(err.Error(), "disabled") { + t.Fatalf("actions off accepted a manual run: %v", err) + } + + a.store.actionsMode = actionsModeSandboxed + run, err := a.store.actionRun("team/sandboxed", "main", "admin") + if err != nil { + t.Fatalf("sandboxed policy refused a sandboxed workflow: %v", err) + } + waitForActionRun(t, a.store, run.ID) +} + +func waitForActionRun(t *testing.T, s *store, id int) actionRun { + t.Helper() + var run actionRun + for i := 0; i < 400; i++ { + var err error + run, err = actionRunByID(s, id) + if err != nil { + t.Fatal(err) + } + if run.Status != "queued" && run.Status != "running" { + return run + } + sleepBriefly() + } + t.Fatalf("action run %d did not finish: %+v", id, run) + return run +} + +func sleepBriefly() { time.Sleep(25 * time.Millisecond) } diff --git a/cmd/trace/actions_schedule_test.go b/cmd/trace/actions_schedule_test.go index b588926..c16d24d 100644 --- a/cmd/trace/actions_schedule_test.go +++ b/cmd/trace/actions_schedule_test.go @@ -20,6 +20,7 @@ func TestScheduleOnFreshNodeDoesNotPanic(t *testing.T) { if err != nil { t.Fatal(err) } + s.actionsMode = actionsModeTrusted if err := s.createRepo("team/nightly", false); err != nil { t.Fatal(err) } diff --git a/cmd/trace/actions_test.go b/cmd/trace/actions_test.go index 37ddf08..7764350 100644 --- a/cmd/trace/actions_test.go +++ b/cmd/trace/actions_test.go @@ -74,6 +74,9 @@ func TestLocalActionRunAndArtifact(t *testing.T) { if err != nil { t.Fatal(err) } + // This test exercises the trusted local runner, which an operator must + // opt into explicitly with `trace serve -actions trusted`. + a.store.actionsMode = actionsModeTrusted if err := a.store.createRepo("team/ci", false); err != nil { t.Fatal(err) } @@ -153,6 +156,7 @@ func TestScheduledWorkflowQueuesOncePerInterval(t *testing.T) { if err != nil { t.Fatal(err) } + a.store.actionsMode = actionsModeTrusted if err := a.store.createRepo("team/scheduled", false); err != nil { t.Fatal(err) } diff --git a/cmd/trace/main.go b/cmd/trace/main.go index 9f60bd7..04536cd 100644 --- a/cmd/trace/main.go +++ b/cmd/trace/main.go @@ -56,6 +56,7 @@ func run(args []string) error { sshAddr := fs.String("ssh-listen", "", "SSH Git listen address (disabled by default)") federationInterval := fs.Duration("federation-interval", 0, "background federation peer sync interval (disabled by default)") actionsInterval := fs.Duration("actions-interval", 0, "scheduled workflow evaluation interval (disabled by default)") + actionsMode := fs.String("actions", actionsModeSandboxed, "CI runner policy: off, sandboxed (only workflows with \"sandbox\":true), or trusted (also run unsandboxed workflows as the Trace service account)") if err := fs.Parse(args[1:]); err != nil { return err } @@ -68,7 +69,11 @@ func run(args []string) error { if *actionsInterval < 0 { return errors.New("-actions-interval cannot be negative") } - return serve(*data, *addr, *sshAddr, *federationInterval, *actionsInterval) + mode, err := parseActionsMode(*actionsMode) + if err != nil { + return err + } + return serve(serveOptions{data: *data, addr: *addr, sshAddr: *sshAddr, federationInterval: *federationInterval, actionsInterval: *actionsInterval, actionsMode: mode}) case "repo": if len(args) < 2 || (args[1] != "create" && args[1] != "import" && args[1] != "fork" && args[1] != "archive" && args[1] != "restore" && args[1] != "delete" && args[1] != "transfer" && args[1] != "topics") { return errors.New("usage: trace repo ...") @@ -436,6 +441,9 @@ func run(args []string) error { type store struct { root string repos string + // actionsMode is the operator's CI runner policy (see parseActionsMode). + // The zero value means actionsModeSandboxed. + actionsMode string } func openStore(data string) (*store, error) { @@ -935,11 +943,25 @@ func newApp(data string) (*app, error) { return &app{store: s, csrf: base64.RawURLEncoding.EncodeToString(csrfBytes), sessionKey: sessionKey, gitPath: gitPath, limiter: newRateLimiter(s.root)}, nil } -func serve(data, addr, sshAddr string, federationInterval, actionsInterval time.Duration) error { +// serveOptions carries the operator's `trace serve` flags. +type serveOptions struct { + data string + addr string + sshAddr string + federationInterval time.Duration + actionsInterval time.Duration + actionsMode string +} + +func serve(opts serveOptions) error { + data, addr, sshAddr := opts.data, opts.addr, opts.sshAddr + federationInterval, actionsInterval := opts.federationInterval, opts.actionsInterval a, err := newApp(data) if err != nil { return err } + a.store.actionsMode = opts.actionsMode + log.Printf("trace CI actions mode: %s", a.store.actionsPolicy()) if err := a.store.ensureHooks(); err != nil { return err } From a138ec0cb1ab2b8b04b29dafb390150a09dc5cc2 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 27 Sep 2026 03:42:42 +0530 Subject: [PATCH 05/30] fix(actions): make the macOS sandbox profile start a shell The sandbox-exec profile denied reading the root directory entry, the /private/var/select/sh link that /bin/sh resolves on current macOS, and /dev/null, and it listed the workspace by its unresolved path while sandbox-exec matches resolved paths (/var -> /private/var). On macOS 26 every "sandbox":true job therefore died before running a command, so the documented macOS isolation never worked. Allow exactly those entries, resolve the writable directory before building the profile, and add a darwin-only test that runs real commands: the job writes its workspace, while reading or writing outside it fails. Found while adding the regression tests for F088. --- cmd/trace/actions.go | 22 ++++++++++--- cmd/trace/actions_sandbox_test.go | 53 +++++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+), 4 deletions(-) create mode 100644 cmd/trace/actions_sandbox_test.go diff --git a/cmd/trace/actions.go b/cmd/trace/actions.go index 69d46af..b7f721e 100644 --- a/cmd/trace/actions.go +++ b/cmd/trace/actions.go @@ -648,16 +648,30 @@ func actionCommandWithSandbox(ctx context.Context, commandText, workspace string if sandboxRuntime != "macos" || runtime.GOOS != "darwin" || !commandAvailable("sandbox-exec") { return nil, errors.New("macOS sandboxing requires sandbox-exec; refusing unsafe fallback") } + return exec.CommandContext(ctx, "sandbox-exec", "-p", macOSSandboxProfile(workspace), "/bin/sh", "-c", commandText), nil +} + +// macOSSandboxProfile allows the job to read system tool directories and to +// read and write only writableDir. sandbox-exec matches resolved paths, so +// symlinks such as /var -> /private/var are resolved first; otherwise every +// write to the workspace is denied. The root directory entry and /bin/sh's +// selector link must be readable for the shell to start on current macOS, +// and /dev/null is needed for ordinary redirections. +func macOSSandboxProfile(writableDir string) string { + if resolved, err := filepath.EvalSymlinks(writableDir); err == nil { + writableDir = resolved + } quote := func(value string) string { return strconv.Quote(value) } - profile := "(version 1)\n" + + return "(version 1)\n" + "(deny default)\n" + "(allow process-fork)\n" + "(allow process-exec)\n" + "(allow signal (target self))\n" + + "(allow file-read* (literal \"/\") (literal \"/private/var/select/sh\"))\n" + + "(allow file-read* file-write-data (literal \"/dev/null\"))\n" + "(allow file-read* (subpath \"/bin\") (subpath \"/usr\") (subpath \"/System\") (subpath \"/Library\") (subpath \"/opt/homebrew\"))\n" + - "(allow file-read* (subpath " + quote(workspace) + "))\n" + - "(allow file-write* (subpath " + quote(workspace) + "))\n" - return exec.CommandContext(ctx, "sandbox-exec", "-p", profile, "/bin/sh", "-c", commandText), nil + "(allow file-read* (subpath " + quote(writableDir) + "))\n" + + "(allow file-write* (subpath " + quote(writableDir) + "))\n" } // Kept in a helper so the duration is easy to audit and change in one place. diff --git a/cmd/trace/actions_sandbox_test.go b/cmd/trace/actions_sandbox_test.go new file mode 100644 index 0000000..d5d5f70 --- /dev/null +++ b/cmd/trace/actions_sandbox_test.go @@ -0,0 +1,53 @@ +package main + +import ( + "context" + "os" + "path/filepath" + "runtime" + "strings" + "testing" +) + +// TestMacOSSandboxRunsJobsAndConfinesThem runs real commands under the +// generated sandbox-exec profile: the shell must start and write inside the +// workspace (even when it is reached through a symlink such as /var), while +// files outside the workspace stay unreadable and unwritable. +func TestMacOSSandboxRunsJobsAndConfinesThem(t *testing.T) { + if runtime.GOOS != "darwin" || !commandAvailable("sandbox-exec") { + t.Skip("sandbox-exec is only available on macOS") + } + workspace := filepath.Join(t.TempDir(), "workspace") + if err := os.MkdirAll(workspace, 0700); err != nil { + t.Fatal(err) + } + outside := filepath.Join(t.TempDir(), "outside.txt") + if err := os.WriteFile(outside, []byte("server data"), 0600); err != nil { + t.Fatal(err) + } + runSandboxed := func(command string) (string, error) { + cmd, err := actionCommand(context.Background(), command, workspace, true) + if err != nil { + t.Fatal(err) + } + cmd.Dir = workspace + out, err := cmd.CombinedOutput() + return string(out), err + } + if out, err := runSandboxed("printf ok > result.txt && printf x > /dev/null"); err != nil { + t.Fatalf("sandboxed job could not write its workspace: %v\n%s", err, out) + } + if b, err := os.ReadFile(filepath.Join(workspace, "result.txt")); err != nil || string(b) != "ok" { + t.Fatalf("sandboxed job output: %q %v", b, err) + } + if out, err := runSandboxed("cat " + outside); err == nil || strings.Contains(out, "server data") { + t.Fatalf("sandboxed job read a file outside its workspace: %v\n%s", err, out) + } + escape := filepath.Join(filepath.Dir(outside), "escape.txt") + if _, err := runSandboxed("printf x > " + escape); err == nil { + t.Fatal("sandboxed job wrote outside its workspace") + } + if _, err := os.Stat(escape); err == nil { + t.Fatal("sandboxed job created a file outside its workspace") + } +} From 6e098f640600542e1479fad424f551d9a465c269 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 27 Sep 2026 03:45:47 +0530 Subject: [PATCH 06/30] fix(actions): give jobs a minimal environment instead of the server's Every job, including sandbox-exec jobs, received append(os.Environ(), ...), so any variable in the Trace service environment (cloud tokens, proxy or registry credentials) was readable by any workflow via `env`. Jobs now get exactly PATH, HOME (the checkout), TMPDIR, LANG/LC_ALL when set, TRACE_REPOSITORY, TRACE_COMMIT, CI=true and the repository's TRACE_SECRET_* values. Each run gets a private directory with the checkout (src) and a scratch TMPDIR (tmp), both passed as canonical paths so sandboxed jobs can use them; the scratch directory is also writable under sandbox-exec and mounted at /tmp for Docker. The docker client keeps the operator environment it needs to reach the daemon; containers never inherit it. Finding: F088 --- README.md | 2 +- cmd/trace/actions.go | 106 +++++++++++++++++++++++++++------- cmd/trace/actions_env_test.go | 68 ++++++++++++++++++++++ cmd/trace/actions_test.go | 2 +- 4 files changed, 154 insertions(+), 24 deletions(-) create mode 100644 cmd/trace/actions_env_test.go diff --git a/README.md b/README.md index 9ad8ab2..f3043f6 100644 --- a/README.md +++ b/README.md @@ -413,7 +413,7 @@ Cancellation terminates the runner context and records a `cancelled` run status. The runner checks out the requested commit, limits each command to 15 minutes and each log to 1 MiB, and stores matching artifacts under `data/artifacts`. A node runs at most four jobs concurrently; additional runs remain queued and can be cancelled. Workflows without `"sandbox":true` run only under `-actions trusted` and then execute with the Trace service account. On macOS, `"sandbox":true` uses `sandbox-exec` with a restricted filesystem and no network access. For portable isolation, set `"sandbox_runtime":"docker"` and an explicit `"sandbox_image"`; Trace runs Docker with no network, a read-only root, dropped capabilities, no-new-privileges, a process limit, and only the checked-out workspace writable. Trace refuses to fall back to unsandboxed execution when sandboxing is requested. Docker still depends on the host daemon and image supply chain. -Repository-scoped CI secrets can be managed through the dashboard, API, or CLI. Secret values are stored in `data/secrets.json` with owner-only permissions and are injected only into jobs as `TRACE_SECRET_` environment variables; list operations return names, never values: +Repository-scoped CI secrets can be managed through the dashboard, API, or CLI. Secret values are stored in `data/secrets.json` with owner-only permissions and are injected only into jobs as `TRACE_SECRET_` environment variables; list operations return names, never values. A job's environment contains only `PATH`, `HOME` (the checkout), `TMPDIR` (a per-run scratch directory), `LANG`/`LC_ALL` when set, `TRACE_REPOSITORY`, `TRACE_COMMIT`, `CI=true`, and the repository's `TRACE_SECRET_*` values; the Trace service environment is never passed to jobs: ```sh ./trace api secret-set -url http://127.0.0.1:8787 -user admin \ diff --git a/cmd/trace/actions.go b/cmd/trace/actions.go index b7f721e..b7610b0 100644 --- a/cmd/trace/actions.go +++ b/cmd/trace/actions.go @@ -517,9 +517,21 @@ func (s *store) executeActionRun(ctx context.Context, run actionRun, config work delete(actionCancels, run.ID) actionCancelMu.Unlock() }() - workspace := filepath.Join(s.root, "action-work", fmt.Sprint(run.ID)) - _ = os.MkdirAll(filepath.Dir(workspace), 0700) - defer os.RemoveAll(workspace) + // Each run gets a private directory holding the checkout (src) and a + // scratch TMPDIR (tmp); both are writable by the job and removed after. + runDir := filepath.Join(s.root, "action-work", fmt.Sprint(run.ID)) + workspace := filepath.Join(runDir, "src") + scratch := filepath.Join(runDir, "tmp") + defer os.RemoveAll(runDir) + if err := os.MkdirAll(scratch, 0700); err != nil { + s.finishActionRun(run.ID, "failure", []actionJob{{ID: 1, Name: "checkout", Status: "failure", ExitCode: 1, Log: "cannot create the run directory"}}) + return + } + // Give jobs canonical paths: a sandbox cannot traverse symlinks such as + // macOS's /var -> /private/var that it is not allowed to read. + if resolved, err := filepath.EvalSymlinks(runDir); err == nil { + workspace, scratch = filepath.Join(resolved, "src"), filepath.Join(resolved, "tmp") + } if config.Sandbox { runtimeName := config.SandboxRuntime if runtimeName == "" { @@ -568,7 +580,7 @@ func (s *store) executeActionRun(ctx context.Context, run actionRun, config work logText.WriteByte('\n') } commandCtx, cancel := context.WithTimeout(ctx, maxActionDuration) - cmd, commandErr := actionCommandWithSandbox(commandCtx, commandText, workspace, config.Sandbox, config.SandboxRuntime, config.SandboxImage) + cmd, commandErr := actionCommandWithSandbox(commandCtx, commandText, workspace, scratch, config.Sandbox, config.SandboxRuntime, config.SandboxImage) if commandErr != nil { job.Status, job.ExitCode = "failure", 1 logText.WriteString(commandErr.Error()) @@ -577,11 +589,7 @@ func (s *store) executeActionRun(ctx context.Context, run actionRun, config work break } cmd.Dir = workspace - env := append(os.Environ(), "TRACE_REPOSITORY="+run.Repo, "TRACE_COMMIT="+run.Commit, "CI=true") - for name, value := range secrets { - env = append(env, name+"="+value) - } - cmd.Env = env + cmd.Env = actionCommandEnv(cmd, actionJobEnv(run.Repo, run.Commit, workspace, scratch, secrets)) out, err := cmd.CombinedOutput() cancel() if logText.Len() < maxActionLog { @@ -623,8 +631,47 @@ func (s *store) executeActionRun(ctx context.Context, run actionRun, config work s.finishActionRun(run.ID, status, jobs) } +// actionJobEnv is the complete environment a job sees: the documented TRACE_* +// variables, CI, the repository's TRACE_SECRET_* values, and a minimal +// PATH/HOME/TMPDIR/locale. The Trace service environment is never inherited, +// so server credentials in it are not readable by workflows. +func actionJobEnv(repo, commit, home, tmpDir string, secrets map[string]string) []string { + path := os.Getenv("PATH") + if path == "" { + path = "/usr/local/bin:/usr/bin:/bin" + } + env := []string{"PATH=" + path, "HOME=" + home, "TMPDIR=" + tmpDir} + for _, name := range []string{"LANG", "LC_ALL"} { + if value := os.Getenv(name); value != "" { + env = append(env, name+"="+value) + } + } + env = append(env, "TRACE_REPOSITORY="+repo, "TRACE_COMMIT="+commit, "CI=true") + names := make([]string, 0, len(secrets)) + for name := range secrets { + names = append(names, name) + } + sort.Strings(names) + for _, name := range names { + env = append(env, name+"="+secrets[name]) + } + return env +} + +// actionCommandEnv returns the environment for the runner process. Local and +// sandbox-exec jobs get exactly jobEnv. The docker client itself is operator +// tooling and keeps the service environment (DOCKER_HOST, HOME for its +// config); the container only receives variables named explicitly on the +// docker command line. +func actionCommandEnv(cmd *exec.Cmd, jobEnv []string) []string { + if len(cmd.Args) > 0 && cmd.Args[0] == "docker" { + return append(os.Environ(), jobEnv...) + } + return jobEnv +} + func actionCommand(ctx context.Context, commandText, workspace string, sandbox bool) (*exec.Cmd, error) { - return actionCommandWithSandbox(ctx, commandText, workspace, sandbox, "", "") + return actionCommandWithSandbox(ctx, commandText, workspace, "", sandbox, "", "") } func commandAvailable(name string) bool { @@ -632,7 +679,10 @@ func commandAvailable(name string) bool { return err == nil } -func actionCommandWithSandbox(ctx context.Context, commandText, workspace string, sandbox bool, sandboxRuntime, sandboxImage string) (*exec.Cmd, error) { +// actionCommandWithSandbox builds the runner command for one workflow step. +// workspace is the checkout; scratch, when set, is an extra job-writable +// directory used as TMPDIR (mounted at /tmp for Docker). +func actionCommandWithSandbox(ctx context.Context, commandText, workspace, scratch string, sandbox bool, sandboxRuntime, sandboxImage string) (*exec.Cmd, error) { if !sandbox { return exec.CommandContext(ctx, "sh", "-c", commandText), nil } @@ -643,35 +693,47 @@ func actionCommandWithSandbox(ctx context.Context, commandText, workspace string if strings.TrimSpace(sandboxImage) == "" || !commandAvailable("docker") { return nil, errors.New("Docker sandboxing requires docker and sandbox_image; refusing unsafe fallback") } - return exec.CommandContext(ctx, "docker", "run", "--rm", "--network", "none", "--read-only", "--cap-drop", "ALL", "--security-opt", "no-new-privileges", "--pids-limit", "256", "-v", workspace+":/workspace:rw", "-w", "/workspace", sandboxImage, "/bin/sh", "-c", commandText), nil + args := []string{"run", "--rm", "--network", "none", "--read-only", "--cap-drop", "ALL", "--security-opt", "no-new-privileges", "--pids-limit", "256", "-v", workspace + ":/workspace:rw"} + if scratch != "" { + args = append(args, "-v", scratch+":/tmp:rw") + } + args = append(args, "-w", "/workspace", sandboxImage, "/bin/sh", "-c", commandText) + return exec.CommandContext(ctx, "docker", args...), nil } if sandboxRuntime != "macos" || runtime.GOOS != "darwin" || !commandAvailable("sandbox-exec") { return nil, errors.New("macOS sandboxing requires sandbox-exec; refusing unsafe fallback") } - return exec.CommandContext(ctx, "sandbox-exec", "-p", macOSSandboxProfile(workspace), "/bin/sh", "-c", commandText), nil + writable := []string{workspace} + if scratch != "" { + writable = append(writable, scratch) + } + return exec.CommandContext(ctx, "sandbox-exec", "-p", macOSSandboxProfile(writable...), "/bin/sh", "-c", commandText), nil } // macOSSandboxProfile allows the job to read system tool directories and to -// read and write only writableDir. sandbox-exec matches resolved paths, so +// read and write only writableDirs. sandbox-exec matches resolved paths, so // symlinks such as /var -> /private/var are resolved first; otherwise every // write to the workspace is denied. The root directory entry and /bin/sh's // selector link must be readable for the shell to start on current macOS, // and /dev/null is needed for ordinary redirections. -func macOSSandboxProfile(writableDir string) string { - if resolved, err := filepath.EvalSymlinks(writableDir); err == nil { - writableDir = resolved - } +func macOSSandboxProfile(writableDirs ...string) string { quote := func(value string) string { return strconv.Quote(value) } - return "(version 1)\n" + + profile := "(version 1)\n" + "(deny default)\n" + "(allow process-fork)\n" + "(allow process-exec)\n" + "(allow signal (target self))\n" + "(allow file-read* (literal \"/\") (literal \"/private/var/select/sh\"))\n" + "(allow file-read* file-write-data (literal \"/dev/null\"))\n" + - "(allow file-read* (subpath \"/bin\") (subpath \"/usr\") (subpath \"/System\") (subpath \"/Library\") (subpath \"/opt/homebrew\"))\n" + - "(allow file-read* (subpath " + quote(writableDir) + "))\n" + - "(allow file-write* (subpath " + quote(writableDir) + "))\n" + "(allow file-read* (subpath \"/bin\") (subpath \"/usr\") (subpath \"/System\") (subpath \"/Library\") (subpath \"/opt/homebrew\"))\n" + for _, dir := range writableDirs { + if resolved, err := filepath.EvalSymlinks(dir); err == nil { + dir = resolved + } + profile += "(allow file-read* (subpath " + quote(dir) + "))\n" + + "(allow file-write* (subpath " + quote(dir) + "))\n" + } + return profile } // Kept in a helper so the duration is easy to audit and change in one place. diff --git a/cmd/trace/actions_env_test.go b/cmd/trace/actions_env_test.go new file mode 100644 index 0000000..7b24091 --- /dev/null +++ b/cmd/trace/actions_env_test.go @@ -0,0 +1,68 @@ +package main + +import ( + "os" + "path/filepath" + "runtime" + "strings" + "testing" +) + +// TestActionJobsDoNotInheritServerEnvironment checks that a workflow sees only +// the documented job variables, never the Trace service environment (cloud +// credentials, proxy tokens, and so on). +func TestActionJobsDoNotInheritServerEnvironment(t *testing.T) { + t.Setenv("TRACE_TEST_SERVER_CREDENTIAL", "server-only-value") + cases := []struct { + name string + mode string + sandbox string + }{ + {name: "local", mode: actionsModeTrusted}, + {name: "macos", mode: actionsModeSandboxed, sandbox: `"sandbox":true,"sandbox_runtime":"macos",`}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if tc.name == "macos" && (runtime.GOOS != "darwin" || !commandAvailable("sandbox-exec")) { + t.Skip("sandbox-exec is only available on macOS") + } + root := filepath.Join(t.TempDir(), "node") + if err := initData(root); err != nil { + t.Fatal(err) + } + a, err := newApp(root) + if err != nil { + t.Fatal(err) + } + a.store.actionsMode = tc.mode + if err := a.store.createRepo("team/env", false); err != nil { + t.Fatal(err) + } + if err := a.store.setSecret("team/env", "DEPLOY", "repo-secret"); err != nil { + t.Fatal(err) + } + pushWorkflow(t, a, root, "team/env", `{"name":"env",`+tc.sandbox+`"jobs":[{"name":"env","run":["env > env.txt","touch \"$TMPDIR/probe\""],"artifacts":["env.txt"]}]}`) + runs, err := a.store.listActionRuns("team/env") + if err != nil || len(runs) != 1 { + t.Fatalf("push did not queue one run: %+v %v", runs, err) + } + run := waitForActionRun(t, a.store, runs[0].ID) + if run.Status != "success" || len(run.Jobs) != 1 || len(run.Jobs[0].Artifacts) != 1 { + t.Fatalf("env job failed: %+v", run) + } + b, err := os.ReadFile(run.Jobs[0].Artifacts[0].Path) + if err != nil { + t.Fatal(err) + } + env := string(b) + if strings.Contains(env, "TRACE_TEST_SERVER_CREDENTIAL") || strings.Contains(env, "server-only-value") { + t.Fatalf("job inherited the server environment:\n%s", env) + } + for _, want := range []string{"TRACE_REPOSITORY=team/env", "TRACE_COMMIT=" + run.Commit, "CI=true", "TRACE_SECRET_DEPLOY=repo-secret", "HOME=", "TMPDIR=", "PATH="} { + if !strings.Contains(env, want) { + t.Fatalf("job environment is missing %q:\n%s", want, env) + } + } + }) + } +} diff --git a/cmd/trace/actions_test.go b/cmd/trace/actions_test.go index 7764350..a7c8fa1 100644 --- a/cmd/trace/actions_test.go +++ b/cmd/trace/actions_test.go @@ -53,7 +53,7 @@ func TestDockerSandboxCommandShape(t *testing.T) { if !commandAvailable("docker") { t.Skip("docker client is not installed") } - cmd, err := actionCommandWithSandbox(context.Background(), "printf ok", t.TempDir(), true, "docker", "alpine:3.20") + cmd, err := actionCommandWithSandbox(context.Background(), "printf ok", t.TempDir(), "", true, "docker", "alpine:3.20") if err != nil { t.Fatal(err) } From f40759a06ed2f7c6616fcde5bf32664a50c52be9 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 27 Sep 2026 03:49:41 +0530 Subject: [PATCH 07/30] fix(actions): forward job variables into Docker and stop whole steps Docker jobs never received TRACE_SECRET_*, TRACE_REPOSITORY, TRACE_COMMIT or CI: they were set on the docker client's environment, which Docker does not forward. Cancellation and the 15-minute limit killed only the docker client, leaving the container running with the workspace mounted, and for local and sandbox-exec jobs only the direct child was killed, so background processes survived both and could hold a step open. - Docker steps run as `--name trace-run-RUN-JOB-STEP`, receive the job variables with `-e NAME` (values come from the client environment, never argv), and cancellation runs `docker kill` before killing the client. - Local and sandbox-exec steps run in their own process group; the group is killed on cancellation, on timeout, and after the step exits. WaitDelay bounds how long leftover children may hold the output pipes; a step that exited 0 still succeeds and the log notes the cleanup. - Step output is captured through the existing capped buffer, so a step that prints without bound no longer grows server memory before the 1 MiB log cap is applied. Finding: F087 --- README.md | 4 +- cmd/trace/actions.go | 189 ++++++++++++++++++++++-------- cmd/trace/actions_process_test.go | 112 ++++++++++++++++++ cmd/trace/actions_test.go | 14 ++- 4 files changed, 263 insertions(+), 56 deletions(-) create mode 100644 cmd/trace/actions_process_test.go diff --git a/README.md b/README.md index f3043f6..c2e17b1 100644 --- a/README.md +++ b/README.md @@ -409,9 +409,9 @@ Queued or running jobs can be cancelled with `POST /api/v1/repos/OWNER/NAME/acti ./trace api action-cancel -url http://127.0.0.1:8787 -user admin -token-file ./data/admin-token OWNER/NAME ID ``` -Cancellation terminates the runner context and records a `cancelled` run status. The runner is still local process execution, not a hardened container or VM sandbox. +Cancellation and the 15-minute step limit stop the whole step and record the result. Local and `sandbox-exec` steps run in their own process group, which Trace kills on cancellation, on timeout, and when the step exits, so background processes do not outlive it (a process that deliberately leaves the group with `setsid` is not tracked). Docker steps run in a container named `trace-run-RUN-JOB-STEP`, which Trace stops with `docker kill`. Each step's captured output is capped at the 1 MiB log limit. The runner is still local process execution, not a hardened container or VM sandbox. -The runner checks out the requested commit, limits each command to 15 minutes and each log to 1 MiB, and stores matching artifacts under `data/artifacts`. A node runs at most four jobs concurrently; additional runs remain queued and can be cancelled. Workflows without `"sandbox":true` run only under `-actions trusted` and then execute with the Trace service account. On macOS, `"sandbox":true` uses `sandbox-exec` with a restricted filesystem and no network access. For portable isolation, set `"sandbox_runtime":"docker"` and an explicit `"sandbox_image"`; Trace runs Docker with no network, a read-only root, dropped capabilities, no-new-privileges, a process limit, and only the checked-out workspace writable. Trace refuses to fall back to unsandboxed execution when sandboxing is requested. Docker still depends on the host daemon and image supply chain. +The runner checks out the requested commit, limits each command to 15 minutes and each log to 1 MiB, and stores matching artifacts under `data/artifacts`. A node runs at most four jobs concurrently; additional runs remain queued and can be cancelled. Workflows without `"sandbox":true` run only under `-actions trusted` and then execute with the Trace service account. On macOS, `"sandbox":true` uses `sandbox-exec` with a restricted filesystem and no network access. For portable isolation, set `"sandbox_runtime":"docker"` and an explicit `"sandbox_image"`; Trace runs Docker with no network, a read-only root, dropped capabilities, no-new-privileges, a process limit, and only the checked-out workspace (`/workspace`) and a per-run scratch directory (`/tmp`) writable; `TRACE_REPOSITORY`, `TRACE_COMMIT`, `CI`, and `TRACE_SECRET_*` are passed into the container by name. Trace refuses to fall back to unsandboxed execution when sandboxing is requested. Docker still depends on the host daemon and image supply chain. Repository-scoped CI secrets can be managed through the dashboard, API, or CLI. Secret values are stored in `data/secrets.json` with owner-only permissions and are injected only into jobs as `TRACE_SECRET_` environment variables; list operations return names, never values. A job's environment contains only `PATH`, `HOME` (the checkout), `TMPDIR` (a per-run scratch directory), `LANG`/`LC_ALL` when set, `TRACE_REPOSITORY`, `TRACE_COMMIT`, `CI=true`, and the repository's `TRACE_SECRET_*` values; the Trace service environment is never passed to jobs: diff --git a/cmd/trace/actions.go b/cmd/trace/actions.go index b7610b0..1c7ec09 100644 --- a/cmd/trace/actions.go +++ b/cmd/trace/actions.go @@ -573,14 +573,23 @@ func (s *store) executeActionRun(ctx context.Context, run actionRun, config work } job := actionJob{ID: i + 1, Name: spec.Name, Status: "running", StartedAt: time.Now().UTC()} var logText strings.Builder - for _, commandText := range spec.Run { + for commandIndex, commandText := range spec.Run { if logText.Len() < maxActionLog { logText.WriteString("$ ") logText.WriteString(commandText) logText.WriteByte('\n') } commandCtx, cancel := context.WithTimeout(ctx, maxActionDuration) - cmd, commandErr := actionCommandWithSandbox(commandCtx, commandText, workspace, scratch, config.Sandbox, config.SandboxRuntime, config.SandboxImage) + cmd, commandErr := buildActionCommand(commandCtx, actionCommandSpec{ + Command: commandText, + Workspace: workspace, + Scratch: scratch, + Sandbox: config.Sandbox, + Runtime: config.SandboxRuntime, + Image: config.SandboxImage, + Env: actionJobEnv(run.Repo, run.Commit, workspace, scratch, secrets), + Name: fmt.Sprintf("trace-run-%d-%d-%d", run.ID, job.ID, commandIndex+1), + }) if commandErr != nil { job.Status, job.ExitCode = "failure", 1 logText.WriteString(commandErr.Error()) @@ -588,16 +597,22 @@ func (s *store) executeActionRun(ctx context.Context, run actionRun, config work cancel() break } - cmd.Dir = workspace - cmd.Env = actionCommandEnv(cmd, actionJobEnv(run.Repo, run.Commit, workspace, scratch, secrets)) - out, err := cmd.CombinedOutput() + // One shared writer: os/exec then serializes stdout and stderr + // writes, and the cap keeps unbounded output out of memory. + output := &cappedBuffer{limit: maxActionLog - logText.Len()} + cmd.Stdout, cmd.Stderr = output, output + err := runActionCommand(cmd) + timedOut := commandCtx.Err() != nil cancel() - if logText.Len() < maxActionLog { - remaining := maxActionLog - logText.Len() - if len(out) > remaining { - out = out[:remaining] - } - logText.Write(out) + logText.Write(output.Bytes()) + if timedOut && ctx.Err() == nil { + logText.WriteString("\nTrace: step exceeded the 15-minute limit and was stopped\n") + } + if errors.Is(err, exec.ErrWaitDelay) && ctx.Err() == nil && !timedOut { + // The step exited successfully but left background processes + // holding its output open; they were terminated with the group. + logText.WriteString("\nTrace: stopped background processes left running by this step\n") + err = nil } if err != nil { job.Status, job.ExitCode = "failure", 1 @@ -658,20 +673,121 @@ func actionJobEnv(repo, commit, home, tmpDir string, secrets map[string]string) return env } -// actionCommandEnv returns the environment for the runner process. Local and -// sandbox-exec jobs get exactly jobEnv. The docker client itself is operator -// tooling and keeps the service environment (DOCKER_HOST, HOME for its -// config); the container only receives variables named explicitly on the -// docker command line. -func actionCommandEnv(cmd *exec.Cmd, jobEnv []string) []string { - if len(cmd.Args) > 0 && cmd.Args[0] == "docker" { - return append(os.Environ(), jobEnv...) +// actionCommandSpec describes one workflow step for buildActionCommand. +type actionCommandSpec struct { + Command string + Workspace string // checkout, the working directory + Scratch string // optional extra job-writable directory (TMPDIR; /tmp in Docker) + Sandbox bool // request sandbox-exec or Docker isolation + Runtime string // "macos" (default) or "docker" + Image string // Docker image + Env []string // complete job environment, KEY=VALUE + Name string // unique Docker container name +} + +// dockerForwardedEnv lists the job variables passed into a container. The +// container keeps the image's own PATH and HOME; values are read by the +// docker client from its environment, so secrets never appear in argv. +func dockerForwardedEnv(env []string) []string { + var names []string + for _, item := range env { + name, _, _ := strings.Cut(item, "=") + switch { + case name == "TRACE_REPOSITORY", name == "TRACE_COMMIT", name == "CI", strings.HasPrefix(name, "TRACE_SECRET_"): + names = append(names, name) + } + } + return names +} + +// buildActionCommand returns the runner command for one workflow step with +// its environment, working directory, and cancellation wired up. Local and +// sandbox-exec steps run in their own process group so cancellation, the +// step timeout, and step completion can stop every process they started. +// Docker steps run in a named container that cancellation kills explicitly, +// because killing the docker client alone leaves the container running. +func buildActionCommand(ctx context.Context, spec actionCommandSpec) (*exec.Cmd, error) { + runtimeName := spec.Runtime + if runtimeName == "" { + runtimeName = "macos" + } + var cmd *exec.Cmd + switch { + case !spec.Sandbox: + cmd = exec.CommandContext(ctx, "sh", "-c", spec.Command) + case runtimeName == "docker": + if strings.TrimSpace(spec.Image) == "" || !commandAvailable("docker") { + return nil, errors.New("Docker sandboxing requires docker and sandbox_image; refusing unsafe fallback") + } + if spec.Name == "" { + return nil, errors.New("Docker sandboxing requires a container name") + } + args := []string{"run", "--rm", "--name", spec.Name, "--network", "none", "--read-only", "--cap-drop", "ALL", "--security-opt", "no-new-privileges", "--pids-limit", "256", "-v", spec.Workspace + ":/workspace:rw"} + if spec.Scratch != "" { + args = append(args, "-v", spec.Scratch+":/tmp:rw") + } + for _, name := range dockerForwardedEnv(spec.Env) { + args = append(args, "-e", name) + } + args = append(args, "-w", "/workspace", spec.Image, "/bin/sh", "-c", spec.Command) + cmd = exec.CommandContext(ctx, "docker", args...) + name := spec.Name + cmd.Cancel = func() error { + killCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + _ = exec.CommandContext(killCtx, "docker", "kill", name).Run() + return cmd.Process.Kill() + } + // The docker client talks to the daemon with the operator's + // environment; the container only sees the variables named above. + cmd.Env = append(os.Environ(), spec.Env...) + case runtimeName == "macos" && runtime.GOOS == "darwin" && commandAvailable("sandbox-exec"): + writable := []string{spec.Workspace} + if spec.Scratch != "" { + writable = append(writable, spec.Scratch) + } + cmd = exec.CommandContext(ctx, "sandbox-exec", "-p", macOSSandboxProfile(writable...), "/bin/sh", "-c", spec.Command) + default: + return nil, errors.New("macOS sandboxing requires sandbox-exec; refusing unsafe fallback") + } + if cmd.Env == nil { + cmd.Env = spec.Env + if cmd.Env == nil { + cmd.Env = []string{} + } + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + cmd.Cancel = func() error { return killProcessGroup(cmd) } + } + cmd.Dir = spec.Workspace + cmd.WaitDelay = actionWaitDelay + return cmd, nil +} + +// actionWaitDelay bounds how long a finished or cancelled step may keep its +// output pipes open through leftover child processes. +const actionWaitDelay = 5 * time.Second + +func killProcessGroup(cmd *exec.Cmd) error { + if cmd.Process == nil || cmd.SysProcAttr == nil || !cmd.SysProcAttr.Setpgid { + return nil + } + if err := syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL); err != nil && !errors.Is(err, syscall.ESRCH) { + return err } - return jobEnv + return nil +} + +// runActionCommand runs a step and then stops anything it left behind in its +// process group, so background processes cannot outlive the step or keep +// running after the 15-minute limit. +func runActionCommand(cmd *exec.Cmd) error { + err := cmd.Run() + _ = killProcessGroup(cmd) + return err } func actionCommand(ctx context.Context, commandText, workspace string, sandbox bool) (*exec.Cmd, error) { - return actionCommandWithSandbox(ctx, commandText, workspace, "", sandbox, "", "") + return buildActionCommand(ctx, actionCommandSpec{Command: commandText, Workspace: workspace, Sandbox: sandbox, Name: "trace-run-adhoc"}) } func commandAvailable(name string) bool { @@ -679,37 +795,6 @@ func commandAvailable(name string) bool { return err == nil } -// actionCommandWithSandbox builds the runner command for one workflow step. -// workspace is the checkout; scratch, when set, is an extra job-writable -// directory used as TMPDIR (mounted at /tmp for Docker). -func actionCommandWithSandbox(ctx context.Context, commandText, workspace, scratch string, sandbox bool, sandboxRuntime, sandboxImage string) (*exec.Cmd, error) { - if !sandbox { - return exec.CommandContext(ctx, "sh", "-c", commandText), nil - } - if sandboxRuntime == "" { - sandboxRuntime = "macos" - } - if sandboxRuntime == "docker" { - if strings.TrimSpace(sandboxImage) == "" || !commandAvailable("docker") { - return nil, errors.New("Docker sandboxing requires docker and sandbox_image; refusing unsafe fallback") - } - args := []string{"run", "--rm", "--network", "none", "--read-only", "--cap-drop", "ALL", "--security-opt", "no-new-privileges", "--pids-limit", "256", "-v", workspace + ":/workspace:rw"} - if scratch != "" { - args = append(args, "-v", scratch+":/tmp:rw") - } - args = append(args, "-w", "/workspace", sandboxImage, "/bin/sh", "-c", commandText) - return exec.CommandContext(ctx, "docker", args...), nil - } - if sandboxRuntime != "macos" || runtime.GOOS != "darwin" || !commandAvailable("sandbox-exec") { - return nil, errors.New("macOS sandboxing requires sandbox-exec; refusing unsafe fallback") - } - writable := []string{workspace} - if scratch != "" { - writable = append(writable, scratch) - } - return exec.CommandContext(ctx, "sandbox-exec", "-p", macOSSandboxProfile(writable...), "/bin/sh", "-c", commandText), nil -} - // macOSSandboxProfile allows the job to read system tool directories and to // read and write only writableDirs. sandbox-exec matches resolved paths, so // symlinks such as /var -> /private/var are resolved first; otherwise every diff --git a/cmd/trace/actions_process_test.go b/cmd/trace/actions_process_test.go new file mode 100644 index 0000000..eb40200 --- /dev/null +++ b/cmd/trace/actions_process_test.go @@ -0,0 +1,112 @@ +package main + +import ( + "errors" + "os" + "path/filepath" + "strconv" + "strings" + "syscall" + "testing" + "time" +) + +func readPID(t *testing.T, path string) int { + t.Helper() + for i := 0; i < 400; i++ { + if b, err := os.ReadFile(path); err == nil { + if pid, convErr := strconv.Atoi(strings.TrimSpace(string(b))); convErr == nil && pid > 0 { + return pid + } + } + time.Sleep(25 * time.Millisecond) + } + t.Fatalf("job never wrote %s", path) + return 0 +} + +func waitProcessGone(t *testing.T, pid int, label string) { + t.Helper() + for i := 0; i < 400; i++ { + if err := syscall.Kill(pid, 0); errors.Is(err, syscall.ESRCH) { + return + } + time.Sleep(25 * time.Millisecond) + } + _ = syscall.Kill(pid, syscall.SIGKILL) + t.Fatalf("%s process %d outlived its workflow step", label, pid) +} + +// TestActionStepsDoNotLeaveProcessesBehind checks that background processes +// started by a local step are stopped when the step finishes (whether or not +// they hold its output open) and when a run is cancelled. +func TestActionStepsDoNotLeaveProcessesBehind(t *testing.T) { + root := filepath.Join(t.TempDir(), "node") + if err := initData(root); err != nil { + t.Fatal(err) + } + a, err := newApp(root) + if err != nil { + t.Fatal(err) + } + a.store.actionsMode = actionsModeTrusted + if err := a.store.createRepo("team/procs", false); err != nil { + t.Fatal(err) + } + pids := t.TempDir() + workflow := `{"name":"procs","jobs":[{"name":"leftovers","run":[` + + `"(sleep 120) >/dev/null 2>&1 & echo $! > ` + pids + `/detached",` + + `"sleep 120 & echo $! > ` + pids + `/attached"` + + `]}]}` + pushWorkflow(t, a, root, "team/procs", workflow) + runs, err := a.store.listActionRuns("team/procs") + if err != nil || len(runs) != 1 { + t.Fatalf("push did not queue one run: %+v %v", runs, err) + } + started := time.Now() + run := waitForActionRunWithin(t, a.store, runs[0].ID, 60*time.Second) + if run.Status != "success" { + t.Fatalf("step that left background processes should still succeed: %+v", run) + } + if elapsed := time.Since(started); elapsed > 60*time.Second { + t.Fatalf("step waited for its background processes: %s", elapsed) + } + waitProcessGone(t, readPID(t, filepath.Join(pids, "detached")), "detached background") + waitProcessGone(t, readPID(t, filepath.Join(pids, "attached")), "attached background") + + if err := a.store.createRepo("team/cancel", false); err != nil { + t.Fatal(err) + } + cancelPIDs := t.TempDir() + pushWorkflow(t, a, root, "team/cancel", `{"name":"cancel","jobs":[{"name":"long","run":["sleep 120 & echo $! > `+cancelPIDs+`/child; sleep 120"]}]}`) + runs, err = a.store.listActionRuns("team/cancel") + if err != nil || len(runs) != 1 { + t.Fatalf("push did not queue one run: %+v %v", runs, err) + } + child := readPID(t, filepath.Join(cancelPIDs, "child")) + if !cancelActionRun(runs[0].ID) { + t.Fatal("cancelActionRun returned false for a running run") + } + run = waitForActionRunWithin(t, a.store, runs[0].ID, 30*time.Second) + if run.Status != "cancelled" { + t.Fatalf("cancelled run status: %+v", run) + } + waitProcessGone(t, child, "cancelled step's background") +} + +func waitForActionRunWithin(t *testing.T, s *store, id int, limit time.Duration) actionRun { + t.Helper() + deadline := time.Now().Add(limit) + for time.Now().Before(deadline) { + run, err := actionRunByID(s, id) + if err != nil { + t.Fatal(err) + } + if run.Status != "queued" && run.Status != "running" { + return run + } + time.Sleep(25 * time.Millisecond) + } + t.Fatalf("action run %d did not finish within %s", id, limit) + return actionRun{} +} diff --git a/cmd/trace/actions_test.go b/cmd/trace/actions_test.go index a7c8fa1..77d9249 100644 --- a/cmd/trace/actions_test.go +++ b/cmd/trace/actions_test.go @@ -53,16 +53,26 @@ func TestDockerSandboxCommandShape(t *testing.T) { if !commandAvailable("docker") { t.Skip("docker client is not installed") } - cmd, err := actionCommandWithSandbox(context.Background(), "printf ok", t.TempDir(), "", true, "docker", "alpine:3.20") + env := actionJobEnv("team/ci", "abc123", "/work/src", "/work/tmp", map[string]string{"TRACE_SECRET_DEPLOY": "super-secret-value"}) + cmd, err := buildActionCommand(context.Background(), actionCommandSpec{Command: "printf ok", Workspace: t.TempDir(), Scratch: t.TempDir(), Sandbox: true, Runtime: "docker", Image: "alpine:3.20", Env: env, Name: "trace-run-7-1-1"}) if err != nil { t.Fatal(err) } joined := strings.Join(cmd.Args, " ") - for _, expected := range []string{"--network none", "--read-only", "--cap-drop ALL", "--security-opt no-new-privileges", "alpine:3.20"} { + for _, expected := range []string{"--network none", "--read-only", "--cap-drop ALL", "--security-opt no-new-privileges", "alpine:3.20", "--name trace-run-7-1-1", "-e TRACE_REPOSITORY", "-e TRACE_COMMIT", "-e CI", "-e TRACE_SECRET_DEPLOY", ":/tmp:rw"} { if !strings.Contains(joined, expected) { t.Fatalf("docker sandbox missing %q: %s", expected, joined) } } + if strings.Contains(joined, "super-secret-value") || strings.Contains(joined, "-e PATH") || strings.Contains(joined, "-e HOME") { + t.Fatalf("docker command line leaks values or host paths: %s", joined) + } + if !containsString(cmd.Env, "TRACE_SECRET_DEPLOY=super-secret-value") || !containsString(cmd.Env, "TRACE_COMMIT=abc123") { + t.Fatal("docker client environment does not carry the forwarded job variables") + } + if cmd.Cancel == nil { + t.Fatal("docker steps need a cancel hook that kills the container") + } } func TestLocalActionRunAndArtifact(t *testing.T) { From 9d0c14b57f3f6e86bf12e58bee7eca525dc2ece8 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 27 Sep 2026 03:51:50 +0530 Subject: [PATCH 08/30] perf(actions): keep job logs out of actions.json and prune old runs actions.json embedded up to 1 MiB of log per job and was never pruned. Every run list, every merge-policy check (requiredChecksPass) and every run update deserialized and rewrote the whole file under one global mutex, so cost grew with every run ever executed. - Job logs are written to data/action-logs/RUN/JOB.log; logs that older versions stored inline are moved out on the next run update. - Each repository keeps its 200 most recent finished runs; queued and running runs are never pruned, and pruned runs' logs and artifacts are deleted. - The run list API returns summaries; GET .../actions/runs/ID and the actions page (20 most recent runs) load logs from disk. The actions page also referenced {{$.ID}} for artifact links, a field the page data does not have, so rendering a run with artifacts failed mid-page; the links now use the run's ID. Finding: F096 --- README.md | 2 +- cmd/trace/actions.go | 114 +++++++++++++++++++++++- cmd/trace/actions_history_test.go | 139 ++++++++++++++++++++++++++++++ cmd/trace/web.go | 13 ++- 4 files changed, 264 insertions(+), 4 deletions(-) create mode 100644 cmd/trace/actions_history_test.go diff --git a/README.md b/README.md index c2e17b1..c20c68f 100644 --- a/README.md +++ b/README.md @@ -411,7 +411,7 @@ Queued or running jobs can be cancelled with `POST /api/v1/repos/OWNER/NAME/acti Cancellation and the 15-minute step limit stop the whole step and record the result. Local and `sandbox-exec` steps run in their own process group, which Trace kills on cancellation, on timeout, and when the step exits, so background processes do not outlive it (a process that deliberately leaves the group with `setsid` is not tracked). Docker steps run in a container named `trace-run-RUN-JOB-STEP`, which Trace stops with `docker kill`. Each step's captured output is capped at the 1 MiB log limit. The runner is still local process execution, not a hardened container or VM sandbox. -The runner checks out the requested commit, limits each command to 15 minutes and each log to 1 MiB, and stores matching artifacts under `data/artifacts`. A node runs at most four jobs concurrently; additional runs remain queued and can be cancelled. Workflows without `"sandbox":true` run only under `-actions trusted` and then execute with the Trace service account. On macOS, `"sandbox":true` uses `sandbox-exec` with a restricted filesystem and no network access. For portable isolation, set `"sandbox_runtime":"docker"` and an explicit `"sandbox_image"`; Trace runs Docker with no network, a read-only root, dropped capabilities, no-new-privileges, a process limit, and only the checked-out workspace (`/workspace`) and a per-run scratch directory (`/tmp`) writable; `TRACE_REPOSITORY`, `TRACE_COMMIT`, `CI`, and `TRACE_SECRET_*` are passed into the container by name. Trace refuses to fall back to unsandboxed execution when sandboxing is requested. Docker still depends on the host daemon and image supply chain. +The runner checks out the requested commit, limits each command to 15 minutes and each log to 1 MiB, stores job logs under `data/action-logs`, and stores matching artifacts under `data/artifacts`. Each repository keeps its 200 most recent finished runs; older runs are pruned together with their logs and artifacts, so required checks must pass on a run that is still retained. The run list API returns summaries; `GET /api/v1/repos/OWNER/NAME/actions/runs/ID` returns one run with its logs, and the actions page shows the 20 most recent runs. A node runs at most four jobs concurrently; additional runs remain queued and can be cancelled. Workflows without `"sandbox":true` run only under `-actions trusted` and then execute with the Trace service account. On macOS, `"sandbox":true` uses `sandbox-exec` with a restricted filesystem and no network access. For portable isolation, set `"sandbox_runtime":"docker"` and an explicit `"sandbox_image"`; Trace runs Docker with no network, a read-only root, dropped capabilities, no-new-privileges, a process limit, and only the checked-out workspace (`/workspace`) and a per-run scratch directory (`/tmp`) writable; `TRACE_REPOSITORY`, `TRACE_COMMIT`, `CI`, and `TRACE_SECRET_*` are passed into the container by name. Trace refuses to fall back to unsandboxed execution when sandboxing is requested. Docker still depends on the host daemon and image supply chain. Repository-scoped CI secrets can be managed through the dashboard, API, or CLI. Secret values are stored in `data/secrets.json` with owner-only permissions and are injected only into jobs as `TRACE_SECRET_` environment variables; list operations return names, never values. A job's environment contains only `PATH`, `HOME` (the checkout), `TMPDIR` (a per-run scratch directory), `LANG`/`LC_ALL` when set, `TRACE_REPOSITORY`, `TRACE_COMMIT`, `CI=true`, and the repository's `TRACE_SECRET_*` values; the Trace service environment is never passed to jobs: diff --git a/cmd/trace/actions.go b/cmd/trace/actions.go index 1c7ec09..b8ecea2 100644 --- a/cmd/trace/actions.go +++ b/cmd/trace/actions.go @@ -893,6 +893,102 @@ func copyActionArtifact(ws *os.Root, root string, runID, jobID int, rel string) return actionArtifact{Name: name, Size: written, Path: dest}, true } +// Job logs live in data/action-logs/RUN/JOB.log rather than in actions.json, +// and each repository keeps at most maxActionRunsPerRepo finished runs, so +// listing runs and checking merge policies stay cheap as history grows. +const maxActionRunsPerRepo = 200 + +func (s *store) actionLogPath(runID, jobID int) string { + return filepath.Join(s.root, "action-logs", strconv.Itoa(runID), strconv.Itoa(jobID)+".log") +} + +func (s *store) writeActionLog(runID, jobID int, text string) error { + path := s.actionLogPath(runID, jobID) + if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { + return err + } + tmp, err := os.CreateTemp(filepath.Dir(path), ".log-*") + if err != nil { + return err + } + name := tmp.Name() + defer os.Remove(name) + if _, err := tmp.WriteString(text); err != nil { + tmp.Close() + return err + } + if err := tmp.Close(); err != nil { + return err + } + return os.Rename(name, path) +} + +// externalizeActionLogs moves job logs out of the run database, including +// logs that older Trace versions stored inline. +func (s *store) externalizeActionLogs(db *actionDB) { + for i := range db.Runs { + for j := range db.Runs[i].Jobs { + job := &db.Runs[i].Jobs[j] + if job.Log == "" { + continue + } + if err := s.writeActionLog(db.Runs[i].ID, job.ID, job.Log); err == nil { + job.Log = "" + } + } + } +} + +// hydrateActionLogs loads job logs for a run that is about to be displayed. +func (s *store) hydrateActionLogs(run *actionRun) { + for j := range run.Jobs { + if run.Jobs[j].Log != "" { + continue + } + f, err := os.Open(s.actionLogPath(run.ID, run.Jobs[j].ID)) + if err != nil { + continue + } + b, _ := io.ReadAll(io.LimitReader(f, maxActionLog+4096)) + _ = f.Close() + run.Jobs[j].Log = string(b) + } +} + +// pruneActionRuns drops the oldest finished runs of each repository beyond +// maxActionRunsPerRepo and returns their IDs. Queued and running runs are +// always kept. +func pruneActionRuns(db *actionDB) []int { + finished := map[string]int{} + for _, run := range db.Runs { + if run.Status != "queued" && run.Status != "running" { + finished[run.Repo]++ + } + } + excess := map[string]int{} + for repo, count := range finished { + if count > maxActionRunsPerRepo { + excess[repo] = count - maxActionRunsPerRepo + } + } + if len(excess) == 0 { + return nil + } + sort.SliceStable(db.Runs, func(i, j int) bool { return db.Runs[i].ID < db.Runs[j].ID }) + kept := db.Runs[:0] + var removed []int + for _, run := range db.Runs { + if excess[run.Repo] > 0 && run.Status != "queued" && run.Status != "running" { + excess[run.Repo]-- + removed = append(removed, run.ID) + continue + } + kept = append(kept, run) + } + db.Runs = kept + return removed +} + func (s *store) finishActionRun(id int, status string, jobs []actionJob) { actionRunMu.Lock() defer actionRunMu.Unlock() @@ -900,15 +996,28 @@ func (s *store) finishActionRun(id int, status string, jobs []actionJob) { if err != nil { return } + found := false for i := range db.Runs { if db.Runs[i].ID == id { db.Runs[i].Status = status db.Runs[i].Jobs = jobs db.Runs[i].FinishedAt = time.Now().UTC() - _ = s.saveActions(db) - return + found = true + break } } + if !found { + return + } + s.externalizeActionLogs(&db) + removed := pruneActionRuns(&db) + if err := s.saveActions(db); err != nil { + return + } + for _, runID := range removed { + _ = os.RemoveAll(filepath.Join(s.root, "action-logs", strconv.Itoa(runID))) + _ = os.RemoveAll(filepath.Join(s.root, "artifacts", strconv.Itoa(runID))) + } } func actionRunByID(s *store, id int) (actionRun, error) { @@ -921,6 +1030,7 @@ func actionRunByID(s *store, id int) (actionRun, error) { for _, run := range db.Runs { if run.ID == id { hydrateActionPaths(s, &run) + s.hydrateActionLogs(&run) return run, nil } } diff --git a/cmd/trace/actions_history_test.go b/cmd/trace/actions_history_test.go new file mode 100644 index 0000000..e9a74bc --- /dev/null +++ b/cmd/trace/actions_history_test.go @@ -0,0 +1,139 @@ +package main + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strconv" + "strings" + "testing" + "time" +) + +// TestActionLogsLiveOutsideTheRunDatabase checks that job logs are stored per +// run instead of inside actions.json, that API list responses stay small, and +// that the single-run API and the actions page still show the log and a +// working artifact link. +func TestActionLogsLiveOutsideTheRunDatabase(t *testing.T) { + root := filepath.Join(t.TempDir(), "node") + if err := initData(root); err != nil { + t.Fatal(err) + } + a, err := newApp(root) + if err != nil { + t.Fatal(err) + } + a.store.actionsMode = actionsModeTrusted + if err := a.store.createRepo("team/logs", false); err != nil { + t.Fatal(err) + } + pushWorkflow(t, a, root, "team/logs", `{"name":"logs","jobs":[{"name":"build","run":["echo distinctive-log-line","printf ok > out.txt"],"artifacts":["out.txt"]}]}`) + runs, err := a.store.listActionRuns("team/logs") + if err != nil || len(runs) != 1 { + t.Fatalf("push did not queue one run: %+v %v", runs, err) + } + run := waitForActionRun(t, a.store, runs[0].ID) + if run.Status != "success" || !strings.Contains(run.Jobs[0].Log, "distinctive-log-line") { + t.Fatalf("single-run lookup lost the job log: %+v", run) + } + raw, err := os.ReadFile(filepath.Join(root, "actions.json")) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(raw), "distinctive-log-line") { + t.Fatal("job log is still embedded in actions.json") + } + token := adminToken(t, root) + list := httptest.NewRequest(http.MethodGet, "/api/v1/repos/team/logs/actions/runs", nil) + list.SetBasicAuth("admin", token) + listRes := httptest.NewRecorder() + a.ServeHTTP(listRes, list) + if listRes.Code != http.StatusOK || strings.Contains(listRes.Body.String(), "distinctive-log-line") { + t.Fatalf("run list should be a summary: %d %s", listRes.Code, listRes.Body.String()) + } + one := httptest.NewRequest(http.MethodGet, "/api/v1/repos/team/logs/actions/runs/"+strconv.Itoa(run.ID), nil) + one.SetBasicAuth("admin", token) + oneRes := httptest.NewRecorder() + a.ServeHTTP(oneRes, one) + if oneRes.Code != http.StatusOK || !strings.Contains(oneRes.Body.String(), "distinctive-log-line") { + t.Fatalf("single-run API lost the log: %d %s", oneRes.Code, oneRes.Body.String()) + } + page := httptest.NewRequest(http.MethodGet, "/repos/team/logs/actions", nil) + page.SetBasicAuth("admin", token) + pageRes := httptest.NewRecorder() + a.ServeHTTP(pageRes, page) + artifactLink := "/api/v1/repos/team/logs/actions/runs/" + strconv.Itoa(run.ID) + "/artifacts/out.txt" + if pageRes.Code != http.StatusOK || !strings.Contains(pageRes.Body.String(), "distinctive-log-line") || !strings.Contains(pageRes.Body.String(), artifactLink) || !strings.HasSuffix(strings.TrimSpace(pageRes.Body.String()), "") { + t.Fatalf("actions page is missing the log or artifact link:\n%s", pageRes.Body.String()) + } +} + +func TestActionHistoryMigratesInlineLogsAndPrunes(t *testing.T) { + root := t.TempDir() + s := &store{root: root, repos: filepath.Join(root, "repos")} + now := time.Now().UTC() + db := actionDB{NextRunID: 1, LastScheduledAt: map[string]time.Time{}} + total := maxActionRunsPerRepo + 5 + for i := 1; i <= total; i++ { + run := actionRun{ID: i, Repo: "team/busy", Ref: "main", Commit: "c", Status: "success", CreatedAt: now, Jobs: []actionJob{{ID: 1, Name: "test", Status: "success", Log: "legacy inline log " + strconv.Itoa(i)}}} + if i == 2 { + run.Status = "running" + } + db.Runs = append(db.Runs, run) + if err := os.MkdirAll(filepath.Join(root, "artifacts", strconv.Itoa(i), "1"), 0700); err != nil { + t.Fatal(err) + } + } + db.Runs = append(db.Runs, actionRun{ID: total + 1, Repo: "team/quiet", Status: "queued", CreatedAt: now}) + db.NextRunID = total + 2 + if err := s.saveActions(db); err != nil { + t.Fatal(err) + } + s.finishActionRun(total+1, "success", []actionJob{{ID: 1, Name: "test", Status: "success", Log: "fresh log"}}) + + loaded, err := s.loadActions() + if err != nil { + t.Fatal(err) + } + busy := 0 + ids := map[int]bool{} + for _, run := range loaded.Runs { + ids[run.ID] = true + if run.Repo == "team/busy" { + busy++ + } + for _, job := range run.Jobs { + if job.Log != "" { + t.Fatalf("run %d still stores its log inline", run.ID) + } + } + } + // 200 finished runs are kept plus the still-running run #2. + if busy != maxActionRunsPerRepo+1 || !ids[2] || ids[1] || ids[3] || !ids[6] || !ids[total] || !ids[total+1] { + t.Fatalf("unexpected pruning result: busy=%d ids=%v", busy, ids) + } + // 204 finished runs exceed the cap by four: the oldest finished ones go. + for _, gone := range []int{1, 3, 4, 5} { + if _, err := os.Stat(filepath.Join(root, "artifacts", strconv.Itoa(gone))); !os.IsNotExist(err) { + t.Fatalf("artifacts of pruned run %d remain: %v", gone, err) + } + } + if _, err := os.Stat(filepath.Join(root, "artifacts", "6")); err != nil { + t.Fatalf("artifacts of a kept run were removed: %v", err) + } + run, err := actionRunByID(s, total) + if err != nil || run.Jobs[0].Log != "legacy inline log "+strconv.Itoa(total) { + t.Fatalf("migrated log not readable: %+v %v", run, err) + } + fresh, err := actionRunByID(s, total+1) + if err != nil || fresh.Jobs[0].Log != "fresh log" { + t.Fatalf("fresh log not readable: %+v %v", fresh, err) + } + var check map[string]any + raw, _ := os.ReadFile(filepath.Join(root, "actions.json")) + if err := json.Unmarshal(raw, &check); err != nil || strings.Contains(string(raw), "inline log") { + t.Fatalf("actions.json still carries logs or is invalid: %v", err) + } +} diff --git a/cmd/trace/web.go b/cmd/trace/web.go index a37ff68..30b520d 100644 --- a/cmd/trace/web.go +++ b/cmd/trace/web.go @@ -366,6 +366,9 @@ type repoData struct { Topics []string } +// actionPageRuns is how many recent runs the actions page renders. +const actionPageRuns = 20 + type actionPageData struct { Name string CSRF string @@ -438,7 +441,7 @@ var repoHTML string var repoTemplate = template.Must(template.New("repo").Parse(strings.Replace(repoHTML, "", pageStyle+repoPageStyle, 1))) -var actionPageTemplate = template.Must(template.New("actions").Parse(`Actions · {{.Name}} · Trace` + pageStyle + `

Actions

Runs execute on this Trace node from .trace/workflow.json. Secrets are exposed to jobs as TRACE_SECRET_NAME and never displayed here.

{{if .CanWrite}}

CI secrets

{{if .Secrets}}{{range .Secrets}}{{.}} {{end}}{{else}}No secrets configured.{{end}}

Run workflow

{{end}}

Recent runs

{{range .Runs}}

#{{.ID}} · {{.Status}}

{{.Ref}} · {{.Commit}} · {{.TriggeredBy}} · {{.CreatedAt}}

{{range .Jobs}}

{{.Name}}: {{.Status}} (exit {{.ExitCode}})

{{if .Log}}
{{.Log}}
{{end}}{{range .Artifacts}}Download {{.Name}}{{end}}{{end}}
{{else}}

No runs yet.

{{end}}
`)) +var actionPageTemplate = template.Must(template.New("actions").Parse(`Actions · {{.Name}} · Trace` + pageStyle + `

Actions

Runs execute on this Trace node from .trace/workflow.json. Secrets are exposed to jobs as TRACE_SECRET_NAME and never displayed here.

{{if .CanWrite}}

CI secrets

{{if .Secrets}}{{range .Secrets}}{{.}} {{end}}{{else}}No secrets configured.{{end}}

Run workflow

{{end}}

Recent runs

{{range $run := .Runs}}

#{{$run.ID}} · {{$run.Status}}

{{$run.Ref}} · {{$run.Commit}} · {{$run.TriggeredBy}} · {{$run.CreatedAt}}

{{range $run.Jobs}}

{{.Name}}: {{.Status}} (exit {{.ExitCode}})

{{if .Log}}
{{.Log}}
{{end}}{{range .Artifacts}}Download {{.Name}}{{end}}{{end}}
{{else}}

No runs yet.

{{end}}
`)) type cappedBuffer struct { bytes.Buffer @@ -695,6 +698,14 @@ func (a *app) actionPage(w http.ResponseWriter, r *http.Request, username string http.Error(w, "cannot list action runs", http.StatusInternalServerError) return } + // Show the most recent runs with their logs; older runs remain available + // through GET /api/v1/repos/OWNER/NAME/actions/runs/ID. + if len(runs) > actionPageRuns { + runs = runs[:actionPageRuns] + } + for i := range runs { + a.store.hydrateActionLogs(&runs[i]) + } var secrets []string if u.canWrite(name) { secrets, err = a.store.listSecretNames(name) From 0ab11fdcb8b12113ac925622406974ff806eec82 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 27 Sep 2026 03:56:47 +0530 Subject: [PATCH 09/30] fix(hooks): stop protected-branch patterns from injecting shell code Protected-branch patterns were concatenated unescaped into the generated sh pre-receive hook, and validation only rejected a few characters (space, ~^:?[]\). A payload such as main)exit${IFS}0;;esac;touch${IFS}pwned;case${IFS}x${IFS}in(x passed validation and ran on every push as the Trace service account, giving any forge administrator (or leaked admin token) host code execution and a silent way to disable branch protection. - normalizePolicy accepts only [A-Za-z0-9][A-Za-z0-9._/-]* with an optional trailing *, or *, and rejects "..", "//" and refs/ prefixes. - receiveHook single-quotes every pattern's literal part, leaving only the trailing wildcard unquoted, so even an unvalidated pattern cannot execute. Tests run the generated hook with sh: injection payloads are rejected by validation and do not execute even when fed to receiveHook directly, and main, release/*, tags and refs/trace stay protected for non-admins. Finding: F080 --- README.md | 2 +- cmd/trace/hooks.go | 18 +++++++-- cmd/trace/hooks_test.go | 89 +++++++++++++++++++++++++++++++++++++++++ cmd/trace/policy.go | 17 ++++++-- 4 files changed, 119 insertions(+), 7 deletions(-) create mode 100644 cmd/trace/hooks_test.go diff --git a/README.md b/README.md index c20c68f..6c3ef80 100644 --- a/README.md +++ b/README.md @@ -696,7 +696,7 @@ Admins can configure merge requirements through the API or CLI. The default requ -token-file ./data/admin-token team/project ``` -Required checks match successful action job names for the pull-request head commit. A run from a different commit does not satisfy the policy. Protected branch patterns are enforced by the Git receive hook for non-admin pushes; `*` is supported as a trailing wildcard, for example `release/*`. Administrators can still perform emergency updates. +Required checks match successful action job names for the pull-request head commit. A run from a different commit does not satisfy the policy. Protected branch patterns are enforced by the Git receive hook for non-admin pushes; `*` is supported as a trailing wildcard, for example `release/*`. Patterns may contain only letters, digits, `.`, `_`, `-`, and `/`, must start with a letter or digit, and are quoted when Trace writes the hook. Administrators can still perform emergency updates. When `-require-codeowners` is enabled, Trace reads `CODEOWNERS` from the pull-request head (`CODEOWNERS`, `.github/CODEOWNERS`, `.gitlab/CODEOWNERS`, or `docs/CODEOWNERS`) and requires an approval from a matching user or team member for every changed file covered by the last matching rule. Pattern matching supports repository-relative paths, filename patterns, trailing directory patterns, and recursive `**` path segments. CODEOWNERS syntax outside this subset is ignored. diff --git a/cmd/trace/hooks.go b/cmd/trace/hooks.go index 923171c..ca71dc8 100644 --- a/cmd/trace/hooks.go +++ b/cmd/trace/hooks.go @@ -11,15 +11,27 @@ import ( const receiveHookMarker = "# trace-managed:" +// shellCasePattern renders "prefix+pattern" as a POSIX sh case pattern. The +// literal part is single-quoted so no character in it is interpreted by the +// shell; only a trailing "*" (Trace's one wildcard form) stays unquoted. +func shellCasePattern(prefix, pattern string) string { + literal := prefix + strings.TrimSuffix(pattern, "*") + quoted := "'" + strings.ReplaceAll(literal, "'", `'\''`) + "'" + if strings.HasSuffix(pattern, "*") { + quoted += "*" + } + return quoted +} + func receiveHook(policy repoPolicy) string { patterns := make([]string, 0, len(policy.ProtectedBranches)) for _, branch := range policy.ProtectedBranches { - patterns = append(patterns, "refs/heads/"+branch) + patterns = append(patterns, shellCasePattern("refs/heads/", branch)) } if len(patterns) == 0 { - patterns = []string{"refs/heads/main"} + patterns = []string{shellCasePattern("refs/heads/", "main")} } - patterns = append(patterns, "refs/tags/*", "refs/trace/*") + patterns = append(patterns, shellCasePattern("refs/tags/", "*"), shellCasePattern("refs/trace/", "*")) return "#!/bin/sh\n# trace-managed: configurable protected branches and tags\nif [ \"$TRACE_ADMIN\" = \"1\" ]; then\n exit 0\nfi\nwhile read old new ref; do\n case \"$ref\" in\n " + strings.Join(patterns, "|") + ")\n echo \"Trace: only an administrator may update $ref\" >&2\n exit 1 ;;\n refs/heads/*) ;;\n *)\n echo \"Trace: unsupported ref $ref\" >&2\n exit 1 ;;\n esac\ndone\nexit 0\n" } diff --git a/cmd/trace/hooks_test.go b/cmd/trace/hooks_test.go new file mode 100644 index 0000000..32fa6cf --- /dev/null +++ b/cmd/trace/hooks_test.go @@ -0,0 +1,89 @@ +package main + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +// runReceiveHook feeds one ref update to a generated pre-receive hook. +func runReceiveHook(t *testing.T, dir, hook, ref string, admin bool) (string, error) { + t.Helper() + path := filepath.Join(t.TempDir(), "pre-receive") + if err := os.WriteFile(path, []byte(hook), 0700); err != nil { + t.Fatal(err) + } + cmd := exec.Command(path) + cmd.Dir = dir + cmd.Stdin = strings.NewReader("0000000000000000000000000000000000000000 1111111111111111111111111111111111111111 " + ref + "\n") + cmd.Env = []string{"PATH=/usr/bin:/bin", "TRACE_ADMIN=0"} + if admin { + cmd.Env[1] = "TRACE_ADMIN=1" + } + out, err := cmd.CombinedOutput() + return string(out), err +} + +func TestProtectedBranchPatternsCannotInjectShell(t *testing.T) { + // The hook runs in dir; payloads create the relative file "pwned" there + // (a short name keeps them within the 100-character pattern limit). + dir := t.TempDir() + marker := "pwned" + injections := []string{ + // No spaces: the pre-fix validator accepted this and the hook ran it. + "main)exit${IFS}0;;esac;touch${IFS}" + marker + ";case${IFS}x${IFS}in(x", + "main) exit 0 ;; esac; touch " + marker + "; case x in (x", + "main|refs/heads/*", + "release/$(touch " + marker + ")", + "release/`touch " + marker + "`", + "main;touch " + marker, + "re'lease", + "../main", + "release//x", + "-main", + "release/*/x", + } + for _, pattern := range injections { + if _, err := normalizePolicy(repoPolicy{ProtectedBranches: []string{pattern}}); err == nil { + t.Fatalf("protected branch pattern %q was accepted", pattern) + } + // Even a policy that bypassed validation must not execute as shell. + hook := receiveHook(repoPolicy{ProtectedBranches: []string{pattern}}) + out, err := runReceiveHook(t, dir, hook, "refs/heads/feature", false) + if _, statErr := os.Stat(filepath.Join(dir, marker)); statErr == nil { + t.Fatalf("pattern %q executed shell code (hook output %q, err %v)", pattern, out, err) + } + } + for _, pattern := range []string{"main", "release/*", "*", "v1.2_x-y", "team/feature"} { + if _, err := normalizePolicy(repoPolicy{ProtectedBranches: []string{pattern}}); err != nil { + t.Fatalf("valid pattern %q rejected: %v", pattern, err) + } + } +} + +func TestReceiveHookEnforcesQuotedPatterns(t *testing.T) { + hook := receiveHook(repoPolicy{ProtectedBranches: []string{"main", "release/*"}}) + for ref, allowed := range map[string]bool{ + "refs/heads/main": false, + "refs/heads/release/v1": false, + "refs/heads/feature/x": true, + "refs/heads/mainline": true, + "refs/tags/v1": false, + "refs/trace/agents/node": false, + "refs/notes/commits": false, + } { + _, err := runReceiveHook(t, t.TempDir(), hook, ref, false) + if (err == nil) != allowed { + t.Fatalf("non-admin update of %s: allowed=%v, want %v", ref, err == nil, allowed) + } + if _, err := runReceiveHook(t, t.TempDir(), hook, ref, true); err != nil { + t.Fatalf("admin update of %s was refused: %v", ref, err) + } + } + everything := receiveHook(repoPolicy{ProtectedBranches: []string{"*"}}) + if _, err := runReceiveHook(t, t.TempDir(), everything, "refs/heads/anything", false); err == nil { + t.Fatal("wildcard protection allowed a non-admin branch update") + } +} diff --git a/cmd/trace/policy.go b/cmd/trace/policy.go index fd8a9f4..4863c8f 100644 --- a/cmd/trace/policy.go +++ b/cmd/trace/policy.go @@ -6,6 +6,7 @@ import ( "fmt" "os" "path/filepath" + "regexp" "sort" "strings" "syscall" @@ -91,6 +92,16 @@ func (s *store) updatePolicies(change func(*policyDB) error) error { return os.Rename(name, filepath.Join(s.root, policyFile)) } +// protectedBranchPattern is deliberately narrower than Git's ref-name rules: +// patterns end up in a generated shell hook, so only characters with no +// shell meaning are allowed, plus a single trailing "*". +var protectedBranchPattern = regexp.MustCompile(`^(\*|[A-Za-z0-9][A-Za-z0-9._/-]*\*?)$`) + +func validProtectedBranchPattern(branch string) bool { + return len(branch) <= 100 && protectedBranchPattern.MatchString(branch) && + !strings.Contains(branch, "..") && !strings.Contains(branch, "//") && !strings.HasPrefix(branch, "refs/") +} + func normalizePolicy(policy repoPolicy) (repoPolicy, error) { if policy.RequiredApprovals < 0 || policy.RequiredApprovals > 100 { return repoPolicy{}, errors.New("required_approvals must be between 0 and 100") @@ -119,12 +130,12 @@ func normalizePolicy(policy repoPolicy) (repoPolicy, error) { branches := make([]string, 0, len(policy.ProtectedBranches)) for _, branch := range policy.ProtectedBranches { branch = strings.TrimSpace(branch) - if branch == "" || len(branch) > 100 || strings.HasPrefix(branch, "refs/") || strings.ContainsAny(branch, " ~^:?[]\\") { - return repoPolicy{}, errors.New("protected branch patterns must be valid branch names and may use * as a wildcard") - } if strings.Contains(branch, "*") && branch != "*" && !strings.HasSuffix(branch, "*") { return repoPolicy{}, errors.New("protected branch wildcards must appear at the end of a pattern") } + if !validProtectedBranchPattern(branch) { + return repoPolicy{}, errors.New("protected branch patterns may use letters, digits, '.', '_', '-', and '/' (starting with a letter or digit) and an optional trailing * wildcard") + } if !seen[branch] { seen[branch] = true branches = append(branches, branch) From 30bdbc4dc47de29475e6e7dbfacfd1b002812909 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 27 Sep 2026 03:57:38 +0530 Subject: [PATCH 10/30] fix(hooks): keep configured branch protection across server restarts ensureHooks, which `trace serve` runs at every start, reinstalled each repository's pre-receive hook with the default policy (main only). After any restart, protected-branch patterns configured through the API or the policy page (for example release/*) silently stopped being enforced, while policies.json and the UI still showed them. Install each hook from the repository's stored policy. If a stored policy no longer validates (for example a pattern saved before the stricter pattern rules), log it and fail closed by protecting every branch until an administrator saves a valid policy. Found while fixing F080. --- cmd/trace/hooks.go | 11 ++++++++- cmd/trace/hooks_test.go | 50 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 1 deletion(-) diff --git a/cmd/trace/hooks.go b/cmd/trace/hooks.go index ca71dc8..3a96446 100644 --- a/cmd/trace/hooks.go +++ b/cmd/trace/hooks.go @@ -4,6 +4,7 @@ import ( "bytes" "errors" "fmt" + "log" "os" "path/filepath" "strings" @@ -80,7 +81,15 @@ func (s *store) ensureHooks() error { for _, repo := range repos { name := strings.TrimSuffix(repo.Name(), ".git") if repo.IsDir() && strings.HasSuffix(repo.Name(), ".git") && namePattern.MatchString(name) { - if err := installHook(filepath.Join(s.repos, owner.Name(), repo.Name())); err != nil { + fullName := owner.Name() + "/" + name + policy, err := s.repoPolicy(fullName) + if err != nil { + // Fail closed: protect every branch until an administrator + // stores a valid policy again. + log.Printf("trace: invalid branch policy for %s (%v); protecting all branches until it is fixed", fullName, err) + policy = repoPolicy{ProtectedBranches: []string{"*"}} + } + if err := installHookWithPolicy(filepath.Join(s.repos, owner.Name(), repo.Name()), policy); err != nil { return err } } diff --git a/cmd/trace/hooks_test.go b/cmd/trace/hooks_test.go index 32fa6cf..caf85e7 100644 --- a/cmd/trace/hooks_test.go +++ b/cmd/trace/hooks_test.go @@ -87,3 +87,53 @@ func TestReceiveHookEnforcesQuotedPatterns(t *testing.T) { t.Fatal("wildcard protection allowed a non-admin branch update") } } + +// TestServerStartKeepsConfiguredBranchProtection covers ensureHooks, which +// `trace serve` runs at startup: it must reinstall each repository's hook +// from its stored policy instead of the default (main only), and it must +// fail closed when a stored policy is no longer valid. +func TestServerStartKeepsConfiguredBranchProtection(t *testing.T) { + root := t.TempDir() + if err := initData(root); err != nil { + t.Fatal(err) + } + s, err := openStore(root) + if err != nil { + t.Fatal(err) + } + for _, name := range []string{"team/configured", "team/tampered"} { + if err := s.createRepo(name, false); err != nil { + t.Fatal(err) + } + } + if _, err := s.setRepoPolicy("team/configured", repoPolicy{RequiredApprovals: 1, ProtectedBranches: []string{"main", "release/*"}}); err != nil { + t.Fatal(err) + } + // A policy stored by an older version with a pattern that is now invalid. + if err := s.updatePolicies(func(db *policyDB) error { + db.Repos["team/tampered"] = repoPolicy{ProtectedBranches: []string{"main)exit${IFS}0;;esac;case${IFS}x${IFS}in(x"}} + return nil + }); err != nil { + t.Fatal(err) + } + if err := s.ensureHooks(); err != nil { + t.Fatal(err) + } + hookFor := func(name string) string { + path, _ := s.repoPath(name) + b, err := os.ReadFile(filepath.Join(path, "hooks", "pre-receive")) + if err != nil { + t.Fatal(err) + } + return string(b) + } + if _, err := runReceiveHook(t, t.TempDir(), hookFor("team/configured"), "refs/heads/release/v1", false); err == nil { + t.Fatal("configured release/* protection was dropped at server start") + } + if _, err := runReceiveHook(t, t.TempDir(), hookFor("team/configured"), "refs/heads/feature", false); err != nil { + t.Fatalf("feature branch unexpectedly protected: %v", err) + } + if _, err := runReceiveHook(t, t.TempDir(), hookFor("team/tampered"), "refs/heads/feature", false); err == nil { + t.Fatal("an invalid stored policy must fail closed and protect every branch") + } +} From 8ae3139639a849b67dab801bd6eb35d1f2312883 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 27 Sep 2026 03:58:45 +0530 Subject: [PATCH 11/30] fix(ssh): reject pushes to archived repositories The SSH transport gated git-receive-pack only on mirror status and the managed hook, while the HTTP transport also refuses archived repositories. Any writer with an SSH key could keep pushing to an archived repository, contradicting the documented "archived repositories reject Git writes". Apply the same receive-pack checks as HTTP (mirror, archived, managed hook) with a specific refusal message. The test pushes over a real ssh client before and after archiving. Finding: F082 --- cmd/trace/ssh.go | 19 +++++- cmd/trace/ssh_push_test.go | 132 +++++++++++++++++++++++++++++++++++++ 2 files changed, 150 insertions(+), 1 deletion(-) create mode 100644 cmd/trace/ssh_push_test.go diff --git a/cmd/trace/ssh.go b/cmd/trace/ssh.go index 421577a..d9ae5dd 100644 --- a/cmd/trace/ssh.go +++ b/cmd/trace/ssh.go @@ -247,10 +247,27 @@ func handleSSHSession(s *store, permissions *ssh.Permissions, ch ssh.Channel, re _, _ = io.WriteString(ch.Stderr(), "Trace: repository is unavailable\n") return } - if _, err := os.Stat(path); err != nil || (service == "git-receive-pack" && (isMirror(path) || !hasManagedHook(path))) { + if _, err := os.Stat(path); err != nil { _, _ = io.WriteString(ch.Stderr(), "Trace: repository is unavailable\n") return } + // Mirror the HTTP receive-pack checks: mirrors and archived repositories + // are read-only, and pushes need the managed protection hook. + if service == "git-receive-pack" { + refusal := "" + switch { + case isMirror(path): + refusal = "Trace: mirror is read-only\n" + case isArchived(path): + refusal = "Trace: repository is archived\n" + case !hasManagedHook(path): + refusal = "Trace: branch protection is unavailable\n" + } + if refusal != "" { + _, _ = io.WriteString(ch.Stderr(), refusal) + return + } + } cmd := exec.Command(service, path) cmd.Stdin = ch cmd.Stdout = ch diff --git a/cmd/trace/ssh_push_test.go b/cmd/trace/ssh_push_test.go new file mode 100644 index 0000000..0082c94 --- /dev/null +++ b/cmd/trace/ssh_push_test.go @@ -0,0 +1,132 @@ +package main + +import ( + "crypto/ed25519" + "crypto/rand" + "encoding/pem" + "net" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "golang.org/x/crypto/ssh" +) + +// sshPushFixture runs Trace's SSH listener for a repository seeded with one +// commit on main and a writer "alice" who authenticates with an SSH key. +type sshPushFixture struct { + store *store + addr string + key string +} + +func newSSHPushFixture(t *testing.T, repo string) *sshPushFixture { + t.Helper() + if _, err := exec.LookPath("ssh"); err != nil { + t.Skip("ssh client is not installed") + } + root := t.TempDir() + if err := initData(root); err != nil { + t.Fatal(err) + } + s, err := openStore(root) + if err != nil { + t.Fatal(err) + } + if err := s.createRepo(repo, false); err != nil { + t.Fatal(err) + } + seed := t.TempDir() + gitTest(t, seed, "init", "--initial-branch=main") + gitTest(t, seed, "config", "user.name", "Admin") + gitTest(t, seed, "config", "user.email", "admin@example.invalid") + gitTest(t, seed, "commit", "--allow-empty", "-m", "seed") + repoPath, _ := s.repoPath(repo) + push := exec.Command("git", "-C", seed, "push", repoPath, "main") + push.Env = append(os.Environ(), "TRACE_ADMIN=1") + if out, err := push.CombinedOutput(); err != nil { + t.Fatalf("seed: %v\n%s", err, out) + } + if _, err := s.addUser("alice", false); err != nil { + t.Fatal(err) + } + if err := s.grantUser("alice", repo, "write"); err != nil { + t.Fatal(err) + } + public, private, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + publicKey, err := ssh.NewPublicKey(public) + if err != nil { + t.Fatal(err) + } + if err := s.updateUsers(func(db *userDB) error { + u := db.Users["alice"] + u.SSHKeys = []string{string(ssh.MarshalAuthorizedKey(publicKey))} + db.Users["alice"] = u + return nil + }); err != nil { + t.Fatal(err) + } + block, err := ssh.MarshalPrivateKey(private, "") + if err != nil { + t.Fatal(err) + } + keyPath := filepath.Join(t.TempDir(), "id_ed25519") + if err := os.WriteFile(keyPath, pem.EncodeToMemory(block), 0600); err != nil { + t.Fatal(err) + } + config, err := sshServerConfig(s) + if err != nil { + t.Fatal(err) + } + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = listener.Close() }) + go func() { _ = serveSSHListener(s, config, listener) }() + return &sshPushFixture{store: s, addr: listener.Addr().String(), key: keyPath} +} + +// push clones the repository as alice over SSH, commits on branch, and +// pushes it back, returning the push output and error. +func (f *sshPushFixture) push(t *testing.T, repo, branch string) (string, error) { + t.Helper() + host, port, _ := net.SplitHostPort(f.addr) + sshCommand := "ssh -F /dev/null -i " + f.key + " -o IdentitiesOnly=yes -o BatchMode=yes -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -p " + port + remote := "ssh://alice@" + host + ":" + port + "/" + repo + ".git" + git := func(dir string, args ...string) (string, error) { + cmd := exec.Command("git", args...) + cmd.Dir = dir + cmd.Env = append(os.Environ(), "GIT_SSH_COMMAND="+sshCommand, "GIT_TERMINAL_PROMPT=0") + out, err := cmd.CombinedOutput() + return string(out), err + } + work := filepath.Join(t.TempDir(), "work") + if out, err := git(t.TempDir(), "clone", remote, work); err != nil { + t.Fatalf("SSH clone: %v\n%s", err, out) + } + for _, args := range [][]string{{"config", "user.name", "Alice"}, {"config", "user.email", "alice@example.invalid"}, {"checkout", "-B", branch}, {"commit", "--allow-empty", "-m", "alice " + branch}} { + if out, err := git(work, args...); err != nil { + t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, out) + } + } + return git(work, "push", "origin", branch) +} + +func TestSSHPushToArchivedRepositoryIsRejected(t *testing.T) { + f := newSSHPushFixture(t, "team/old") + if out, err := f.push(t, "team/old", "feature"); err != nil { + t.Fatalf("writer SSH push before archiving failed: %v\n%s", err, out) + } + if err := f.store.setArchived("team/old", true); err != nil { + t.Fatal(err) + } + if out, err := f.push(t, "team/old", "feature-2"); err == nil { + t.Fatalf("SSH push to an archived repository succeeded:\n%s", out) + } +} From 8c3f76d6cf36b458e22795a6b504731c1e67046c Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 27 Sep 2026 03:59:08 +0530 Subject: [PATCH 12/30] fix(ssh): run Git services with an explicit minimal environment SSH Git processes ran with append(os.Environ(), "REMOTE_USER=...") and only appended TRACE_ADMIN=1 for administrators. If the service environment ever contained TRACE_ADMIN=1 (a debugging shell, a unit file), every non-admin SSH push bypassed the pre-receive protection, and all service variables were exposed to hooks. Build the environment explicitly, as the HTTP CGI path does: PATH, the platform library path if set, REMOTE_USER, and TRACE_ADMIN pinned to 0 or 1. The test sets TRACE_ADMIN=1 in the server process and checks that a writer's SSH push to main is still refused while a feature branch push succeeds. Finding: F083 --- cmd/trace/ssh.go | 27 +++++++++++++++++++++++---- cmd/trace/ssh_push_test.go | 13 +++++++++++++ 2 files changed, 36 insertions(+), 4 deletions(-) diff --git a/cmd/trace/ssh.go b/cmd/trace/ssh.go index d9ae5dd..20c119b 100644 --- a/cmd/trace/ssh.go +++ b/cmd/trace/ssh.go @@ -272,10 +272,7 @@ func handleSSHSession(s *store, permissions *ssh.Permissions, ch ssh.Channel, re cmd.Stdin = ch cmd.Stdout = ch cmd.Stderr = ch.Stderr() - cmd.Env = append(os.Environ(), "REMOTE_USER="+username) - if u.Admin { - cmd.Env = append(cmd.Env, "TRACE_ADMIN=1") - } + cmd.Env = gitServiceEnv(username, u.Admin) err = cmd.Run() status := uint32(0) if err != nil { @@ -289,3 +286,25 @@ func handleSSHSession(s *store, permissions *ssh.Permissions, ch ssh.Channel, re binary.BigEndian.PutUint32(payload, status) _, _ = ch.SendRequest("exit-status", false, payload) } + +// gitServiceEnv is the complete environment for a Git service process that +// Trace starts for a user. Like the HTTP CGI path, it never inherits the +// service environment, and TRACE_ADMIN is always set explicitly so a stray +// TRACE_ADMIN=1 in the operator's environment cannot disable branch +// protection for non-admin pushes. +func gitServiceEnv(username string, admin bool) []string { + path := os.Getenv("PATH") + if path == "" { + path = "/usr/local/bin:/usr/bin:/bin" + } + env := []string{"PATH=" + path, "REMOTE_USER=" + username, "TRACE_ADMIN=0"} + if admin { + env[2] = "TRACE_ADMIN=1" + } + for _, name := range []string{"LD_LIBRARY_PATH", "DYLD_LIBRARY_PATH"} { + if value := os.Getenv(name); value != "" { + env = append(env, name+"="+value) + } + } + return env +} diff --git a/cmd/trace/ssh_push_test.go b/cmd/trace/ssh_push_test.go index 0082c94..6bd95a8 100644 --- a/cmd/trace/ssh_push_test.go +++ b/cmd/trace/ssh_push_test.go @@ -130,3 +130,16 @@ func TestSSHPushToArchivedRepositoryIsRejected(t *testing.T) { t.Fatalf("SSH push to an archived repository succeeded:\n%s", out) } } + +func TestSSHPushIgnoresServerTraceAdminEnvironment(t *testing.T) { + // An operator shell or unit file that exports TRACE_ADMIN=1 must not turn + // every SSH push into an administrator push. + t.Setenv("TRACE_ADMIN", "1") + f := newSSHPushFixture(t, "team/protected") + if out, err := f.push(t, "team/protected", "main"); err == nil { + t.Fatalf("non-admin SSH push to protected main succeeded:\n%s", out) + } + if out, err := f.push(t, "team/protected", "feature"); err != nil { + t.Fatalf("non-admin SSH push to a feature branch failed: %v\n%s", err, out) + } +} From 00353f4460c29d1366a5d81a662b416e08a5f614 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 27 Sep 2026 04:01:41 +0530 Subject: [PATCH 13/30] fix(oidc)!: bind OIDC sign-in to the provider's issuer and subject The OIDC callback picked the local account by preferred_username or the email local part and issued a session for any existing account with that name, never checking a stored subject, the ID token's subject, or TOTP. With a public or multi-tenant provider, any identity named "admin" (or admin@anything) signed in as Trace's local admin without its token or second factor; the same applied to every local username. - userRecord stores oidc_issuer and oidc_subject; OIDC sign-in only opens the account bound to the verified (issuer, sub) pair. - Auto-provisioning creates a new bound account and refuses when the derived name already exists; `trace sso oidc link USER SUBJECT` and `unlink USER` let an administrator bind an existing account. - When an ID token is returned, its sub must equal the userinfo sub (OIDC Core 5.3.2). - `-allowed-email-domains` optionally requires a verified email in the listed domains. - Accounts with TOTP enabled are refused through OIDC instead of silently skipping the second factor. BREAKING CHANGE: accounts auto-provisioned by earlier versions carry no binding and must be linked once with `trace sso oidc link`. Finding: F079 --- README.md | 9 ++ cmd/trace/main.go | 2 +- cmd/trace/oidc.go | 216 +++++++++++++++++++++++++++----- cmd/trace/oidc_binding_test.go | 145 +++++++++++++++++++++ cmd/trace/oidc_provider_test.go | 162 ++++++++++++++++++++++++ cmd/trace/oidc_test.go | 4 +- cmd/trace/users.go | 5 + 7 files changed, 510 insertions(+), 33 deletions(-) create mode 100644 cmd/trace/oidc_binding_test.go create mode 100644 cmd/trace/oidc_provider_test.go diff --git a/README.md b/README.md index 6c3ef80..6798f88 100644 --- a/README.md +++ b/README.md @@ -152,6 +152,15 @@ OIDC is optional and disabled unless configured. The setup stores the client sec ./trace sso oidc disable -data ./data ``` +Trace binds each OIDC identity to one local account by the provider's issuer and subject (`sub`); usernames and email addresses from the provider are never used to pick an existing account. With `-auto-provision`, a first sign-in creates a new account named after `preferred_username` (or the email's local part) and bound to that subject; if a local account with that name already exists, sign-in is refused. To let an existing account sign in through the provider, an administrator links it explicitly (the refusal page shows the subject): + +```sh +./trace sso oidc link -data ./data alice PROVIDER_SUBJECT +./trace sso oidc unlink -data ./data alice +``` + +Accounts created by auto-provisioning in earlier Trace versions are not bound and must be linked once. `-allowed-email-domains example.com,example.org` additionally requires a verified email (`email_verified`) in one of those domains. When the provider returns an ID token, its subject must equal the userinfo subject. Accounts with Trace TOTP enabled cannot sign in through OIDC; they use token sign-in with their code. + The login page shows the provider button only while configuration is present. Auto-provisioned accounts receive a local record for session continuity but no personal token is returned. Providers that return only userinfo are still accepted, so deploy behind a provider whose userinfo endpoint is trusted and use HTTPS. SAML and provider-specific group claims remain unimplemented. ## Two-factor authentication diff --git a/cmd/trace/main.go b/cmd/trace/main.go index 04536cd..c95a0cd 100644 --- a/cmd/trace/main.go +++ b/cmd/trace/main.go @@ -264,7 +264,7 @@ func run(args []string) error { return nil case "sso": if len(args) < 3 || args[1] != "oidc" { - return errors.New("usage: trace sso oidc ...") + return errors.New("usage: trace sso oidc ...") } return oidcCommand(args[2:]) case "team": diff --git a/cmd/trace/oidc.go b/cmd/trace/oidc.go index 0c9ff5f..962d39e 100644 --- a/cmd/trace/oidc.go +++ b/cmd/trace/oidc.go @@ -31,6 +31,9 @@ type oidcConfig struct { RedirectURL string `json:"redirect_url"` AutoProvision bool `json:"auto_provision"` Scopes []string `json:"scopes,omitempty"` + // AllowedEmailDomains, when set, admits only identities whose userinfo + // reports email_verified and an email in one of these domains. + AllowedEmailDomains []string `json:"allowed_email_domains,omitempty"` } type oidcDiscoveryDocument struct { @@ -338,11 +341,14 @@ func (a *app) oidcCallback(w http.ResponseWriter, r *http.Request) { a.loginPage(w, r, "OIDC token response was invalid.", http.StatusBadGateway) return } + var idClaims *oidcJWTClaims if tokens.IDToken != "" { - if _, err := a.verifyOIDCIDToken(cfg, doc, tokens.IDToken); err != nil { + verified, err := a.verifyOIDCIDToken(cfg, doc, tokens.IDToken) + if err != nil { a.loginPage(w, r, "OIDC ID token validation failed.", http.StatusUnauthorized) return } + idClaims = &verified } infoReq, err := http.NewRequest(http.MethodGet, doc.UserinfoEndpoint, nil) if err != nil { @@ -356,56 +362,155 @@ func (a *app) oidcCallback(w http.ResponseWriter, r *http.Request) { return } defer infoRes.Body.Close() - var claims struct { - Subject string `json:"sub"` - Name string `json:"preferred_username"` - Email string `json:"email"` - } + var claims oidcUserinfo if infoRes.StatusCode < 200 || infoRes.StatusCode >= 300 || json.NewDecoder(io.LimitReader(infoRes.Body, 256<<10)).Decode(&claims) != nil { a.loginPage(w, r, "OIDC user information was invalid.", http.StatusUnauthorized) return } - localName := claims.Name - if localName == "" { - localName = strings.Split(claims.Email, "@")[0] + // OIDC Core 5.3.2: the userinfo subject must be the ID token's subject. + if claims.Subject == "" || (idClaims != nil && idClaims.Subject != claims.Subject) { + a.loginPage(w, r, "OIDC user information does not match the ID token.", http.StatusUnauthorized) + return } - if !namePattern.MatchString(localName) || localName == "git" || claims.Subject == "" { - a.loginPage(w, r, "OIDC account has no valid Trace username.", http.StatusForbidden) + if !oidcEmailAllowed(cfg, claims) { + a.loginPage(w, r, "Your OIDC account's verified email domain is not allowed on this Trace node.", http.StatusForbidden) return } - db, err := a.store.loadUsers() + issuer := strings.TrimRight(cfg.Issuer, "/") + name, u, found, err := a.store.oidcAccount(issuer, claims.Subject) if err != nil { a.loginPage(w, r, "cannot load Trace users.", http.StatusInternalServerError) return } - u, exists := db.Users[localName] - if !exists { + if !found { if !cfg.AutoProvision { - a.loginPage(w, r, "Your OIDC account is not provisioned in Trace.", http.StatusForbidden) + a.loginPage(w, r, "Your OIDC identity is not linked to a Trace account. An administrator can link it with: trace sso oidc link USER "+claims.Subject, http.StatusForbidden) return } - seed, seedErr := newToken() - if seedErr != nil { - a.loginPage(w, r, "cannot provision Trace account.", http.StatusInternalServerError) + name, u, err = a.store.provisionOIDCAccount(issuer, claims) + if err != nil { + a.loginPage(w, r, err.Error(), http.StatusForbidden) return } - if err := a.store.updateUsers(func(users *userDB) error { - users.Users[localName] = userRecord{Hash: hashToken(seed)} - return nil - }); err != nil { - a.loginPage(w, r, "cannot provision Trace account.", http.StatusInternalServerError) - return - } - u = userRecord{Hash: hashToken(seed)} - } else if u.Disabled { + } + if u.Disabled { a.loginPage(w, r, "Your Trace account is disabled.", http.StatusForbidden) return } - a.issueSession(w, r, localName, u) + if u.TOTPEnabled { + // OIDC sign-in cannot collect Trace's TOTP code, so an account that + // requires it must use token sign-in instead of bypassing it. + a.loginPage(w, r, "This Trace account requires two-factor sign-in with its token and code.", http.StatusForbidden) + return + } + a.issueSession(w, r, name, u) http.SetCookie(w, &http.Cookie{Name: "trace_oidc_state", Path: "/login/oidc", MaxAge: -1, HttpOnly: true, SameSite: http.SameSiteLaxMode, Secure: secureCookie(r)}) http.Redirect(w, r, "/app", http.StatusSeeOther) } +// oidcUserinfo holds the userinfo claims Trace uses. email_verified is kept +// raw because some providers send it as a string. +type oidcUserinfo struct { + Subject string `json:"sub"` + Name string `json:"preferred_username"` + Email string `json:"email"` + EmailVerified json.RawMessage `json:"email_verified"` +} + +func (c oidcUserinfo) emailVerified() bool { + value := strings.Trim(strings.TrimSpace(string(c.EmailVerified)), `"`) + return strings.EqualFold(value, "true") +} + +func oidcEmailAllowed(cfg oidcConfig, claims oidcUserinfo) bool { + if len(cfg.AllowedEmailDomains) == 0 { + return true + } + at := strings.LastIndex(claims.Email, "@") + if !claims.emailVerified() || at < 1 { + return false + } + domain := strings.ToLower(claims.Email[at+1:]) + for _, allowed := range cfg.AllowedEmailDomains { + if strings.EqualFold(strings.TrimSpace(allowed), domain) { + return true + } + } + return false +} + +// oidcAccount returns the local account bound to (issuer, subject). +func (s *store) oidcAccount(issuer, subject string) (string, userRecord, bool, error) { + db, err := s.loadUsers() + if err != nil { + return "", userRecord{}, false, err + } + for name, u := range db.Users { + if u.OIDCSubject != "" && u.OIDCSubject == subject && strings.TrimRight(u.OIDCIssuer, "/") == issuer { + return name, u, true, nil + } + } + return "", userRecord{}, false, nil +} + +// provisionOIDCAccount creates a new account bound to the identity. It never +// adopts an existing account: a name collision must be resolved by an +// administrator with `trace sso oidc link`. +func (s *store) provisionOIDCAccount(issuer string, claims oidcUserinfo) (string, userRecord, error) { + name := claims.Name + if name == "" { + name = strings.Split(claims.Email, "@")[0] + } + if !namePattern.MatchString(name) || name == "git" { + return "", userRecord{}, errors.New("OIDC account has no valid Trace username.") + } + seed, err := newToken() + if err != nil { + return "", userRecord{}, errors.New("cannot provision Trace account.") + } + record := userRecord{Hash: hashToken(seed), OIDCIssuer: issuer, OIDCSubject: claims.Subject} + err = s.updateUsers(func(db *userDB) error { + if _, exists := db.Users[name]; exists { + return fmt.Errorf("A Trace account named %s already exists and is not linked to your OIDC identity. An administrator can link it with: trace sso oidc link %s %s", name, name, claims.Subject) + } + for _, u := range db.Users { + if u.OIDCSubject == claims.Subject && strings.TrimRight(u.OIDCIssuer, "/") == issuer { + return errors.New("This OIDC identity is already linked to another Trace account.") + } + } + db.Users[name] = record + return nil + }) + if err != nil { + return "", userRecord{}, err + } + return name, record, nil +} + +// linkOIDCAccount binds (or, with an empty subject, unbinds) a local account +// to an identity of the configured issuer. +func (s *store) linkOIDCAccount(name, issuer, subject string) error { + return s.updateUsers(func(db *userDB) error { + u, ok := db.Users[name] + if !ok { + return errors.New("user not found") + } + if subject != "" { + for other, candidate := range db.Users { + if other != name && candidate.OIDCSubject == subject && strings.TrimRight(candidate.OIDCIssuer, "/") == issuer { + return fmt.Errorf("OIDC subject is already linked to %s", other) + } + } + } + u.OIDCIssuer, u.OIDCSubject = issuer, subject + if subject == "" { + u.OIDCIssuer = "" + } + db.Users[name] = u + return nil + }) +} + func oidcLoginLink(s *store) string { cfg, err := s.loadOIDC() if err == nil && cfg.Issuer != "" { @@ -415,8 +520,11 @@ func oidcLoginLink(s *store) string { } func oidcCommand(args []string) error { + if len(args) > 0 && (args[0] == "link" || args[0] == "unlink") { + return oidcLinkCommand(args) + } if len(args) == 0 || (args[0] != "set" && args[0] != "disable") { - return errors.New("usage: trace sso oidc [-data DIR] ...") + return errors.New("usage: trace sso oidc [-data DIR] ...") } fs := flag.NewFlagSet("sso oidc "+args[0], flag.ContinueOnError) data := fs.String("data", "./data", "data directory") @@ -426,6 +534,7 @@ func oidcCommand(args []string) error { redirectURL := fs.String("redirect-url", "", "registered callback URL") autoProvision := fs.Bool("auto-provision", false, "create local users on first OIDC sign-in") scopes := fs.String("scopes", "openid,profile,email", "comma-separated OIDC scopes") + allowedDomains := fs.String("allowed-email-domains", "", "comma-separated email domains; when set, sign-in requires a verified email in one of them") if err := fs.Parse(args[1:]); err != nil { return err } @@ -453,7 +562,13 @@ func oidcCommand(args []string) error { selected = append(selected, scope) } } - cfg := oidcConfig{Issuer: strings.TrimRight(*issuer, "/"), ClientID: *clientID, ClientSecret: *clientSecret, RedirectURL: *redirectURL, AutoProvision: *autoProvision, Scopes: selected} + var domains []string + for _, domain := range strings.Split(*allowedDomains, ",") { + if domain = strings.ToLower(strings.TrimSpace(domain)); domain != "" { + domains = append(domains, domain) + } + } + cfg := oidcConfig{Issuer: strings.TrimRight(*issuer, "/"), ClientID: *clientID, ClientSecret: *clientSecret, RedirectURL: *redirectURL, AutoProvision: *autoProvision, Scopes: selected, AllowedEmailDomains: domains} b, err := json.MarshalIndent(cfg, "", " ") if err != nil { return err @@ -485,3 +600,44 @@ func oidcCommand(args []string) error { fmt.Println("OIDC configured") return nil } + +// oidcLinkCommand implements `trace sso oidc link USER SUBJECT` and +// `trace sso oidc unlink USER`, the administrator's way to connect an +// existing local account to an identity of the configured issuer. +func oidcLinkCommand(args []string) error { + usage := errors.New("usage: trace sso oidc link [-data DIR] USER SUBJECT | trace sso oidc unlink [-data DIR] USER") + fs := flag.NewFlagSet("sso oidc "+args[0], flag.ContinueOnError) + data := fs.String("data", "./data", "data directory") + if err := fs.Parse(args[1:]); err != nil { + return err + } + s, err := openStore(*data) + if err != nil { + return err + } + if args[0] == "unlink" { + if fs.NArg() != 1 { + return usage + } + if err := s.linkOIDCAccount(fs.Arg(0), "", ""); err != nil { + return err + } + fmt.Println("unlinked OIDC identity from", fs.Arg(0)) + return nil + } + if fs.NArg() != 2 || strings.TrimSpace(fs.Arg(1)) == "" { + return usage + } + cfg, err := s.loadOIDC() + if err != nil { + return err + } + if cfg.Issuer == "" { + return errors.New("OIDC is not configured; run trace sso oidc set first") + } + if err := s.linkOIDCAccount(fs.Arg(0), strings.TrimRight(cfg.Issuer, "/"), fs.Arg(1)); err != nil { + return err + } + fmt.Printf("linked %s to OIDC subject %s at %s\n", fs.Arg(0), fs.Arg(1), strings.TrimRight(cfg.Issuer, "/")) + return nil +} diff --git a/cmd/trace/oidc_binding_test.go b/cmd/trace/oidc_binding_test.go new file mode 100644 index 0000000..a2a1a33 --- /dev/null +++ b/cmd/trace/oidc_binding_test.go @@ -0,0 +1,145 @@ +package main + +import ( + "net/http" + "path/filepath" + "strings" + "testing" +) + +func newOIDCTestApp(t *testing.T) (*app, string) { + t.Helper() + root := filepath.Join(t.TempDir(), "node") + if err := initData(root); err != nil { + t.Fatal(err) + } + a, err := newApp(root) + if err != nil { + t.Fatal(err) + } + return a, root +} + +// TestOIDCCannotTakeOverLocalAccounts reproduces the account-mapping flaw: an +// IdP user whose preferred_username (or email local part) is "admin" must not +// receive the local admin session, with or without auto-provisioning. +func TestOIDCCannotTakeOverLocalAccounts(t *testing.T) { + for _, autoProvision := range []bool{false, true} { + a, root := newOIDCTestApp(t) + p := newFakeOIDCProvider(t) + configureOIDC(t, root, p, autoProvision) + for _, userinfo := range []map[string]any{ + {"sub": "attacker-1", "preferred_username": "admin"}, + {"sub": "attacker-2", "email": "admin@evil.example"}, + } { + p.set(userinfo, nil) + res := oidcSignIn(t, a, p) + if res.Code == http.StatusSeeOther || sessionCookieIssued(res) { + t.Fatalf("auto-provision=%v: IdP identity %v signed in to the local admin account", autoProvision, userinfo) + } + if !strings.Contains(res.Body.String(), "trace sso oidc link") { + t.Fatalf("refusal should explain how to link the account: %s", res.Body.String()) + } + } + db, _ := a.store.loadUsers() + if db.Users["admin"].OIDCSubject != "" { + t.Fatal("the admin account was bound to an attacker identity") + } + } +} + +func TestOIDCAutoProvisionBindsSubject(t *testing.T) { + a, root := newOIDCTestApp(t) + p := newFakeOIDCProvider(t) + configureOIDC(t, root, p, true) + p.set(map[string]any{"sub": "subject-1", "preferred_username": "oidc-user"}, nil) + if res := oidcSignIn(t, a, p); res.Code != http.StatusSeeOther || !sessionCookieIssued(res) { + t.Fatalf("first sign-in: %d %s", res.Code, res.Body.String()) + } + db, _ := a.store.loadUsers() + u := db.Users["oidc-user"] + if u.OIDCSubject != "subject-1" || u.OIDCIssuer != strings.TrimRight(p.server.URL, "/") { + t.Fatalf("provisioned account is not bound to its identity: %+v", u) + } + // Renaming at the IdP keeps the same account; another subject with the + // same username does not get it. + p.set(map[string]any{"sub": "subject-1", "preferred_username": "renamed"}, nil) + if res := oidcSignIn(t, a, p); res.Code != http.StatusSeeOther { + t.Fatalf("returning identity: %d %s", res.Code, res.Body.String()) + } + p.set(map[string]any{"sub": "subject-2", "preferred_username": "oidc-user"}, nil) + if res := oidcSignIn(t, a, p); res.Code == http.StatusSeeOther || sessionCookieIssued(res) { + t.Fatal("a different subject reused an existing account by username") + } +} + +func TestOIDCLinkedAccountsAndPolicies(t *testing.T) { + a, root := newOIDCTestApp(t) + p := newFakeOIDCProvider(t) + configureOIDC(t, root, p, false) + if err := run([]string{"sso", "oidc", "link", "-data", root, "admin", "idp-admin-subject"}); err != nil { + t.Fatal(err) + } + p.set(map[string]any{"sub": "idp-admin-subject", "preferred_username": "someone-else"}, nil) + if res := oidcSignIn(t, a, p); res.Code != http.StatusSeeOther || !sessionCookieIssued(res) { + t.Fatalf("linked identity could not sign in: %d %s", res.Code, res.Body.String()) + } + if err := run([]string{"sso", "oidc", "link", "-data", root, "admin", "idp-admin-subject"}); err != nil { + t.Fatalf("relinking the same account should be idempotent: %v", err) + } + if _, err := a.store.addUser("bob", false); err != nil { + t.Fatal(err) + } + if err := run([]string{"sso", "oidc", "link", "-data", root, "bob", "idp-admin-subject"}); err == nil { + t.Fatal("one IdP subject was linked to two accounts") + } + + // An ID token whose subject differs from userinfo is rejected. + p.set(map[string]any{"sub": "idp-admin-subject"}, p.validIDToken("someone-else")) + if res := oidcSignIn(t, a, p); res.Code == http.StatusSeeOther || sessionCookieIssued(res) { + t.Fatal("userinfo subject that differs from the ID token subject was accepted") + } + p.set(map[string]any{"sub": "idp-admin-subject"}, p.validIDToken("idp-admin-subject")) + if res := oidcSignIn(t, a, p); res.Code != http.StatusSeeOther { + t.Fatalf("matching ID token and userinfo subjects were refused: %d %s", res.Code, res.Body.String()) + } + + // Accounts that require Trace TOTP cannot bypass it through OIDC. + if err := run([]string{"user", "2fa", "enable", "-data", root, "admin"}); err != nil { + t.Fatal(err) + } + p.set(map[string]any{"sub": "idp-admin-subject"}, nil) + if res := oidcSignIn(t, a, p); res.Code == http.StatusSeeOther || sessionCookieIssued(res) { + t.Fatal("OIDC sign-in bypassed the account's TOTP requirement") + } + if err := run([]string{"sso", "oidc", "unlink", "-data", root, "admin"}); err != nil { + t.Fatal(err) + } + db, _ := a.store.loadUsers() + if db.Users["admin"].OIDCSubject != "" || db.Users["admin"].OIDCIssuer != "" { + t.Fatal("unlink kept the OIDC binding") + } +} + +func TestOIDCAllowedEmailDomains(t *testing.T) { + a, root := newOIDCTestApp(t) + p := newFakeOIDCProvider(t) + configureOIDC(t, root, p, true, "example.com") + for _, userinfo := range []map[string]any{ + {"sub": "d1", "email": "dev@example.com", "email_verified": false}, + {"sub": "d2", "email": "dev@example.com"}, + {"sub": "d3", "email": "dev@other.example", "email_verified": true}, + } { + p.set(userinfo, nil) + if res := oidcSignIn(t, a, p); res.Code == http.StatusSeeOther { + t.Fatalf("identity %v passed the domain allowlist", userinfo) + } + } + for i, verified := range []any{true, "true"} { + name := []string{"dev-bool", "dev-string"}[i] + p.set(map[string]any{"sub": "ok-" + name, "preferred_username": name, "email": "Dev@Example.com", "email_verified": verified}, nil) + if res := oidcSignIn(t, a, p); res.Code != http.StatusSeeOther { + t.Fatalf("verified allowed-domain identity refused (%v): %d %s", verified, res.Code, res.Body.String()) + } + } +} diff --git a/cmd/trace/oidc_provider_test.go b/cmd/trace/oidc_provider_test.go new file mode 100644 index 0000000..3c236f5 --- /dev/null +++ b/cmd/trace/oidc_provider_test.go @@ -0,0 +1,162 @@ +package main + +import ( + "crypto" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "math/big" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "sync" + "testing" + "time" +) + +// fakeOIDCProvider is a minimal OpenID provider for tests. It serves +// discovery, JWKS, token, and userinfo endpoints and can sign ID tokens. +type fakeOIDCProvider struct { + t *testing.T + server *httptest.Server + key *rsa.PrivateKey + mu sync.Mutex + // userinfo is returned from the userinfo endpoint. + userinfo map[string]any + // idToken, when non-nil, is signed and returned from the token endpoint; + // the special value "$nonce" for "nonce" is replaced by the nonce Trace + // sent in its authorization request. + idToken map[string]any + nonce string + // tokenDelay stalls the token endpoint. + tokenDelay time.Duration +} + +var ( + oidcTestKeyOnce sync.Once + oidcTestKey *rsa.PrivateKey +) + +func newFakeOIDCProvider(t *testing.T) *fakeOIDCProvider { + t.Helper() + oidcTestKeyOnce.Do(func() { + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + panic(err) + } + oidcTestKey = key + }) + p := &fakeOIDCProvider{t: t, key: oidcTestKey} + p.server = httptest.NewServer(http.HandlerFunc(p.serve)) + t.Cleanup(p.server.Close) + return p +} + +func (p *fakeOIDCProvider) serve(w http.ResponseWriter, r *http.Request) { + p.mu.Lock() + defer p.mu.Unlock() + switch r.URL.Path { + case "/.well-known/openid-configuration": + _ = json.NewEncoder(w).Encode(map[string]string{"issuer": p.server.URL, "authorization_endpoint": p.server.URL + "/authorize", "token_endpoint": p.server.URL + "/token", "userinfo_endpoint": p.server.URL + "/userinfo", "jwks_uri": p.server.URL + "/jwks"}) + case "/jwks": + _ = json.NewEncoder(w).Encode(map[string]any{"keys": []map[string]string{{"kty": "RSA", "kid": "test", "n": base64.RawURLEncoding.EncodeToString(p.key.N.Bytes()), "e": base64.RawURLEncoding.EncodeToString(big.NewInt(int64(p.key.E)).Bytes())}}}) + case "/token": + if p.tokenDelay > 0 { + delay := p.tokenDelay + p.mu.Unlock() + time.Sleep(delay) + p.mu.Lock() + } + response := map[string]string{"access_token": "provider-token", "token_type": "Bearer"} + if p.idToken != nil { + response["id_token"] = p.signIDToken() + } + _ = json.NewEncoder(w).Encode(response) + case "/userinfo": + if r.Header.Get("Authorization") != "Bearer provider-token" { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + _ = json.NewEncoder(w).Encode(p.userinfo) + default: + http.NotFound(w, r) + } +} + +func (p *fakeOIDCProvider) signIDToken() string { + claims := map[string]any{} + for k, v := range p.idToken { + claims[k] = v + } + if claims["nonce"] == "$nonce" { + claims["nonce"] = p.nonce + } + header, _ := json.Marshal(map[string]string{"alg": "RS256", "kid": "test", "typ": "JWT"}) + payload, _ := json.Marshal(claims) + signingInput := base64.RawURLEncoding.EncodeToString(header) + "." + base64.RawURLEncoding.EncodeToString(payload) + digest := sha256.Sum256([]byte(signingInput)) + signature, err := rsa.SignPKCS1v15(rand.Reader, p.key, crypto.SHA256, digest[:]) + if err != nil { + p.t.Fatal(err) + } + return signingInput + "." + base64.RawURLEncoding.EncodeToString(signature) +} + +// validIDToken returns ID token claims that pass verification for subject. +func (p *fakeOIDCProvider) validIDToken(subject string) map[string]any { + return map[string]any{"iss": p.server.URL, "aud": "trace", "sub": subject, "exp": time.Now().Add(time.Hour).Unix(), "iat": time.Now().Unix(), "nonce": "$nonce"} +} + +func (p *fakeOIDCProvider) set(userinfo, idToken map[string]any) { + p.mu.Lock() + defer p.mu.Unlock() + p.userinfo, p.idToken = userinfo, idToken +} + +// configureOIDC writes an OIDC configuration pointing at the provider. +func configureOIDC(t *testing.T, root string, p *fakeOIDCProvider, autoProvision bool, domains ...string) { + t.Helper() + cfg := oidcConfig{Issuer: p.server.URL, ClientID: "trace", ClientSecret: "secret", RedirectURL: "http://127.0.0.1:8787/login/oidc/callback", AutoProvision: autoProvision, AllowedEmailDomains: domains} + b, _ := json.Marshal(cfg) + if err := os.WriteFile(filepath.Join(root, oidcConfigFile), append(b, '\n'), 0600); err != nil { + t.Fatal(err) + } +} + +// oidcSignIn runs the browser flow: start, then the provider callback. +func oidcSignIn(t *testing.T, a *app, p *fakeOIDCProvider) *httptest.ResponseRecorder { + t.Helper() + start := httptest.NewRequest(http.MethodGet, "/login/oidc", nil) + startRes := httptest.NewRecorder() + a.ServeHTTP(startRes, start) + if startRes.Code != http.StatusFound { + t.Fatalf("OIDC start: %d %s", startRes.Code, startRes.Body.String()) + } + location, err := url.Parse(startRes.Header().Get("Location")) + if err != nil { + t.Fatal(err) + } + p.mu.Lock() + p.nonce = location.Query().Get("nonce") + p.mu.Unlock() + callback := httptest.NewRequest(http.MethodGet, "/login/oidc/callback?code=one-time&state="+url.QueryEscape(location.Query().Get("state")), nil) + for _, cookie := range startRes.Result().Cookies() { + callback.AddCookie(cookie) + } + res := httptest.NewRecorder() + a.ServeHTTP(res, callback) + return res +} + +func sessionCookieIssued(res *httptest.ResponseRecorder) bool { + for _, cookie := range res.Result().Cookies() { + if cookie.Name == "trace_session" && cookie.Value != "" { + return true + } + } + return false +} diff --git a/cmd/trace/oidc_test.go b/cmd/trace/oidc_test.go index d275c17..fa6b83b 100644 --- a/cmd/trace/oidc_test.go +++ b/cmd/trace/oidc_test.go @@ -61,8 +61,8 @@ func TestOIDCLoginWithPKCEAndAutoProvision(t *testing.T) { t.Fatalf("OIDC callback: %d %s", callbackRes.Code, callbackRes.Body.String()) } db, err := a.store.loadUsers() - if err != nil || db.Users["oidc-user"].Hash == "" { - t.Fatalf("OIDC user was not provisioned: %#v err=%v", db.Users, err) + if err != nil || db.Users["oidc-user"].Hash == "" || db.Users["oidc-user"].OIDCSubject != "subject-1" { + t.Fatalf("OIDC user was not provisioned with its subject: %#v err=%v", db.Users, err) } if strings.Contains(callbackRes.Body.String(), "provider-token") { t.Fatal("provider token leaked in callback response") diff --git a/cmd/trace/users.go b/cmd/trace/users.go index 1ae21c3..2acb060 100644 --- a/cmd/trace/users.go +++ b/cmd/trace/users.go @@ -27,6 +27,11 @@ type userRecord struct { SSHKeys []string `json:"ssh_keys,omitempty"` TOTPSecret string `json:"totp_secret,omitempty"` TOTPEnabled bool `json:"totp_enabled,omitempty"` + // OIDCIssuer and OIDCSubject bind the account to one identity-provider + // identity. OIDC sign-in only ever opens the account whose stored + // (issuer, subject) pair matches; usernames and emails are not trusted. + OIDCIssuer string `json:"oidc_issuer,omitempty"` + OIDCSubject string `json:"oidc_subject,omitempty"` } func sshKeyCommand(args []string) error { From 619af3a86af6e7fd8f1cdbb21afefb49146d083e Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 27 Sep 2026 04:03:02 +0530 Subject: [PATCH 14/30] fix(oidc): verify nonce, issuer, key size, and bound provider requests - Send a random nonce with each authorization request, keep it in the signed state cookie, and require the ID token to echo it. - Require a non-empty ID token issuer equal to the configured issuer (an empty iss was accepted). - Reject ID token signing keys shorter than 2048 bits. - Use one provider client for discovery, JWKS, token, and userinfo with a 10-second timeout and no redirect following; the token and userinfo requests previously used http.DefaultClient without any deadline, so a stalled provider pinned callback handlers indefinitely. Finding: F098 (OIDC part) --- README.md | 2 +- cmd/trace/oidc.go | 80 ++++++++++++++++++++--------- cmd/trace/oidc_hardening_test.go | 87 ++++++++++++++++++++++++++++++++ cmd/trace/oidc_provider_test.go | 2 + 4 files changed, 146 insertions(+), 25 deletions(-) create mode 100644 cmd/trace/oidc_hardening_test.go diff --git a/README.md b/README.md index 6798f88..c91d1d0 100644 --- a/README.md +++ b/README.md @@ -140,7 +140,7 @@ HTTP requests are throttled per client address. Normal paths allow 300 requests ## OIDC single sign-on -OIDC is optional and disabled unless configured. The setup stores the client secret under `data/oidc.json` with owner-only permissions, uses discovery plus authorization-code PKCE, validates signed state, requires HTTPS except for loopback development, and obtains identity claims from the provider's userinfo endpoint. When the provider returns an ID token, Trace validates its RS256 signature, issuer, audience, expiry, and signing key from the discovered JWKS endpoint: +OIDC is optional and disabled unless configured. The setup stores the client secret under `data/oidc.json` with owner-only permissions, uses discovery plus authorization-code PKCE, validates signed state, requires HTTPS except for loopback development, and obtains identity claims from the provider's userinfo endpoint. When the provider returns an ID token, Trace validates its RS256 signature (keys of at least 2048 bits from the discovered JWKS endpoint), issuer, audience, expiry, and the nonce sent with the authorization request. Every request to the provider has a 10-second timeout and does not follow redirects: ```sh ./trace sso oidc set -data ./data \ diff --git a/cmd/trace/oidc.go b/cmd/trace/oidc.go index 962d39e..8869378 100644 --- a/cmd/trace/oidc.go +++ b/cmd/trace/oidc.go @@ -44,6 +44,20 @@ type oidcDiscoveryDocument struct { JWKSURI string `json:"jwks_uri"` } +// oidcHTTPTimeout bounds every request Trace makes to the identity provider, +// so a stalled provider cannot pin sign-in handlers indefinitely. +var oidcHTTPTimeout = 10 * time.Second + +// oidcHTTPClient is used for all provider requests. It does not follow +// redirects: discovery, JWKS, token, and userinfo endpoints must answer +// directly at the URLs the provider advertised. +func oidcHTTPClient() *http.Client { + return &http.Client{Timeout: oidcHTTPTimeout, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }} +} + +// oidcMinRSABits is the smallest RSA modulus accepted for ID token signatures. +const oidcMinRSABits = 2048 + type oidcTokenResponse struct { AccessToken string `json:"access_token"` TokenType string `json:"token_type"` @@ -84,13 +98,13 @@ func (a *app) oidcDiscovery(cfg oidcConfig) (oidcDiscoveryDocument, error) { return oidcDiscoveryDocument{}, errors.New("OIDC issuer must use HTTPS (or loopback HTTP for local development)") } endpoint := strings.TrimRight(cfg.Issuer, "/") + "/.well-known/openid-configuration" - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + ctx, cancel := context.WithTimeout(context.Background(), oidcHTTPTimeout) defer cancel() req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) if err != nil { return oidcDiscoveryDocument{}, err } - res, err := http.DefaultClient.Do(req) + res, err := oidcHTTPClient().Do(req) if err != nil { return oidcDiscoveryDocument{}, fmt.Errorf("OIDC discovery: %w", err) } @@ -124,6 +138,7 @@ type oidcJWTClaims struct { Audience json.RawMessage `json:"aud"` Subject string `json:"sub"` Expiry int64 `json:"exp"` + Nonce string `json:"nonce"` } type oidcJWK struct { @@ -133,7 +148,7 @@ type oidcJWK struct { E string `json:"e"` } -func (a *app) verifyOIDCIDToken(cfg oidcConfig, doc oidcDiscoveryDocument, raw string) (oidcJWTClaims, error) { +func (a *app) verifyOIDCIDToken(cfg oidcConfig, doc oidcDiscoveryDocument, raw, nonce string) (oidcJWTClaims, error) { if doc.JWKSURI == "" { return oidcJWTClaims{}, errors.New("OIDC provider did not advertise JWKS") } @@ -151,9 +166,14 @@ func (a *app) verifyOIDCIDToken(cfg oidcConfig, doc oidcDiscoveryDocument, raw s if err != nil || json.Unmarshal(claimBytes, &claims) != nil || claims.Subject == "" { return oidcJWTClaims{}, errors.New("invalid OIDC ID token claims") } - if claims.Issuer != "" && strings.TrimRight(claims.Issuer, "/") != strings.TrimRight(cfg.Issuer, "/") { + if claims.Issuer == "" || strings.TrimRight(claims.Issuer, "/") != strings.TrimRight(cfg.Issuer, "/") { return oidcJWTClaims{}, errors.New("OIDC ID token issuer mismatch") } + // Trace sends a nonce with every authorization request; the ID token + // must echo it so a token minted for another login cannot be replayed. + if nonce == "" || !hmac.Equal([]byte(claims.Nonce), []byte(nonce)) { + return oidcJWTClaims{}, errors.New("OIDC ID token nonce mismatch") + } if claims.Expiry == 0 || time.Now().Unix() >= claims.Expiry { return oidcJWTClaims{}, errors.New("OIDC ID token is expired") } @@ -172,13 +192,13 @@ func (a *app) verifyOIDCIDToken(cfg oidcConfig, doc oidcDiscoveryDocument, raw s if audience != cfg.ClientID { return oidcJWTClaims{}, errors.New("OIDC ID token audience mismatch") } - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + ctx, cancel := context.WithTimeout(context.Background(), oidcHTTPTimeout) defer cancel() req, err := http.NewRequestWithContext(ctx, http.MethodGet, doc.JWKSURI, nil) if err != nil { return oidcJWTClaims{}, err } - res, err := http.DefaultClient.Do(req) + res, err := oidcHTTPClient().Do(req) if err != nil { return oidcJWTClaims{}, err } @@ -218,6 +238,9 @@ func (a *app) verifyOIDCIDToken(cfg oidcConfig, doc oidcDiscoveryDocument, raw s return oidcJWTClaims{}, errors.New("invalid OIDC RSA exponent") } key := &rsa.PublicKey{N: new(big.Int).SetBytes(nBytes), E: e} + if key.N.BitLen() < oidcMinRSABits { + return oidcJWTClaims{}, fmt.Errorf("OIDC signing key is shorter than %d bits", oidcMinRSABits) + } signature, err := base64.RawURLEncoding.DecodeString(parts[2]) if err != nil { return oidcJWTClaims{}, err @@ -237,38 +260,40 @@ func randomOIDCString(size int) (string, error) { return base64.RawURLEncoding.EncodeToString(b), nil } -func (a *app) oidcStateCookie(r *http.Request, state, verifier string) *http.Cookie { - payload := base64.RawURLEncoding.EncodeToString([]byte(state + "\x00" + verifier)) +func (a *app) oidcStateCookie(r *http.Request, state, verifier, nonce string) *http.Cookie { + payload := base64.RawURLEncoding.EncodeToString([]byte(state + "\x00" + verifier + "\x00" + nonce)) h := hmac.New(sha256.New, a.sessionKey) _, _ = h.Write([]byte("trace-oidc\x00" + payload)) value := payload + "." + base64.RawURLEncoding.EncodeToString(h.Sum(nil)) return &http.Cookie{Name: "trace_oidc_state", Value: value, Path: "/login/oidc", MaxAge: 600, HttpOnly: true, SameSite: http.SameSiteLaxMode, Secure: secureCookie(r)} } -func (a *app) readOIDCState(r *http.Request) (string, string, error) { +// readOIDCState returns the state, PKCE verifier, and nonce stored in the +// signed state cookie. +func (a *app) readOIDCState(r *http.Request) (string, string, string, error) { cookie, err := r.Cookie("trace_oidc_state") if err != nil { - return "", "", errors.New("OIDC state cookie missing") + return "", "", "", errors.New("OIDC state cookie missing") } parts := strings.Split(cookie.Value, ".") if len(parts) != 2 { - return "", "", errors.New("invalid OIDC state") + return "", "", "", errors.New("invalid OIDC state") } h := hmac.New(sha256.New, a.sessionKey) _, _ = h.Write([]byte("trace-oidc\x00" + parts[0])) got, err := base64.RawURLEncoding.DecodeString(parts[1]) if err != nil || !hmac.Equal(got, h.Sum(nil)) { - return "", "", errors.New("invalid OIDC state signature") + return "", "", "", errors.New("invalid OIDC state signature") } payload, err := base64.RawURLEncoding.DecodeString(parts[0]) if err != nil { - return "", "", errors.New("invalid OIDC state payload") + return "", "", "", errors.New("invalid OIDC state payload") } fields := strings.Split(string(payload), "\x00") - if len(fields) != 2 || fields[0] == "" || fields[1] == "" { - return "", "", errors.New("invalid OIDC state payload") + if len(fields) != 3 || fields[0] == "" || fields[1] == "" || fields[2] == "" { + return "", "", "", errors.New("invalid OIDC state payload") } - return fields[0], fields[1], nil + return fields[0], fields[1], fields[2], nil } func (a *app) oidcLogin(w http.ResponseWriter, r *http.Request) { @@ -292,10 +317,15 @@ func (a *app) oidcLogin(w http.ResponseWriter, r *http.Request) { http.Error(w, "cannot create OIDC verifier", http.StatusInternalServerError) return } + nonce, err := randomOIDCString(24) + if err != nil { + http.Error(w, "cannot create OIDC nonce", http.StatusInternalServerError) + return + } hash := sha256.Sum256([]byte(verifier)) challenge := base64.RawURLEncoding.EncodeToString(hash[:]) - query := url.Values{"response_type": {"code"}, "client_id": {cfg.ClientID}, "redirect_uri": {cfg.RedirectURL}, "scope": {strings.Join(cfg.Scopes, " ")}, "state": {state}, "code_challenge": {challenge}, "code_challenge_method": {"S256"}} - http.SetCookie(w, a.oidcStateCookie(r, state, verifier)) + query := url.Values{"response_type": {"code"}, "client_id": {cfg.ClientID}, "redirect_uri": {cfg.RedirectURL}, "scope": {strings.Join(cfg.Scopes, " ")}, "state": {state}, "nonce": {nonce}, "code_challenge": {challenge}, "code_challenge_method": {"S256"}} + http.SetCookie(w, a.oidcStateCookie(r, state, verifier, nonce)) http.Redirect(w, r, doc.AuthorizationEndpoint+"?"+query.Encode(), http.StatusFound) } @@ -304,7 +334,7 @@ func (a *app) oidcCallback(w http.ResponseWriter, r *http.Request) { a.loginPage(w, r, "OIDC sign-in was cancelled: "+providerError, http.StatusUnauthorized) return } - state, verifier, err := a.readOIDCState(r) + state, verifier, nonce, err := a.readOIDCState(r) if err != nil || !hmac.Equal([]byte(state), []byte(r.URL.Query().Get("state"))) { a.loginPage(w, r, "OIDC state validation failed.", http.StatusForbidden) return @@ -320,13 +350,15 @@ func (a *app) oidcCallback(w http.ResponseWriter, r *http.Request) { return } form := url.Values{"grant_type": {"authorization_code"}, "code": {r.URL.Query().Get("code")}, "redirect_uri": {cfg.RedirectURL}, "client_id": {cfg.ClientID}, "client_secret": {cfg.ClientSecret}, "code_verifier": {verifier}} - tokenReq, err := http.NewRequest(http.MethodPost, doc.TokenEndpoint, strings.NewReader(form.Encode())) + ctx, cancel := context.WithTimeout(r.Context(), oidcHTTPTimeout) + defer cancel() + tokenReq, err := http.NewRequestWithContext(ctx, http.MethodPost, doc.TokenEndpoint, strings.NewReader(form.Encode())) if err != nil { a.loginPage(w, r, "OIDC token exchange failed.", http.StatusBadGateway) return } tokenReq.Header.Set("Content-Type", "application/x-www-form-urlencoded") - res, err := http.DefaultClient.Do(tokenReq) + res, err := oidcHTTPClient().Do(tokenReq) if err != nil { a.loginPage(w, r, "OIDC token exchange failed.", http.StatusBadGateway) return @@ -343,20 +375,20 @@ func (a *app) oidcCallback(w http.ResponseWriter, r *http.Request) { } var idClaims *oidcJWTClaims if tokens.IDToken != "" { - verified, err := a.verifyOIDCIDToken(cfg, doc, tokens.IDToken) + verified, err := a.verifyOIDCIDToken(cfg, doc, tokens.IDToken, nonce) if err != nil { a.loginPage(w, r, "OIDC ID token validation failed.", http.StatusUnauthorized) return } idClaims = &verified } - infoReq, err := http.NewRequest(http.MethodGet, doc.UserinfoEndpoint, nil) + infoReq, err := http.NewRequestWithContext(ctx, http.MethodGet, doc.UserinfoEndpoint, nil) if err != nil { a.loginPage(w, r, "OIDC user information failed.", http.StatusBadGateway) return } infoReq.Header.Set("Authorization", "Bearer "+tokens.AccessToken) - infoRes, err := http.DefaultClient.Do(infoReq) + infoRes, err := oidcHTTPClient().Do(infoReq) if err != nil { a.loginPage(w, r, "OIDC user information failed.", http.StatusBadGateway) return diff --git a/cmd/trace/oidc_hardening_test.go b/cmd/trace/oidc_hardening_test.go new file mode 100644 index 0000000..659b01c --- /dev/null +++ b/cmd/trace/oidc_hardening_test.go @@ -0,0 +1,87 @@ +package main + +import ( + "crypto/rand" + "crypto/rsa" + "net/http" + "net/url" + "testing" + "time" +) + +func TestOIDCAuthorizationRequestCarriesNonce(t *testing.T) { + a, root := newOIDCTestApp(t) + p := newFakeOIDCProvider(t) + configureOIDC(t, root, p, true) + req, _ := http.NewRequest(http.MethodGet, "/login/oidc", nil) + res := newRecorder() + a.ServeHTTP(res, req) + location, err := url.Parse(res.Header().Get("Location")) + if err != nil || len(location.Query().Get("nonce")) < 20 { + t.Fatalf("authorization request has no nonce: %s", res.Header().Get("Location")) + } +} + +// TestOIDCIDTokenValidation checks the ID token rules: the nonce from the +// authorization request, a non-empty matching issuer, and a signing key of +// at least 2048 bits. +func TestOIDCIDTokenValidation(t *testing.T) { + a, root := newOIDCTestApp(t) + p := newFakeOIDCProvider(t) + configureOIDC(t, root, p, true) + userinfo := map[string]any{"sub": "subject-1", "preferred_username": "oidc-user"} + + cases := map[string]func(map[string]any){ + "wrong nonce": func(c map[string]any) { c["nonce"] = "not-the-nonce" }, + "missing nonce": func(c map[string]any) { delete(c, "nonce") }, + "empty issuer": func(c map[string]any) { c["iss"] = "" }, + "other issuer": func(c map[string]any) { c["iss"] = "https://idp.example" }, + "expired": func(c map[string]any) { c["exp"] = time.Now().Add(-time.Minute).Unix() }, + "other client": func(c map[string]any) { c["aud"] = "someone-else" }, + } + for name, mutate := range cases { + claims := p.validIDToken("subject-1") + mutate(claims) + p.set(userinfo, claims) + if res := oidcSignIn(t, a, p); res.Code == http.StatusSeeOther || sessionCookieIssued(res) { + t.Fatalf("%s: invalid ID token was accepted", name) + } + } + p.set(userinfo, p.validIDToken("subject-1")) + if res := oidcSignIn(t, a, p); res.Code != http.StatusSeeOther { + t.Fatalf("valid ID token refused: %d %s", res.Code, res.Body.String()) + } + + weak, err := rsa.GenerateKey(rand.Reader, 1024) + if err != nil { + t.Fatal(err) + } + p.mu.Lock() + p.key = weak + p.mu.Unlock() + p.set(userinfo, p.validIDToken("subject-1")) + if res := oidcSignIn(t, a, p); res.Code == http.StatusSeeOther { + t.Fatal("ID token signed with a 1024-bit key was accepted") + } +} + +func TestOIDCProviderRequestsAreBounded(t *testing.T) { + previous := oidcHTTPTimeout + oidcHTTPTimeout = 300 * time.Millisecond + defer func() { oidcHTTPTimeout = previous }() + a, root := newOIDCTestApp(t) + p := newFakeOIDCProvider(t) + configureOIDC(t, root, p, true) + p.set(map[string]any{"sub": "subject-1", "preferred_username": "oidc-user"}, nil) + p.mu.Lock() + p.tokenDelay = 3 * time.Second + p.mu.Unlock() + started := time.Now() + res := oidcSignIn(t, a, p) + if elapsed := time.Since(started); elapsed > 2*time.Second { + t.Fatalf("a stalled token endpoint held the callback for %s", elapsed) + } + if res.Code == http.StatusSeeOther { + t.Fatal("sign-in succeeded although the token request timed out") + } +} diff --git a/cmd/trace/oidc_provider_test.go b/cmd/trace/oidc_provider_test.go index 3c236f5..afb246f 100644 --- a/cmd/trace/oidc_provider_test.go +++ b/cmd/trace/oidc_provider_test.go @@ -160,3 +160,5 @@ func sessionCookieIssued(res *httptest.ResponseRecorder) bool { } return false } + +func newRecorder() *httptest.ResponseRecorder { return httptest.NewRecorder() } From ff2924e9ee1ad471e22a8fa925bbe09324338615 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 27 Sep 2026 04:05:40 +0530 Subject: [PATCH 15/30] fix(api): compare the CSRF header in constant time and require it for sessions The X-Trace-CSRF header was compared with !=, and the whole check was skipped whenever an Authorization: Basic header was present, even when those credentials were wrong. A cookie-authenticated request that also carried any Basic header therefore bypassed CSRF protection (the test created a repository that way). Compare with subtle.ConstantTimeCompare and exempt only requests whose Basic credentials authenticate the same user as the resolved identity. Finding: F098 (CSRF part) --- README.md | 2 +- cmd/trace/api.go | 23 +++++++++++--- cmd/trace/api_csrf_test.go | 62 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 81 insertions(+), 6 deletions(-) create mode 100644 cmd/trace/api_csrf_test.go diff --git a/README.md b/README.md index c91d1d0..1e3c660 100644 --- a/README.md +++ b/README.md @@ -614,7 +614,7 @@ The same read operations are available from the bundled CLI. The token is read f ./trace api commits -url http://127.0.0.1:8787 -user admin -token-file ./data/admin-token team/project ``` -The browser session can read the API. A browser-session write must also send `X-Trace-CSRF` equal to the session user's CSRF value; Basic-auth API clients do not need that browser-only header. +The browser session can read the API. A browser-session write must also send `X-Trace-CSRF` equal to the session user's CSRF value; only requests whose Basic credentials authenticate the same user are exempt from that browser-only header. Admins can inspect the append-only audit ledger: diff --git a/cmd/trace/api.go b/cmd/trace/api.go index 49c5c86..0aad6dc 100644 --- a/cmd/trace/api.go +++ b/cmd/trace/api.go @@ -1,6 +1,7 @@ package main import ( + "crypto/subtle" "encoding/json" "errors" "fmt" @@ -53,11 +54,12 @@ func (a *app) api(w http.ResponseWriter, r *http.Request, db userDB) { return } if r.Method != http.MethodGet { - // Cookie-authenticated writes must carry an explicit CSRF header. Basic - // auth is intended for non-browser CLI/API clients and is already bound - // to the user's token. - if _, _, hasBasic := r.BasicAuth(); !hasBasic { - if r.Header.Get("X-Trace-CSRF") == "" || r.Header.Get("X-Trace-CSRF") != a.csrfFor(username) { + // Cookie-authenticated writes must carry an explicit CSRF header. Only + // a request whose Basic credentials themselves authenticate this user + // (a non-browser CLI/API client) is exempt; a Basic header that does + // not authenticate must not waive the check for a session cookie. + if !basicAuthenticates(r, db, username) { + if subtle.ConstantTimeCompare([]byte(r.Header.Get("X-Trace-CSRF")), []byte(a.csrfFor(username))) != 1 { apiError(w, http.StatusForbidden, "missing or invalid X-Trace-CSRF header") return } @@ -1534,6 +1536,17 @@ func (a *app) apiPullRequests(w http.ResponseWriter, r *http.Request, u userReco apiError(w, http.StatusMethodNotAllowed, "method not allowed") } +// basicAuthenticates reports whether r carries valid Basic credentials for +// username. +func basicAuthenticates(r *http.Request, db userDB, username string) bool { + name, token, ok := r.BasicAuth() + if !ok || name != username { + return false + } + _, valid := db.authenticate(name, token) + return valid +} + func roleFor(u userRecord, name string) string { if u.Admin { return "admin" diff --git a/cmd/trace/api_csrf_test.go b/cmd/trace/api_csrf_test.go new file mode 100644 index 0000000..b41cfc5 --- /dev/null +++ b/cmd/trace/api_csrf_test.go @@ -0,0 +1,62 @@ +package main + +import ( + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "testing" +) + +// TestAPIBrowserWritesRequireCSRF checks that a cookie-authenticated API +// write needs X-Trace-CSRF, and that attaching an arbitrary Basic header +// (which does not authenticate the request) does not waive that check. +func TestAPIBrowserWritesRequireCSRF(t *testing.T) { + root := filepath.Join(t.TempDir(), "node") + if err := initData(root); err != nil { + t.Fatal(err) + } + a, err := newApp(root) + if err != nil { + t.Fatal(err) + } + db, err := a.store.loadUsers() + if err != nil { + t.Fatal(err) + } + login := httptest.NewRecorder() + a.issueSession(login, httptest.NewRequest(http.MethodGet, "/", nil), "admin", db.Users["admin"]) + session := login.Result().Cookies()[0] + create := func(name string, mutate func(*http.Request)) int { + req := httptest.NewRequest(http.MethodPost, "/api/v1/repos", strings.NewReader(`{"name":"`+name+`"}`)) + req.Header.Set("Content-Type", "application/json") + mutate(req) + res := httptest.NewRecorder() + a.ServeHTTP(res, req) + return res.Code + } + if code := create("team/no-csrf", func(r *http.Request) { r.AddCookie(session) }); code != http.StatusForbidden { + t.Fatalf("cookie write without CSRF: %d", code) + } + if code := create("team/bogus-basic", func(r *http.Request) { + r.AddCookie(session) + r.SetBasicAuth("admin", "not-the-token") + }); code != http.StatusForbidden { + t.Fatalf("cookie write with a non-authenticating Basic header skipped CSRF: %d", code) + } + if code := create("team/wrong-csrf", func(r *http.Request) { + r.AddCookie(session) + r.Header.Set("X-Trace-CSRF", a.csrfFor("admin")+"x") + }); code != http.StatusForbidden { + t.Fatalf("cookie write with a wrong CSRF value: %d", code) + } + if code := create("team/with-csrf", func(r *http.Request) { + r.AddCookie(session) + r.Header.Set("X-Trace-CSRF", a.csrfFor("admin")) + }); code != http.StatusCreated && code != http.StatusOK { + t.Fatalf("cookie write with CSRF: %d", code) + } + if code := create("team/basic", func(r *http.Request) { r.SetBasicAuth("admin", adminToken(t, root)) }); code != http.StatusCreated && code != http.StatusOK { + t.Fatalf("Basic-authenticated write: %d", code) + } +} From 09535774d67c14747b60b01ce6c144bf007c3789 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 27 Sep 2026 04:07:06 +0530 Subject: [PATCH 16/30] fix(raw,pages): check blob size before reading raw and Pages files Raw file delivery ran `git cat-file blob` with cmd.Output() and Pages ran `git show`, buffering the entire blob before comparing it with the 8 MiB cap. Both routes are anonymous for public repositories, so repeated requests for a large blob could exhaust server memory (the test measured about 134 MB allocated to refuse a 48 MiB blob). Add readBlobLimited, shared by both routes: one `cat-file --batch-check` resolves the object, rejects non-blobs, and compares the size with the cap before any content is read; the content is then streamed through a limit. Pages no longer renders Git's tree listing for a directory path, and both routes resolve the configured branch as refs/heads/NAME so a tag with the same name cannot shadow it. Finding: F085 --- cmd/trace/pages.go | 12 ++--- cmd/trace/raw.go | 60 ++++++++++++++++++++--- cmd/trace/raw_limits_test.go | 95 ++++++++++++++++++++++++++++++++++++ 3 files changed, 154 insertions(+), 13 deletions(-) create mode 100644 cmd/trace/raw_limits_test.go diff --git a/cmd/trace/pages.go b/cmd/trace/pages.go index f237b55..fb2ac4c 100644 --- a/cmd/trace/pages.go +++ b/cmd/trace/pages.go @@ -107,15 +107,13 @@ func (s *store) pagesFile(repo, requestPath string) ([]byte, string, error) { if err := validatePagesPath(filePath); err != nil { return nil, "", err } - ref := config.Branch + ":" + filePath - cmd := exec.Command("git", "--git-dir", repoPath, "show", ref) - b, err := cmd.Output() - if err != nil { - return nil, "", os.ErrNotExist - } - if len(b) > 8<<20 { + b, err := readBlobLimited(repoPath, "refs/heads/"+config.Branch, filePath, maxRawFile) + if errors.Is(err, errBlobTooLarge) { return nil, "", errors.New("pages file exceeds 8 MiB limit") } + if err != nil { + return nil, "", err + } return b, mime.TypeByExtension(filepath.Ext(filePath)), nil } diff --git a/cmd/trace/raw.go b/cmd/trace/raw.go index c204bc4..93f16c1 100644 --- a/cmd/trace/raw.go +++ b/cmd/trace/raw.go @@ -9,6 +9,7 @@ import ( "os" "os/exec" "path/filepath" + "strconv" "strings" ) @@ -42,17 +43,64 @@ func (s *store) rawFile(repo, ref, file string) ([]byte, string, error) { if err := validateRawPath(file); err != nil { return nil, "", err } - cmd := exec.Command("git", "--git-dir", repoPath, "cat-file", "blob", ref+":"+file) - b, err := cmd.Output() - if err != nil { - return nil, "", os.ErrNotExist - } - if len(b) > maxRawFile { + b, err := readBlobLimited(repoPath, "refs/heads/"+ref, file, maxRawFile) + if errors.Is(err, errBlobTooLarge) { return nil, "", errors.New("raw file exceeds 8 MiB limit") } + if err != nil { + return nil, "", err + } return b, mime.TypeByExtension(filepath.Ext(file)), nil } +var errBlobTooLarge = errors.New("blob exceeds the size limit") + +// readBlobLimited returns the blob at rev:path in repoPath. It checks the +// object type and size with one `git cat-file --batch-check` before reading, +// streams the content through a limit, and never holds more than limit bytes, +// so an anonymous request for a huge public blob cannot exhaust memory. +// Missing paths and non-blob objects (trees) report os.ErrNotExist. +func readBlobLimited(repoPath, rev, path string, limit int64) ([]byte, error) { + if strings.ContainsAny(rev+path, "\n\r\x00") || strings.HasPrefix(rev, "-") { + return nil, os.ErrNotExist + } + check := exec.Command("git", "--git-dir", repoPath, "cat-file", "--batch-check=%(objectname) %(objecttype) %(objectsize)") + check.Stdin = strings.NewReader(rev + ":" + path + "\n") + out, err := check.Output() + if err != nil { + return nil, os.ErrNotExist + } + fields := strings.Fields(string(out)) + if len(fields) != 3 || fields[1] != "blob" { + return nil, os.ErrNotExist + } + size, err := strconv.ParseInt(fields[2], 10, 64) + if err != nil { + return nil, os.ErrNotExist + } + if size > limit { + return nil, errBlobTooLarge + } + cmd := exec.Command("git", "--git-dir", repoPath, "cat-file", "blob", fields[0]) + stdout, err := cmd.StdoutPipe() + if err != nil { + return nil, err + } + if err := cmd.Start(); err != nil { + return nil, err + } + b, readErr := io.ReadAll(io.LimitReader(stdout, limit+1)) + if int64(len(b)) > limit { + _ = cmd.Process.Kill() + _ = cmd.Wait() + return nil, errBlobTooLarge + } + if err := cmd.Wait(); err != nil || readErr != nil { + return nil, os.ErrNotExist + } + return b, nil +} + func writeRaw(w http.ResponseWriter, body []byte, contentType string) { if contentType != "" { w.Header().Set("Content-Type", contentType) diff --git a/cmd/trace/raw_limits_test.go b/cmd/trace/raw_limits_test.go new file mode 100644 index 0000000..00dc121 --- /dev/null +++ b/cmd/trace/raw_limits_test.go @@ -0,0 +1,95 @@ +package main + +import ( + "bytes" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" +) + +// commitFiles writes files into a new commit on main of repo's bare +// repository (bypassing the protection hook) and returns the repo path. +func commitFiles(t *testing.T, s *store, repo string, files map[string][]byte) string { + t.Helper() + work := t.TempDir() + gitTest(t, work, "init", "--initial-branch=main") + gitTest(t, work, "config", "user.name", "Admin") + gitTest(t, work, "config", "user.email", "admin@example.invalid") + for name, content := range files { + path := filepath.Join(work, filepath.FromSlash(name)) + if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, content, 0600); err != nil { + t.Fatal(err) + } + } + gitTest(t, work, "add", ".") + gitTest(t, work, "commit", "-m", "files") + repoPath, _ := s.repoPath(repo) + push := exec.Command("git", "-C", work, "push", "--force", repoPath, "main") + push.Env = append(os.Environ(), "TRACE_ADMIN=1") + if out, err := push.CombinedOutput(); err != nil { + t.Fatalf("push: %v\n%s", err, out) + } + return repoPath +} + +// TestRawAndPagesDoNotBufferOversizedBlobs checks that a blob larger than +// the 8 MiB cap is refused without reading it into memory: before the fix, +// the whole blob was buffered and only then compared with the cap. +func TestRawAndPagesDoNotBufferOversizedBlobs(t *testing.T) { + root := t.TempDir() + if err := initData(root); err != nil { + t.Fatal(err) + } + s, err := openStore(root) + if err != nil { + t.Fatal(err) + } + if err := s.createRepo("team/big", false); err != nil { + t.Fatal(err) + } + // Zeros compress well, so the repository stays small on disk. + big := bytes.Repeat([]byte{0}, 48<<20) + commitFiles(t, s, "team/big", map[string][]byte{"big.bin": big, "site/index.html": []byte("

ok

"), "site/dir/x.txt": []byte("x")}) + big = nil + if err := s.setPages("team/big", "main", "", true); err != nil { + t.Fatal(err) + } + measure := func(read func() error) (uint64, error) { + runtime.GC() + var before, after runtime.MemStats + runtime.ReadMemStats(&before) + err := read() + runtime.ReadMemStats(&after) + return after.TotalAlloc - before.TotalAlloc, err + } + allocated, err := measure(func() error { _, _, err := s.rawFile("team/big", "main", "big.bin"); return err }) + if err == nil || !strings.Contains(err.Error(), "8 MiB") { + t.Fatalf("oversized raw file was not refused: %v", err) + } + if allocated > 16<<20 { + t.Fatalf("raw read buffered %d bytes for an oversized blob", allocated) + } + allocated, err = measure(func() error { _, _, err := s.pagesFile("team/big", "big.bin"); return err }) + if err == nil || !strings.Contains(err.Error(), "8 MiB") { + t.Fatalf("oversized pages file was not refused: %v", err) + } + if allocated > 16<<20 { + t.Fatalf("pages read buffered %d bytes for an oversized blob", allocated) + } + if body, _, err := s.pagesFile("team/big", "site/index.html"); err != nil || string(body) != "

ok

" { + t.Fatalf("small pages file: %q %v", body, err) + } + // A directory path must not render Git's tree listing as page content. + if body, _, err := s.pagesFile("team/big", "site/dir"); err == nil { + t.Fatalf("pages served a tree listing: %q", body) + } + if body, _, err := s.rawFile("team/big", "main", "site"); err == nil { + t.Fatalf("raw served a tree: %q", body) + } +} From 33e2073e3685ea8a097353ce1926f4e507de2d61 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 27 Sep 2026 04:08:18 +0530 Subject: [PATCH 17/30] fix(raw,pages): never mark private repository content as publicly cacheable Every raw response (including private repositories and the Basic-auth API raw endpoint) and every Pages response was sent with `Cache-Control: public, max-age=60`. RFC 9111 section 3.5 lets shared caches store such responses even when the request carried credentials, so a caching proxy or CDN in front of Trace could serve a private file to an unauthenticated client for up to a minute. Public repositories keep `public, max-age=60`; private repositories now get `private, no-store` with `Vary: Cookie, Authorization`. Finding: F086 --- cmd/trace/pages.go | 2 +- cmd/trace/raw.go | 23 +++++++++++---- cmd/trace/raw_cache_test.go | 56 +++++++++++++++++++++++++++++++++++++ 3 files changed, 74 insertions(+), 7 deletions(-) create mode 100644 cmd/trace/raw_cache_test.go diff --git a/cmd/trace/pages.go b/cmd/trace/pages.go index fb2ac4c..ea4f677 100644 --- a/cmd/trace/pages.go +++ b/cmd/trace/pages.go @@ -164,7 +164,7 @@ func (a *app) pagesHTTP(w http.ResponseWriter, r *http.Request, db userDB) { if contentType != "" { w.Header().Set("Content-Type", contentType) } - w.Header().Set("Cache-Control", "public, max-age=60") + setRepositoryContentCache(w, public) _, _ = io.Copy(w, bytes.NewReader(b)) } diff --git a/cmd/trace/raw.go b/cmd/trace/raw.go index 93f16c1..87e5b75 100644 --- a/cmd/trace/raw.go +++ b/cmd/trace/raw.go @@ -101,15 +101,27 @@ func readBlobLimited(repoPath, rev, path string, limit int64) ([]byte, error) { return b, nil } -func writeRaw(w http.ResponseWriter, body []byte, contentType string) { +func writeRaw(w http.ResponseWriter, body []byte, contentType string, public bool) { if contentType != "" { w.Header().Set("Content-Type", contentType) } else { w.Header().Set("Content-Type", "application/octet-stream") } w.Header().Set("Content-Length", stringSize(len(body))) - w.Header().Set("Cache-Control", "public, max-age=60") - _, _ = io.Copy(w, strings.NewReader(string(body))) + setRepositoryContentCache(w, public) + _, _ = w.Write(body) +} + +// setRepositoryContentCache lets shared caches keep public repository content +// briefly, but marks private content private and no-store: responses to +// authenticated requests must never be served to someone else by a proxy. +func setRepositoryContentCache(w http.ResponseWriter, public bool) { + if public { + w.Header().Set("Cache-Control", "public, max-age=60") + return + } + w.Header().Set("Cache-Control", "private, no-store") + w.Header().Add("Vary", "Cookie, Authorization") } func stringSize(size int) string { @@ -170,7 +182,7 @@ func (a *app) rawHTTP(w http.ResponseWriter, r *http.Request, db userDB) { http.Error(w, err.Error(), http.StatusBadRequest) return } - writeRaw(w, body, contentType) + writeRaw(w, body, contentType, public) } func (a *app) apiRaw(w http.ResponseWriter, r *http.Request, repo, repoPath string) { @@ -192,6 +204,5 @@ func (a *app) apiRaw(w http.ResponseWriter, r *http.Request, repo, repoPath stri apiError(w, http.StatusBadRequest, err.Error()) return } - writeRaw(w, body, contentType) - _ = repoPath + writeRaw(w, body, contentType, isPublic(repoPath)) } diff --git a/cmd/trace/raw_cache_test.go b/cmd/trace/raw_cache_test.go new file mode 100644 index 0000000..318d475 --- /dev/null +++ b/cmd/trace/raw_cache_test.go @@ -0,0 +1,56 @@ +package main + +import ( + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "testing" +) + +// TestPrivateRawAndPagesAreNotSharedCacheable checks Cache-Control on raw and +// Pages responses: only public repositories may be stored by shared caches. +func TestPrivateRawAndPagesAreNotSharedCacheable(t *testing.T) { + root := filepath.Join(t.TempDir(), "node") + if err := initData(root); err != nil { + t.Fatal(err) + } + a, err := newApp(root) + if err != nil { + t.Fatal(err) + } + for _, name := range []string{"team/private", "team/public"} { + if err := a.store.createRepo(name, false); err != nil { + t.Fatal(err) + } + commitFiles(t, a.store, name, map[string][]byte{"index.html": []byte("

site

"), "notes.txt": []byte("notes")}) + if err := a.store.setPages(name, "main", "", true); err != nil { + t.Fatal(err) + } + } + if err := a.store.setPublic("team/public", true); err != nil { + t.Fatal(err) + } + token := adminToken(t, root) + get := func(path string) *httptest.ResponseRecorder { + req := httptest.NewRequest(http.MethodGet, path, nil) + req.SetBasicAuth("admin", token) + res := httptest.NewRecorder() + a.ServeHTTP(res, req) + if res.Code != http.StatusOK { + t.Fatalf("GET %s: %d %s", path, res.Code, res.Body.String()) + } + return res + } + for _, path := range []string{"/raw/team/private/notes.txt", "/api/v1/repos/team/private/raw?path=notes.txt", "/pages/team/private/"} { + cc := get(path).Header().Get("Cache-Control") + if strings.Contains(cc, "public") || !strings.Contains(cc, "private") || !strings.Contains(cc, "no-store") { + t.Fatalf("%s: private content has Cache-Control %q", path, cc) + } + } + for _, path := range []string{"/raw/team/public/notes.txt", "/pages/team/public/"} { + if cc := get(path).Header().Get("Cache-Control"); !strings.Contains(cc, "public") { + t.Fatalf("%s: public content has Cache-Control %q", path, cc) + } + } +} From 2415badbe42a52b86f8a4cf319147c871a434e7d Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 27 Sep 2026 04:10:49 +0530 Subject: [PATCH 18/30] fix(web): sandbox raw and Pages content and drop unsafe-inline scripts /raw and /pages served repository-controlled HTML and SVG with their detected content types on Trace's own origin, under the global CSP `script-src 'unsafe-inline'`. Any writer could commit an index.html whose inline script, opened by an administrator, ran same-origin: it could window.open('/app'), read the per-user CSRF token from the DOM, and submit privileged forms (delete, transfer, grants, user creation). - Raw responses (web and API) downgrade HTML, SVG, XML and JavaScript to text/plain and carry `Content-Security-Policy: sandbox` with no script plus nosniff; passive media types are unchanged. - Pages responses carry a CSP sandbox with allow-scripts but without allow-same-origin, so sites keep working with an opaque origin that cannot act as the signed-in user. - Trace's own pages now allow their static inline scripts by SHA-256 hash computed from the embedded templates, so script-src no longer contains 'unsafe-inline'. The three inline onsubmit confirm handlers became data-confirm attributes handled by the shared page script. Documented limitation: browsers do not send the session cookie with a sandboxed private Pages site's sub-resource requests. Finding: F081 --- README.md | 4 +- cmd/trace/csp.go | 83 ++++++++++++++++++++++++++ cmd/trace/csp_test.go | 133 ++++++++++++++++++++++++++++++++++++++++++ cmd/trace/main.go | 2 +- cmd/trace/pages.go | 1 + cmd/trace/raw.go | 7 +-- cmd/trace/repo.html | 2 +- cmd/trace/web.go | 4 +- 8 files changed, 225 insertions(+), 11 deletions(-) create mode 100644 cmd/trace/csp.go create mode 100644 cmd/trace/csp_test.go diff --git a/README.md b/README.md index 1e3c660..e3a18a5 100644 --- a/README.md +++ b/README.md @@ -113,7 +113,7 @@ curl https://git.example.com/raw/team/project/README.md?ref=main -token-file ./data/admin-token -ref main -path README.md team/project ``` -Public repositories allow anonymous raw reads; private repositories require repository access. Raw paths are branch-scoped, reject traversal, and are capped at 8 MiB. +Public repositories allow anonymous raw reads; private repositories require repository access. Raw paths are branch-scoped, reject traversal, and are capped at 8 MiB. Raw files are served as inert data: HTML, SVG, XML, and JavaScript are sent as `text/plain`, and every raw response carries a script-free `Content-Security-Policy: sandbox`. Change visibility from the CLI when needed: @@ -367,7 +367,7 @@ Trace can publish a committed branch as a Pages-style static site. Enable it loc -token-file ./data/admin-token -pages-branch main team/site ``` -The site is served at `/pages/OWNER/NAME/`. An `index.html` is used for directory requests, and an optional root such as `dist` can be configured. Public repositories have anonymous Pages reads; private repositories require repository access. Files are read from Git, capped at 8 MiB, and path traversal is rejected. Disable with `trace pages disable` or `trace api pages-disable`. +The site is served at `/pages/OWNER/NAME/`. An `index.html` is used for directory requests, and an optional root such as `dist` can be configured. Public repositories have anonymous Pages reads; private repositories require repository access. Files are read from Git, capped at 8 MiB, and path traversal is rejected. Pages are served on Trace's origin inside a CSP sandbox without `allow-same-origin`: site scripts run with an opaque origin and cannot read Trace pages, cookies, or form tokens as the signed-in user. Because of that sandbox, browsers do not send the Trace session cookie with the site's own sub-resource requests, so a private Pages site should inline its CSS, scripts, and images. Disable with `trace pages disable` or `trace api pages-disable`. For npm-style consumers, Trace exposes package metadata and tarball routes at `/npm/OWNER/REPO/PACKAGE` and `/npm/OWNER/REPO/PACKAGE/-/FILENAME`. A basic npm `PUT` payload with one `_attachments` tarball is accepted and stored immutably. Scoped package names, dist-tag mutation, npm auth token negotiation, and dependency proxying are not implemented. diff --git a/cmd/trace/csp.go b/cmd/trace/csp.go new file mode 100644 index 0000000..8fdb835 --- /dev/null +++ b/cmd/trace/csp.go @@ -0,0 +1,83 @@ +package main + +import ( + "crypto/sha256" + "encoding/base64" + "mime" + "net/http" + "regexp" + "sort" + "strings" +) + +// Trace serves three kinds of HTML on one origin, each with its own policy: +// +// - its own pages, whose only scripts are the static inline scripts in the +// embedded templates; they are allowed by SHA-256 hash, so script-src +// needs no 'unsafe-inline' and injected markup cannot run script; +// - raw repository files, which are data: active formats are downgraded to +// text/plain and every response is sandboxed with no script; +// - Pages sites, which may run their own scripts but only inside a CSP +// sandbox without allow-same-origin, so they get an opaque origin and +// cannot read Trace pages, cookies, or CSRF tokens as the signed-in user. + +var inlineScriptPattern = regexp.MustCompile(`(?s)`) + +// inlineScriptHashes returns the CSP hash sources for every inline script in +// the given template sources. +func inlineScriptHashes(sources ...string) []string { + seen := map[string]bool{} + var hashes []string + for _, source := range sources { + for _, match := range inlineScriptPattern.FindAllStringSubmatch(source, -1) { + sum := sha256.Sum256([]byte(match[1])) + hash := "'sha256-" + base64.StdEncoding.EncodeToString(sum[:]) + "'" + if !seen[hash] { + seen[hash] = true + hashes = append(hashes, hash) + } + } + } + sort.Strings(hashes) + return hashes +} + +var appContentSecurityPolicy = "default-src 'none'; script-src " + + strings.Join(inlineScriptHashes(pageStyle, dashboardHTML, repoHTML, loginHTML, string(landingHTML), agentHTML, commitHTML), " ") + + "; style-src 'unsafe-inline'; font-src 'self'; img-src 'self'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'" + +// rawContentSecurityPolicy renders raw files as inert documents. +const rawContentSecurityPolicy = "default-src 'none'; style-src 'unsafe-inline'; sandbox" + +// pagesContentSecurityPolicy lets a Pages site use its own scripts, styles, +// and assets while the sandbox gives it an opaque origin. +const pagesContentSecurityPolicy = "sandbox allow-scripts allow-forms allow-popups allow-modals allow-downloads; default-src 'self' data: blob:; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; media-src 'self' data: blob:; connect-src 'self'; form-action 'self'; base-uri 'self'; frame-ancestors 'none'" + +// rawContentType keeps passive media types and turns anything a browser could +// execute or render as a document (HTML, SVG, XML, JavaScript) into +// text/plain. +func rawContentType(detected string) string { + if detected == "" { + return "application/octet-stream" + } + mediaType, _, err := mime.ParseMediaType(detected) + if err != nil { + return "application/octet-stream" + } + switch { + case mediaType == "image/svg+xml": + return "text/plain; charset=utf-8" + case strings.HasPrefix(mediaType, "image/"), strings.HasPrefix(mediaType, "audio/"), strings.HasPrefix(mediaType, "video/"), strings.HasPrefix(mediaType, "font/"), + mediaType == "application/pdf", mediaType == "application/json", mediaType == "application/zip", mediaType == "application/gzip", mediaType == "application/wasm", mediaType == "application/octet-stream": + return detected + case strings.HasPrefix(mediaType, "text/"), strings.HasSuffix(mediaType, "+xml"), strings.HasSuffix(mediaType, "/xml"), strings.Contains(mediaType, "javascript"), strings.Contains(mediaType, "ecmascript"): + return "text/plain; charset=utf-8" + default: + return "application/octet-stream" + } +} + +func setRawSecurityHeaders(w http.ResponseWriter) { + w.Header().Set("Content-Security-Policy", rawContentSecurityPolicy) + w.Header().Set("X-Content-Type-Options", "nosniff") +} diff --git a/cmd/trace/csp_test.go b/cmd/trace/csp_test.go new file mode 100644 index 0000000..3860a8b --- /dev/null +++ b/cmd/trace/csp_test.go @@ -0,0 +1,133 @@ +package main + +import ( + "crypto/sha256" + "encoding/base64" + "net/http" + "net/http/httptest" + "path/filepath" + "regexp" + "strings" + "testing" +) + +var ( + renderedScriptPattern = regexp.MustCompile(`(?is)]*)>(.*?)`) + inlineHandlerPattern = regexp.MustCompile(`(?i)\son[a-z]+\s*=`) + scriptSrcDirectivePart = regexp.MustCompile(`script-src ([^;]*)`) +) + +// TestAppPagesNeedNoUnsafeInlineScripts renders Trace's own pages and checks +// that every inline script is allowed by hash, that script-src does not +// contain 'unsafe-inline', and that no inline event handlers remain. +func TestAppPagesNeedNoUnsafeInlineScripts(t *testing.T) { + root := filepath.Join(t.TempDir(), "node") + if err := initData(root); err != nil { + t.Fatal(err) + } + a, err := newApp(root) + if err != nil { + t.Fatal(err) + } + if err := a.store.createRepo("team/app", false); err != nil { + t.Fatal(err) + } + commitFiles(t, a.store, "team/app", map[string][]byte{"README.md": []byte("hello\n")}) + repoPath, _ := a.store.repoPath("team/app") + head, err := gitActionOutput(repoPath, "rev-parse", "refs/heads/main") + if err != nil { + t.Fatal(err) + } + token := adminToken(t, root) + paths := []string{"/", "/login", "/app", "/search?q=hello", "/settings/federation", "/settings/teams", "/repos/team/app", "/repos/team/app/actions", "/repos/team/app/agents", "/repos/team/app/issues", "/repos/team/app/releases", "/repos/team/app/packages", "/repos/team/app/projects", "/repos/team/app/settings/policy", "/repos/team/app/search?q=hello", "/repos/team/app/commits/" + head} + rendered := 0 + for _, path := range paths { + req := httptest.NewRequest(http.MethodGet, path, nil) + if path != "/" && path != "/login" { + req.SetBasicAuth("admin", token) + } + res := httptest.NewRecorder() + a.ServeHTTP(res, req) + if res.Code != http.StatusOK || !strings.HasPrefix(res.Header().Get("Content-Type"), "text/html") { + t.Fatalf("GET %s: %d %s", path, res.Code, res.Header().Get("Content-Type")) + } + rendered++ + csp := res.Header().Get("Content-Security-Policy") + scriptSrc := scriptSrcDirectivePart.FindStringSubmatch(csp) + if scriptSrc == nil || strings.Contains(scriptSrc[1], "unsafe-inline") { + t.Fatalf("%s: script-src must not allow unsafe-inline: %q", path, csp) + } + body := res.Body.String() + for _, match := range renderedScriptPattern.FindAllStringSubmatch(body, -1) { + if strings.TrimSpace(match[1]) != "" { + t.Fatalf("%s: unexpected script attributes %q", path, match[1]) + } + sum := sha256.Sum256([]byte(match[2])) + if hash := "'sha256-" + base64.StdEncoding.EncodeToString(sum[:]) + "'"; !strings.Contains(scriptSrc[1], hash) { + t.Fatalf("%s: inline script is not covered by the CSP (%s)", path, hash) + } + } + if handler := inlineHandlerPattern.FindString(body); handler != "" { + t.Fatalf("%s: inline event handler %q would be blocked by the CSP", path, handler) + } + } + if rendered != len(paths) { + t.Fatalf("rendered %d of %d pages", rendered, len(paths)) + } +} + +// TestRawAndPagesCannotRunScriptsAsTheUser covers the stored-XSS path: a +// writer commits HTML/SVG with script, and an administrator opens it through +// /raw or /pages on Trace's origin. +func TestRawAndPagesCannotRunScriptsAsTheUser(t *testing.T) { + root := filepath.Join(t.TempDir(), "node") + if err := initData(root); err != nil { + t.Fatal(err) + } + a, err := newApp(root) + if err != nil { + t.Fatal(err) + } + if err := a.store.createRepo("team/site", false); err != nil { + t.Fatal(err) + } + payload := []byte(``) + commitFiles(t, a.store, "team/site", map[string][]byte{"index.html": payload, "evil.svg": []byte(``), "evil.js": []byte("alert(1)"), "logo.png": []byte("\x89PNG\r\n\x1a\n")}) + if err := a.store.setPages("team/site", "main", "", true); err != nil { + t.Fatal(err) + } + token := adminToken(t, root) + get := func(path string) *httptest.ResponseRecorder { + req := httptest.NewRequest(http.MethodGet, path, nil) + req.SetBasicAuth("admin", token) + res := httptest.NewRecorder() + a.ServeHTTP(res, req) + if res.Code != http.StatusOK { + t.Fatalf("GET %s: %d", path, res.Code) + } + return res + } + for _, path := range []string{"/raw/team/site/index.html", "/raw/team/site/evil.svg", "/raw/team/site/evil.js", "/api/v1/repos/team/site/raw?path=index.html"} { + res := get(path) + if ct := res.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/plain") { + t.Fatalf("%s: active content served as %q", path, ct) + } + if csp := res.Header().Get("Content-Security-Policy"); !strings.Contains(csp, "sandbox") || strings.Contains(csp, "allow-scripts") { + t.Fatalf("%s: raw content is not sandboxed: %q", path, csp) + } + if res.Header().Get("X-Content-Type-Options") != "nosniff" { + t.Fatalf("%s: missing nosniff", path) + } + } + if ct := get("/raw/team/site/logo.png").Header().Get("Content-Type"); ct != "image/png" { + t.Fatalf("passive image type changed: %q", ct) + } + page := get("/pages/team/site/") + csp := page.Header().Get("Content-Security-Policy") + if !strings.HasPrefix(csp, "sandbox ") || strings.Contains(csp, "allow-same-origin") || strings.Contains(csp, "allow-top-navigation") { + t.Fatalf("Pages are not isolated in an opaque-origin sandbox: %q", csp) + } + if !strings.HasPrefix(page.Header().Get("Content-Type"), "text/html") || page.Body.String() != string(payload) { + t.Fatalf("Pages site content changed: %q %q", page.Header().Get("Content-Type"), page.Body.String()) + } +} diff --git a/cmd/trace/main.go b/cmd/trace/main.go index c95a0cd..ccc7c2c 100644 --- a/cmd/trace/main.go +++ b/cmd/trace/main.go @@ -1020,7 +1020,7 @@ func (a *app) ServeHTTP(w http.ResponseWriter, r *http.Request) { w.Header().Set("X-Content-Type-Options", "nosniff") w.Header().Set("X-Frame-Options", "DENY") w.Header().Set("Referrer-Policy", "same-origin") - w.Header().Set("Content-Security-Policy", "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; font-src 'self'; img-src 'self'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'") + w.Header().Set("Content-Security-Policy", appContentSecurityPolicy) switch { case r.URL.Path == "/.well-known/trace/ssh-host-key" && r.Method == http.MethodGet: publicKey, fingerprint, err := sshHostKeyInfo(a.store.root) diff --git a/cmd/trace/pages.go b/cmd/trace/pages.go index ea4f677..0997666 100644 --- a/cmd/trace/pages.go +++ b/cmd/trace/pages.go @@ -164,6 +164,7 @@ func (a *app) pagesHTTP(w http.ResponseWriter, r *http.Request, db userDB) { if contentType != "" { w.Header().Set("Content-Type", contentType) } + w.Header().Set("Content-Security-Policy", pagesContentSecurityPolicy) setRepositoryContentCache(w, public) _, _ = io.Copy(w, bytes.NewReader(b)) } diff --git a/cmd/trace/raw.go b/cmd/trace/raw.go index 87e5b75..7d466f4 100644 --- a/cmd/trace/raw.go +++ b/cmd/trace/raw.go @@ -102,11 +102,8 @@ func readBlobLimited(repoPath, rev, path string, limit int64) ([]byte, error) { } func writeRaw(w http.ResponseWriter, body []byte, contentType string, public bool) { - if contentType != "" { - w.Header().Set("Content-Type", contentType) - } else { - w.Header().Set("Content-Type", "application/octet-stream") - } + w.Header().Set("Content-Type", rawContentType(contentType)) + setRawSecurityHeaders(w) w.Header().Set("Content-Length", stringSize(len(body))) setRepositoryContentCache(w, public) _, _ = w.Write(body) diff --git a/cmd/trace/repo.html b/cmd/trace/repo.html index 1eb9f58..d105e31 100644 --- a/cmd/trace/repo.html +++ b/cmd/trace/repo.html @@ -96,7 +96,7 @@

{{.Name}}

Repository tools

Fork repository

{{if .CanMaintain}}

Topics

{{end}}
- {{if .IsAdmin}}
Repository settings

Repository lifecycle

{{end}} + {{if .IsAdmin}}
Repository settings

Repository lifecycle

{{end}} diff --git a/cmd/trace/web.go b/cmd/trace/web.go index 30b520d..30dd505 100644 --- a/cmd/trace/web.go +++ b/cmd/trace/web.go @@ -28,7 +28,7 @@ const pageStyle = `` +` type repoLink struct { Name string @@ -66,7 +66,7 @@ type federationConflictPageView struct { Approved bool } -var federationPageTemplate = template.Must(template.New("federation").Parse(`Federation · Trace` + pageStyle + federationPageStyle + `

Trace federation

Back to workspace

Signed peer replication for administrators.

{{if .Message}}
{{.Message}}
{{end}}

Configured peers

{{if .Peers}}
    {{range .Peers}}
  • {{.Repo}} ← {{.Source}} ({{.Username}})
  • {{end}}
{{else}}

No peers configured.

{{end}}{{if .Peers}}
{{end}}

Add or replace a peer

Replication conflicts

A conflicting ref remains on the mirror until an administrator approves the exact signed source object ID and retries sync. Approving a source deletion removes that mirror ref on the next successful sync.

{{range .Conflicts}}

{{.Repo}} · {{.Ref}}

Source: {{.Source}}

Mirror
{{.LocalSHA}}
Signed source
{{if .RemoteSHA}}{{.RemoteSHA}}{{else}}deleted{{end}}
{{if .Approved}}

Approved by {{.ApprovedBy}}. Retry signed peer sync to apply.

{{else}}
{{end}}
{{else}}

No unresolved conflicts.

{{end}}

Pinned source identities

Trace pins each source node ID at first verified sync and rejects a changed identity. Verify any replacement ID through a trusted channel before forgetting a pin.

{{range .Trust}}

{{.Repo}}

{{.Source}}

Node ID: {{.NodeID}}

{{else}}

No source identities pinned yet.

{{end}}
`)) +var federationPageTemplate = template.Must(template.New("federation").Parse(`Federation · Trace` + pageStyle + federationPageStyle + `

Trace federation

Back to workspace

Signed peer replication for administrators.

{{if .Message}}
{{.Message}}
{{end}}

Configured peers

{{if .Peers}}
    {{range .Peers}}
  • {{.Repo}} ← {{.Source}} ({{.Username}})
  • {{end}}
{{else}}

No peers configured.

{{end}}{{if .Peers}}
{{end}}

Add or replace a peer

Replication conflicts

A conflicting ref remains on the mirror until an administrator approves the exact signed source object ID and retries sync. Approving a source deletion removes that mirror ref on the next successful sync.

{{range .Conflicts}}

{{.Repo}} · {{.Ref}}

Source: {{.Source}}

Mirror
{{.LocalSHA}}
Signed source
{{if .RemoteSHA}}{{.RemoteSHA}}{{else}}deleted{{end}}
{{if .Approved}}

Approved by {{.ApprovedBy}}. Retry signed peer sync to apply.

{{else}}
{{end}}
{{else}}

No unresolved conflicts.

{{end}}

Pinned source identities

Trace pins each source node ID at first verified sync and rejects a changed identity. Verify any replacement ID through a trusted channel before forgetting a pin.

{{range .Trust}}

{{.Repo}}

{{.Source}}

Node ID: {{.NodeID}}

{{else}}

No source identities pinned yet.

{{end}}
`)) func (a *app) federationPage(w http.ResponseWriter, r *http.Request, username string, u userRecord) { if !u.Admin { From 7abb317c706a70b6eb6cb16ed2838eb07dc00f93 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 27 Sep 2026 04:14:55 +0530 Subject: [PATCH 19/30] fix(ratelimit): throttle per client in memory and support trusted proxies The limiter keyed buckets by client IP plus URL path, so the documented "300 requests per minute per client" was really per path and a client could evade it by varying paths. It used only RemoteAddr, so behind the reverse proxy the README requires, every user shared the proxy's bucket. And every HTTP request, static assets included, took a file lock, read and unmarshalled rate-state.json, then marshalled, fsynced and renamed it (up to 10,000 entries). - Key HTTP buckets by client only (300/min), with a separate sign-in POST bucket (20/min); SSH stays 60 connections/min per host. - Keep counters in memory, shared by all limiters for the same data directory in the process, and prune expired windows once a minute. - `trace serve -trusted-proxy IP|CIDR,...`: for those peers, use the rightmost X-Forwarded-For entry that is not a trusted proxy, then X-Real-IP; headers from other peers are ignored. - Group IPv6 clients by /64. Counters are no longer shared between separate processes; the README now says so and points to the reverse proxy's limiter for that. Finding: F084 --- FEATURES.md | 4 +- README.md | 10 +- cmd/trace/main.go | 10 +- cmd/trace/ratelimit.go | 207 +++++++++++++++++++++-------- cmd/trace/ratelimit_client_test.go | 91 +++++++++++++ 5 files changed, 261 insertions(+), 61 deletions(-) create mode 100644 cmd/trace/ratelimit_client_test.go diff --git a/FEATURES.md b/FEATURES.md index 0268787..7b1c5d6 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -34,8 +34,8 @@ This is the honest baseline for the current monorepo. “All features from the t | Webhooks | Implemented, signed delivery with retries/history | Admin API/CLI configuration; HTTPS or loopback HTTP; HMAC-SHA256 signatures; three attempts and persisted delivery records | | Releases, assets, archive downloads, and Pages | Implemented | Tag-backed releases support bounded 100 MiB asset upload/list/download, gzip archives use `git archive`, and Pages serves committed branch files with public/private access and path validation | | Packages, registries, Git LFS | Generic artifacts, basic npm/PyPI interoperability, and basic Git LFS implemented | Authenticated package publish/list/download, one-attachment npm publish, basic PyPI multipart upload and simple-index reads, and LFS batch/upload/download; SHA-256 verification and 100 MiB object caps; no wheel metadata or distributed object store | -| SSH Git transport | Implemented, key-authenticated, throttled, discoverable, and rotatable | `trace user key`, persisted host key, `trace ssh host-key`, `trace ssh rotate-host-key`, admin API rotation, `/.well-known/trace/ssh-host-key`, `git-upload-pack`/`git-receive-pack`, and a shared 60-connections/minute per-host limiter; rotation requires a Trace restart to load the new signer; disabled by default | -| SSO, 2FA, SCIM, audit log | Optional OIDC authorization-code login with PKCE; returned RS256 ID tokens are checked against discovered JWKS, while userinfo-only providers remain supported; TOTP 2FA, SCIM users and groups mapped to teams, audit log, and shared file-backed rate limits implemented; SAML remains missing | `trace sso oidc`, `/login/oidc`, `/login/oidc/callback`, `/scim/v2/Users`, `/scim/v2/Groups`, durable team membership, append-only `audit.jsonl`, admin API, and `429` request throttling shared across local processes | +| SSH Git transport | Implemented, key-authenticated, throttled, discoverable, and rotatable | `trace user key`, persisted host key, `trace ssh host-key`, `trace ssh rotate-host-key`, admin API rotation, `/.well-known/trace/ssh-host-key`, `git-upload-pack`/`git-receive-pack`, and a 60-connections/minute per-host limiter; rotation requires a Trace restart to load the new signer; disabled by default | +| SSO, 2FA, SCIM, audit log | Optional OIDC authorization-code login with PKCE; returned RS256 ID tokens are checked against discovered JWKS, while userinfo-only providers remain supported; TOTP 2FA, SCIM users and groups mapped to teams, audit log, and in-memory per-client rate limits (with trusted reverse-proxy support) implemented; SAML remains missing | `trace sso oidc`, `/login/oidc`, `/login/oidc/callback`, `/scim/v2/Users`, `/scim/v2/Groups`, durable team membership, append-only `audit.jsonl`, admin API, and per-client `429` request throttling (`-trusted-proxy` for reverse proxies) | | Search and code indexing | Implemented, bounded persistent code index with live fallback; literal code, commit, and agent session search | Repository and workspace API/CLI/web queries return code, branch-scoped commit messages, and redacted session/checkpoint matches; commit results open a read-only diff page; each repository scans at most 1,000 recent commits and returns 100 matches; workspace search pages through ten authorized repositories at a time and respects team grants; code index rebuilds via API and refreshes on HTTP pushes; no semantic search | | Agent session/checkpoint storage | Implemented, structured records with opt-in signed portability and narrow Codex/Claude Code/Gemini CLI/Cursor adapters | API, CLI, and browser page; sessions/checkpoints link to commits, redact common token/password/secret assignments, and can be exported/imported as Ed25519-signed bundles or explicitly published in private Git refs after expected node ID and target commit verification; supported completion notifications can queue metadata until the observed HEAD exists on the server; raw transcripts, automatic Git publication, and authorship verification are not implemented | | Signed federation identity and peer sync | Implemented, configured peers, with explicit conflict decisions and source identity pinning | Persisted Ed25519 identity, signed full-ref manifests, first-use source node ID pinning with CLI/API/web inspection and explicit forget, exact probe ref comparison before an atomic mirror ref transaction, opt-in scheduling, non-fast-forward and changed-tag conflict detection, durable conflict records, and exact-object-ID source acceptance for branch, tag, or deletion conflicts; first contact still requires out-of-band verification, while network discovery and multi-writer reconciliation remain external | diff --git a/README.md b/README.md index e3a18a5..b5c42ba 100644 --- a/README.md +++ b/README.md @@ -134,9 +134,15 @@ SSH is optional and disabled unless you pass `-ssh-listen`. Add an authorized pu git clone ssh://admin@127.0.0.1:2222/team/project.git ``` -The SSH host key is persisted at `data/ssh/host_ed25519`. Trace accepts Git smart-SSH commands only; shell access is not provided. New SSH connections are throttled to 60 per minute per remote host using the shared local rate-state file. Operators can print the key and fingerprint with `./trace ssh host-key -data ./data`, or retrieve JSON from `/.well-known/trace/ssh-host-key` before provisioning `known_hosts`. +The SSH host key is persisted at `data/ssh/host_ed25519`. Trace accepts Git smart-SSH commands only; shell access is not provided. New SSH connections are throttled to 60 per minute per remote host. Operators can print the key and fingerprint with `./trace ssh host-key -data ./data`, or retrieve JSON from `/.well-known/trace/ssh-host-key` before provisioning `known_hosts`. -HTTP requests are throttled per client address. Normal paths allow 300 requests per minute and login POSTs allow 20; rejected requests return `429`, `Retry-After`, and `X-RateLimit-*` headers. Counters are persisted in `data/rate-state.json` under a file lock, so multiple Trace processes sharing one data directory observe the same limits. This does not coordinate separate nodes; use a shared reverse-proxy limiter across VPS instances. +HTTP requests are throttled per client address across all paths: 300 requests per minute, plus a separate limit of 20 sign-in POSTs per minute; rejected requests return `429`, `Retry-After`, and `X-RateLimit-*` headers. IPv6 clients are grouped by their /64 prefix. Counters are kept in memory by each Trace process and reset when it restarts; they are not shared between processes or nodes, so use the reverse proxy's limiter when you run several. + +Behind a reverse proxy every request arrives from the proxy's address, so tell Trace which peers are proxies; their `X-Forwarded-For` (rightmost untrusted entry) or `X-Real-IP` header then identifies the client. Headers from any other peer are ignored: + +```sh +./trace serve -data ./data -trusted-proxy 127.0.0.1,::1 +``` ## OIDC single sign-on diff --git a/cmd/trace/main.go b/cmd/trace/main.go index ccc7c2c..b227964 100644 --- a/cmd/trace/main.go +++ b/cmd/trace/main.go @@ -13,6 +13,7 @@ import ( "net" "net/http" "net/http/cgi" + "net/netip" "net/url" "os" "os/exec" @@ -56,6 +57,7 @@ func run(args []string) error { sshAddr := fs.String("ssh-listen", "", "SSH Git listen address (disabled by default)") federationInterval := fs.Duration("federation-interval", 0, "background federation peer sync interval (disabled by default)") actionsInterval := fs.Duration("actions-interval", 0, "scheduled workflow evaluation interval (disabled by default)") + trustedProxy := fs.String("trusted-proxy", "", "comma-separated IPs or CIDR prefixes of reverse proxies whose X-Forwarded-For/X-Real-IP identify clients for rate limiting") actionsMode := fs.String("actions", actionsModeSandboxed, "CI runner policy: off, sandboxed (only workflows with \"sandbox\":true), or trusted (also run unsandboxed workflows as the Trace service account)") if err := fs.Parse(args[1:]); err != nil { return err @@ -73,7 +75,11 @@ func run(args []string) error { if err != nil { return err } - return serve(serveOptions{data: *data, addr: *addr, sshAddr: *sshAddr, federationInterval: *federationInterval, actionsInterval: *actionsInterval, actionsMode: mode}) + proxies, err := parseTrustedProxies(*trustedProxy) + if err != nil { + return err + } + return serve(serveOptions{data: *data, addr: *addr, sshAddr: *sshAddr, federationInterval: *federationInterval, actionsInterval: *actionsInterval, actionsMode: mode, trustedProxies: proxies}) case "repo": if len(args) < 2 || (args[1] != "create" && args[1] != "import" && args[1] != "fork" && args[1] != "archive" && args[1] != "restore" && args[1] != "delete" && args[1] != "transfer" && args[1] != "topics") { return errors.New("usage: trace repo ...") @@ -951,6 +957,7 @@ type serveOptions struct { federationInterval time.Duration actionsInterval time.Duration actionsMode string + trustedProxies []netip.Prefix } func serve(opts serveOptions) error { @@ -961,6 +968,7 @@ func serve(opts serveOptions) error { return err } a.store.actionsMode = opts.actionsMode + a.limiter.trustedProxies = opts.trustedProxies log.Printf("trace CI actions mode: %s", a.store.actionsPolicy()) if err := a.store.ensureHooks(); err != nil { return err diff --git a/cmd/trace/ratelimit.go b/cmd/trace/ratelimit.go index 73bcc88..60e96f9 100644 --- a/cmd/trace/ratelimit.go +++ b/cmd/trace/ratelimit.go @@ -1,24 +1,50 @@ package main import ( - "encoding/json" + "errors" "net" "net/http" - "os" - "path/filepath" + "net/netip" "strconv" "strings" - "syscall" + "sync" "time" ) +// Trace throttles each client per minute: 300 HTTP requests across all paths, +// 20 sign-in POSTs, and 60 new SSH connections. Counters live in memory and +// are shared by every limiter for the same data directory within one process; +// they reset on restart and are not coordinated between processes or nodes +// (use the reverse proxy's limiter for that). + +const ( + rateWindowLength = time.Minute + httpRateLimit = 300 + loginRateLimit = 20 + sshRateLimit = 60 +) + type rateWindow struct { Started time.Time Count int } +type rateState struct { + mu sync.Mutex + windows map[string]rateWindow + lastPrune time.Time +} + +var ( + rateStatesMu sync.Mutex + rateStates = map[string]*rateState{} +) + type rateLimiter struct { - root string + state *rateState + // trustedProxies are peers whose X-Forwarded-For / X-Real-IP headers + // identify the real client. Headers from any other peer are ignored. + trustedProxies []netip.Prefix } type rateDecision struct { @@ -29,30 +55,117 @@ type rateDecision struct { } func newRateLimiter(root string) *rateLimiter { - return &rateLimiter{root: root} + rateStatesMu.Lock() + defer rateStatesMu.Unlock() + state, ok := rateStates[root] + if !ok { + state = &rateState{windows: map[string]rateWindow{}} + rateStates[root] = state + } + return &rateLimiter{state: state} } -func requestClientKey(r *http.Request) string { +// parseTrustedProxies parses a comma-separated list of IP addresses and CIDR +// prefixes for `trace serve -trusted-proxy`. +func parseTrustedProxies(value string) ([]netip.Prefix, error) { + var out []netip.Prefix + for _, item := range strings.Split(value, ",") { + item = strings.TrimSpace(item) + if item == "" { + continue + } + if prefix, err := netip.ParsePrefix(item); err == nil { + out = append(out, prefix.Masked()) + continue + } + addr, err := netip.ParseAddr(item) + if err != nil { + return nil, errors.New("-trusted-proxy entries must be IP addresses or CIDR prefixes") + } + addr = addr.Unmap() + out = append(out, netip.PrefixFrom(addr, addr.BitLen())) + } + return out, nil +} + +func (l *rateLimiter) trusted(addr netip.Addr) bool { + for _, prefix := range l.trustedProxies { + if prefix.Contains(addr) { + return true + } + } + return false +} + +// clientAddr returns the address a request came from: the peer, or, when the +// peer is a trusted proxy, the rightmost X-Forwarded-For entry that is not +// itself a trusted proxy (entries further left are client-controlled), then +// X-Real-IP. +func (l *rateLimiter) clientAddr(r *http.Request) (netip.Addr, bool) { host, _, err := net.SplitHostPort(r.RemoteAddr) - if err == nil && host != "" { - return host + if err != nil { + host = r.RemoteAddr + } + peer, err := netip.ParseAddr(strings.TrimSpace(host)) + if err != nil { + return netip.Addr{}, false + } + peer = peer.Unmap() + if !l.trusted(peer) { + return peer, true + } + var hops []string + for _, header := range r.Header.Values("X-Forwarded-For") { + hops = append(hops, strings.Split(header, ",")...) } - if strings.TrimSpace(r.RemoteAddr) != "" { - return strings.TrimSpace(r.RemoteAddr) + for i := len(hops) - 1; i >= 0; i-- { + addr, err := netip.ParseAddr(strings.TrimSpace(hops[i])) + if err != nil { + break + } + if addr = addr.Unmap(); !l.trusted(addr) { + return addr, true + } + } + if addr, err := netip.ParseAddr(strings.TrimSpace(r.Header.Get("X-Real-IP"))); err == nil { + return addr.Unmap(), true + } + return peer, true +} + +// clientKey identifies a client for throttling. IPv6 clients are grouped by +// their /64 so rotating addresses within one allocation does not evade it. +func (l *rateLimiter) clientKey(r *http.Request) string { + addr, ok := l.clientAddr(r) + if !ok { + if strings.TrimSpace(r.RemoteAddr) != "" { + return strings.TrimSpace(r.RemoteAddr) + } + return "local-unknown" + } + return addrKey(addr) +} + +func addrKey(addr netip.Addr) string { + if addr.Is6() && !addr.Is4In6() { + prefix, err := addr.Prefix(64) + if err == nil { + return prefix.String() + } } - return "local-unknown" + return addr.String() } -func requestRateLimit(r *http.Request) int { +func requestRateLimit(r *http.Request) (string, int) { if r.URL.Path == "/login" && r.Method == http.MethodPost { - return 20 + return "login", loginRateLimit } - return 300 + return "http", httpRateLimit } func (l *rateLimiter) allow(r *http.Request) rateDecision { - limit := requestRateLimit(r) - return l.allowKey(requestClientKey(r)+"\x00http\x00"+r.URL.Path, limit) + class, limit := requestRateLimit(r) + return l.allowKey(class+"\x00"+l.clientKey(r), limit) } func (l *rateLimiter) allowSSH(remoteAddr string) rateDecision { @@ -60,58 +173,40 @@ func (l *rateLimiter) allowSSH(remoteAddr string) rateDecision { if err != nil || host == "" { host = strings.TrimSpace(remoteAddr) } - if host == "" { - host = "local-unknown" + key := host + if addr, err := netip.ParseAddr(host); err == nil { + key = addrKey(addr.Unmap()) + } + if key == "" { + key = "local-unknown" } - return l.allowKey("ssh\x00"+host, 60) + return l.allowKey("ssh\x00"+key, sshRateLimit) } func (l *rateLimiter) allowKey(key string, limit int) rateDecision { now := time.Now().UTC() - statePath := filepath.Join(l.root, "rate-state.json") - lock, err := os.OpenFile(filepath.Join(l.root, ".rate-state.lock"), os.O_CREATE|os.O_RDWR, 0600) - if err != nil { - return rateDecision{Allowed: true, Limit: limit, Remaining: limit, Reset: now.Add(time.Minute)} - } - defer lock.Close() - if syscall.Flock(int(lock.Fd()), syscall.LOCK_EX) != nil { - return rateDecision{Allowed: true, Limit: limit, Remaining: limit, Reset: now.Add(time.Minute)} - } - defer syscall.Flock(int(lock.Fd()), syscall.LOCK_UN) - windows := make(map[string]rateWindow) - if b, readErr := os.ReadFile(statePath); readErr == nil { - _ = json.Unmarshal(b, &windows) - } - item, ok := windows[key] - if !ok || item.Started.After(now) || now.Sub(item.Started) >= time.Minute { - item = rateWindow{Started: now} - } - item.Count++ - windows[key] = item - if len(windows) > 10000 { - for oldKey, old := range windows { - if now.Sub(old.Started) > 2*time.Minute { - delete(windows, oldKey) + st := l.state + st.mu.Lock() + defer st.mu.Unlock() + if now.Sub(st.lastPrune) >= rateWindowLength { + for oldKey, old := range st.windows { + if now.Sub(old.Started) >= rateWindowLength || old.Started.After(now) { + delete(st.windows, oldKey) } } + st.lastPrune = now } - if b, marshalErr := json.Marshal(windows); marshalErr == nil { - if tmp, createErr := os.CreateTemp(l.root, ".rate-state-"); createErr == nil { - name := tmp.Name() - if tmp.Chmod(0600) == nil { - _, _ = tmp.Write(append(b, '\n')) - _ = tmp.Sync() - } - _ = tmp.Close() - _ = os.Rename(name, statePath) - _ = os.Remove(name) - } + item, ok := st.windows[key] + if !ok || item.Started.After(now) || now.Sub(item.Started) >= rateWindowLength { + item = rateWindow{Started: now} } + item.Count++ + st.windows[key] = item remaining := limit - item.Count if remaining < 0 { remaining = 0 } - return rateDecision{Allowed: item.Count <= limit, Limit: limit, Remaining: remaining, Reset: item.Started.Add(time.Minute)} + return rateDecision{Allowed: item.Count <= limit, Limit: limit, Remaining: remaining, Reset: item.Started.Add(rateWindowLength)} } func writeRateHeaders(w http.ResponseWriter, decision rateDecision) { diff --git a/cmd/trace/ratelimit_client_test.go b/cmd/trace/ratelimit_client_test.go new file mode 100644 index 0000000..0920bb4 --- /dev/null +++ b/cmd/trace/ratelimit_client_test.go @@ -0,0 +1,91 @@ +package main + +import ( + "net/http" + "net/http/httptest" + "net/netip" + "os" + "path/filepath" + "strconv" + "testing" +) + +func rateRequest(path, remote string, headers map[string]string) *http.Request { + req := httptest.NewRequest(http.MethodGet, path, nil) + req.RemoteAddr = remote + for k, v := range headers { + req.Header.Set(k, v) + } + return req +} + +// TestRateLimitIsPerClientNotPerPath reproduces the evasion: varying the URL +// path used to give each request a fresh bucket. +func TestRateLimitIsPerClientNotPerPath(t *testing.T) { + root := t.TempDir() + limiter := newRateLimiter(root) + for i := 0; i < 300; i++ { + if d := limiter.allow(rateRequest("/repos/team/p"+strconv.Itoa(i), "203.0.113.20:5000", nil)); !d.Allowed { + t.Fatalf("request %d blocked early", i+1) + } + } + if d := limiter.allow(rateRequest("/repos/team/another-path", "203.0.113.20:5000", nil)); d.Allowed { + t.Fatal("varying the path evaded the per-client limit") + } + if d := limiter.allow(rateRequest("/app", "203.0.113.21:5000", nil)); !d.Allowed { + t.Fatal("a different client shared the exhausted bucket") + } + if _, err := os.Stat(filepath.Join(root, "rate-state.json")); !os.IsNotExist(err) { + t.Fatalf("limiter still rewrites rate-state.json per request: %v", err) + } +} + +func TestRateLimitTrustedProxyForwardedFor(t *testing.T) { + proxy := []netip.Prefix{netip.MustParsePrefix("10.0.0.0/8")} + limiter := newRateLimiter(t.TempDir()) + limiter.trustedProxies = proxy + cases := []struct { + remote string + headers map[string]string + want string + }{ + {"10.0.0.5:1234", map[string]string{"X-Forwarded-For": "198.51.100.7"}, "198.51.100.7"}, + // A client cannot choose its key by prepending addresses: the + // rightmost address not belonging to a trusted proxy wins. + {"10.0.0.5:1234", map[string]string{"X-Forwarded-For": "192.0.2.99, 198.51.100.7, 10.0.0.9"}, "198.51.100.7"}, + {"10.0.0.5:1234", map[string]string{"X-Real-IP": "198.51.100.8"}, "198.51.100.8"}, + // Headers from an untrusted peer are ignored. + {"203.0.113.9:1234", map[string]string{"X-Forwarded-For": "198.51.100.7"}, "203.0.113.9"}, + // IPv6 clients are grouped by /64. + {"[2001:db8:1:2:aaaa::1]:443", nil, "2001:db8:1:2::/64"}, + {"[2001:db8:1:2:bbbb::9]:443", nil, "2001:db8:1:2::/64"}, + {"[::ffff:198.51.100.10]:443", nil, "198.51.100.10"}, + } + for _, tc := range cases { + if got := limiter.clientKey(rateRequest("/", tc.remote, tc.headers)); got != tc.want { + t.Fatalf("clientKey(%s, %v) = %q, want %q", tc.remote, tc.headers, got, tc.want) + } + } + for i := 0; i < 300; i++ { + limiter.allow(rateRequest("/app", "10.0.0.5:1", map[string]string{"X-Forwarded-For": "198.51.100.30"})) + } + if d := limiter.allow(rateRequest("/app", "10.0.0.5:1", map[string]string{"X-Forwarded-For": "198.51.100.30"})); d.Allowed { + t.Fatal("client behind the proxy was not limited") + } + if d := limiter.allow(rateRequest("/app", "10.0.0.5:1", map[string]string{"X-Forwarded-For": "198.51.100.31"})); !d.Allowed { + t.Fatal("one busy client behind the proxy throttled everyone") + } +} + +func TestParseTrustedProxies(t *testing.T) { + got, err := parseTrustedProxies("10.0.0.0/8, 127.0.0.1,::1") + if err != nil || len(got) != 3 || got[1].Bits() != 32 || got[2].Bits() != 128 { + t.Fatalf("parseTrustedProxies: %v %v", got, err) + } + if _, err := parseTrustedProxies("not-an-ip"); err == nil { + t.Fatal("invalid proxy accepted") + } + if got, err := parseTrustedProxies(""); err != nil || len(got) != 0 { + t.Fatalf("empty list: %v %v", got, err) + } +} From 4ce72d76571b98509d7d94cc50515bd1455efb28 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 27 Sep 2026 04:16:49 +0530 Subject: [PATCH 20/30] fix(scim): refuse passwords and support filters, paging, and member removal - A SCIM `password` became the user's token hash via unsalted SHA-256 (safe only for Trace's 256-bit random tokens), so a leaked users.json exposed low-entropy provisioned passwords to offline cracking, and they worked as Basic-auth credentials. SCIM passwords are now refused with an RFC 7644 error; new SCIM users stay disabled until an administrator issues a token. - Users and Groups lists support `filter=ATTRIBUTE eq "value"` (userName/displayName/id) and startIndex/count paging over results sorted by id, which identity providers rely on to look resources up. - Group PATCH handles `remove` (by members[value eq "x"] path, by value list, or all members) and `add`/`replace` with path "members"; remove operations were silently dropped. User PATCH accepts path "active" with a boolean or "True"/"False". Finding: F089 --- README.md | 2 +- cmd/trace/scim.go | 178 +++++++++++++++++++--------- cmd/trace/scim_provisioning_test.go | 147 +++++++++++++++++++++++ cmd/trace/scim_query.go | 139 ++++++++++++++++++++++ 4 files changed, 411 insertions(+), 55 deletions(-) create mode 100644 cmd/trace/scim_provisioning_test.go create mode 100644 cmd/trace/scim_query.go diff --git a/README.md b/README.md index b5c42ba..e71874e 100644 --- a/README.md +++ b/README.md @@ -558,7 +558,7 @@ Administrators can archive and restore repositories: Archived repositories remain readable and clonable, but Git pushes and write operations are rejected until restoration. -Trace exposes administrator-authenticated SCIM 2.0 endpoints at `/scim/v2/Users` and `/scim/v2/Groups`. User provisioning supports list, create, get, deactivate/reactivate, and delete; group resources map directly to durable Trace teams and their members. A SCIM user without a supplied password is created disabled so provisioning never creates an account with an undisclosed credential. Full OIDC/SAML SSO and SCIM role mapping beyond team repository grants are not implemented. +Trace exposes administrator-authenticated SCIM 2.0 endpoints at `/scim/v2/Users` and `/scim/v2/Groups`. User provisioning supports list, create, get, deactivate/reactivate (including `path: "active"` patches), and delete; group resources map directly to durable Trace teams, and group patches add, replace, and remove members (including `members[value eq "USER"]` paths). Lists support `filter=userName eq "NAME"` (users) or `filter=displayName eq "NAME"` (groups) and `startIndex`/`count` paging. Trace refuses SCIM `password` values: credentials are personal tokens issued by an administrator, or OIDC sign-in. New SCIM users are therefore created disabled until an administrator issues a token (`trace user rotate`) and the account is reactivated. Full OIDC/SAML SSO and SCIM role mapping beyond team repository grants are not implemented. Branches can also be managed without raw Git plumbing: diff --git a/cmd/trace/scim.go b/cmd/trace/scim.go index f9144be..1d25bd0 100644 --- a/cmd/trace/scim.go +++ b/cmd/trace/scim.go @@ -2,6 +2,7 @@ package main import ( "encoding/json" + "errors" "io" "net/http" "sort" @@ -48,12 +49,24 @@ func (a *app) scimGroups(w http.ResponseWriter, r *http.Request) { if path == "" || path == "/" { switch r.Method { case http.MethodGet: + want, filtered, err := parseSCIMEqualityFilter(r.URL.Query().Get("filter"), "displayName", "id") + if err != nil { + scimError(w, http.StatusBadRequest, "invalidFilter", err.Error()) + return + } resources := make([]scimGroup, 0, len(db.Teams)) for name, team := range db.Teams { - resources = append(resources, scimGroupResource(name, team)) + if !filtered || name == want { + resources = append(resources, scimGroupResource(name, team)) + } } sort.Slice(resources, func(i, j int) bool { return resources[i].ID < resources[j].ID }) - writeJSON(w, http.StatusOK, map[string]any{"schemas": []string{"urn:ietf:params:scim:api:messages:2.0:ListResponse"}, "totalResults": len(resources), "Resources": resources}) + start, end, startIndex, err := scimPage(r, len(resources)) + if err != nil { + scimError(w, http.StatusBadRequest, "invalidValue", err.Error()) + return + } + writeJSON(w, http.StatusOK, scimListResponse(len(resources), startIndex, resources[start:end], end-start)) case http.MethodPost: var input struct { DisplayName string `json:"displayName"` @@ -97,32 +110,16 @@ func (a *app) scimGroups(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, scimGroupResource(name, record)) case http.MethodPatch: var input struct { - Operations []struct { - Op string `json:"op"` - Value []scimGroupMember `json:"value"` - } `json:"Operations"` + Operations []scimPatchOperation `json:"Operations"` } if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&input); err != nil { - apiError(w, http.StatusBadRequest, "invalid SCIM group patch") + scimError(w, http.StatusBadRequest, "invalidSyntax", "invalid SCIM group patch") return } for _, operation := range input.Operations { - if !strings.EqualFold(operation.Op, "add") && !strings.EqualFold(operation.Op, "replace") { - continue - } - if strings.EqualFold(operation.Op, "replace") { - for member := range record.Members { - if err := a.store.updateTeamMember(name, member, false); err != nil { - apiError(w, http.StatusBadRequest, err.Error()) - return - } - } - } - for _, member := range operation.Value { - if err := a.store.updateTeamMember(name, member.Value, true); err != nil { - apiError(w, http.StatusBadRequest, err.Error()) - return - } + if err := a.applySCIMGroupOperation(name, operation); err != nil { + scimError(w, http.StatusBadRequest, "invalidValue", err.Error()) + return } } updated, _ := a.store.loadTeams() @@ -153,19 +150,39 @@ func (a *app) scim(w http.ResponseWriter, r *http.Request, db userDB) { if path == "" || path == "/" { switch r.Method { case http.MethodGet: + want, filtered, err := parseSCIMEqualityFilter(r.URL.Query().Get("filter"), "userName", "id") + if err != nil { + scimError(w, http.StatusBadRequest, "invalidFilter", err.Error()) + return + } resources := make([]scimUser, 0, len(db.Users)) for name, record := range db.Users { - resources = append(resources, scimUser{Schemas: []string{"urn:ietf:params:scim:schemas:core:2.0:User"}, ID: name, UserName: name, Active: !record.Disabled}) + if !filtered || name == want { + resources = append(resources, scimUser{Schemas: []string{"urn:ietf:params:scim:schemas:core:2.0:User"}, ID: name, UserName: name, Active: !record.Disabled}) + } } - writeJSON(w, http.StatusOK, map[string]any{"schemas": []string{"urn:ietf:params:scim:api:messages:2.0:ListResponse"}, "totalResults": len(resources), "Resources": resources}) + sort.Slice(resources, func(i, j int) bool { return resources[i].ID < resources[j].ID }) + start, end, startIndex, err := scimPage(r, len(resources)) + if err != nil { + scimError(w, http.StatusBadRequest, "invalidValue", err.Error()) + return + } + writeJSON(w, http.StatusOK, scimListResponse(len(resources), startIndex, resources[start:end], end-start)) case http.MethodPost: var input struct { - UserName string `json:"userName"` - Active *bool `json:"active"` - Password string `json:"password"` + UserName string `json:"userName"` + Active *bool `json:"active"` + Password json.RawMessage `json:"password"` } if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&input); err != nil || !namePattern.MatchString(input.UserName) { - apiError(w, http.StatusBadRequest, "userName is required and must be valid") + scimError(w, http.StatusBadRequest, "invalidValue", "userName is required and must be valid") + return + } + // Trace credentials are generated personal tokens (or OIDC); a + // user-chosen SCIM password would be stored as an unsalted token + // hash and could be cracked offline, so it is refused. + if len(input.Password) > 0 && string(input.Password) != "null" && string(input.Password) != `""` { + scimError(w, http.StatusBadRequest, "invalidValue", "Trace does not accept SCIM passwords; administrators issue personal tokens (trace user rotate) or users sign in with OIDC") return } token, err := a.store.addUser(input.UserName, false) @@ -173,21 +190,11 @@ func (a *app) scim(w http.ResponseWriter, r *http.Request, db userDB) { apiError(w, http.StatusConflict, err.Error()) return } - active := input.Active == nil || *input.Active - if input.Password != "" { - _ = a.store.updateUsers(func(users *userDB) error { - record := users.Users[input.UserName] - record.Hash = hashToken(input.Password) - record.Disabled = !active - users.Users[input.UserName] = record - return nil - }) - } else { - // Do not create an active account with no credential that can be - // delivered by SCIM. An administrator can rotate/enroll a token. - _ = a.store.setUserActive(input.UserName, false) - active = false - } + // Do not create an active account with no credential that can be + // delivered by SCIM. An administrator can rotate/enroll a token and + // reactivate the account. + _ = a.store.setUserActive(input.UserName, false) + active := false // SCIM must not return a Trace personal token. Provisioning systems // should deliver a token through their own secure enrollment flow. _ = token @@ -215,21 +222,24 @@ func (a *app) scim(w http.ResponseWriter, r *http.Request, db userDB) { writeJSON(w, http.StatusOK, scimUser{Schemas: []string{"urn:ietf:params:scim:schemas:core:2.0:User"}, ID: name, UserName: name, Active: !record.Disabled}) case http.MethodPatch: var input struct { - Operations []struct { - Op string `json:"op"` - Value struct { - Active *bool `json:"active"` - } `json:"value"` - } `json:"Operations"` + Operations []scimPatchOperation `json:"Operations"` } if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&input); err != nil { - apiError(w, http.StatusBadRequest, "invalid SCIM patch") + scimError(w, http.StatusBadRequest, "invalidSyntax", "invalid SCIM patch") return } for _, operation := range input.Operations { - if operation.Value.Active != nil { - if err := a.store.setUserActive(name, *operation.Value.Active); err != nil { - apiError(w, http.StatusBadRequest, err.Error()) + if !strings.EqualFold(operation.Op, "replace") && !strings.EqualFold(operation.Op, "add") { + continue + } + active, ok, err := scimActiveValue(operation) + if err != nil { + scimError(w, http.StatusBadRequest, "invalidValue", err.Error()) + return + } + if ok { + if err := a.store.setUserActive(name, active); err != nil { + scimError(w, http.StatusBadRequest, "invalidValue", err.Error()) return } } @@ -247,3 +257,63 @@ func (a *app) scim(w http.ResponseWriter, r *http.Request, db userDB) { } _ = username } + +// applySCIMGroupOperation applies one group PATCH operation to the team: +// add, replace, and remove of members, including the +// members[value eq "USER"] path form that identity providers send. +func (a *app) applySCIMGroupOperation(team string, operation scimPatchOperation) error { + op := strings.ToLower(strings.TrimSpace(operation.Op)) + path := strings.TrimSpace(operation.Path) + if path != "" && !strings.EqualFold(path, "members") { + if match := scimMemberPathPattern.FindStringSubmatch(path); match != nil && op == "remove" { + var member string + if err := json.Unmarshal([]byte(`"`+match[1]+`"`), &member); err != nil { + return errors.New("invalid member filter") + } + return a.store.updateTeamMember(team, member, false) + } + // Teams cannot be renamed and carry no other SCIM attributes. + return nil + } + members, err := scimMembersValue(operation.Value) + if err != nil { + return err + } + current, err := a.store.loadTeams() + if err != nil { + return err + } + switch op { + case "add": + case "replace": + for member := range current.Teams[team].Members { + if err := a.store.updateTeamMember(team, member, false); err != nil { + return err + } + } + case "remove": + if len(members) == 0 { + // Removing the members attribute clears the group. + for member := range current.Teams[team].Members { + if err := a.store.updateTeamMember(team, member, false); err != nil { + return err + } + } + return nil + } + for _, member := range members { + if err := a.store.updateTeamMember(team, member.Value, false); err != nil { + return err + } + } + return nil + default: + return errors.New("unsupported SCIM patch operation " + operation.Op) + } + for _, member := range members { + if err := a.store.updateTeamMember(team, member.Value, true); err != nil { + return err + } + } + return nil +} diff --git a/cmd/trace/scim_provisioning_test.go b/cmd/trace/scim_provisioning_test.go new file mode 100644 index 0000000..3eb5179 --- /dev/null +++ b/cmd/trace/scim_provisioning_test.go @@ -0,0 +1,147 @@ +package main + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" +) + +func newSCIMTestApp(t *testing.T) (*app, func(method, path, body string) *httptest.ResponseRecorder) { + t.Helper() + root := t.TempDir() + if err := initData(root); err != nil { + t.Fatal(err) + } + a, err := newApp(root) + if err != nil { + t.Fatal(err) + } + token := adminToken(t, root) + return a, func(method, path, body string) *httptest.ResponseRecorder { + r := httptest.NewRequest(method, path, strings.NewReader(body)) + r.SetBasicAuth("admin", token) + r.Header.Set("Content-Type", "application/scim+json") + w := httptest.NewRecorder() + a.ServeHTTP(w, r) + return w + } +} + +func TestSCIMRejectsPasswords(t *testing.T) { + a, request := newSCIMTestApp(t) + res := request(http.MethodPost, "/scim/v2/Users", `{"userName":"weak","active":true,"password":"hunter2"}`) + if res.Code != http.StatusBadRequest || !strings.Contains(res.Body.String(), "urn:ietf:params:scim:api:messages:2.0:Error") { + t.Fatalf("SCIM password was not refused: %d %s", res.Code, res.Body.String()) + } + db, _ := a.store.loadUsers() + if _, exists := db.Users["weak"]; exists { + t.Fatal("user was created despite the refused password") + } + if _, ok := db.authenticate("weak", "hunter2"); ok { + t.Fatal("SCIM password became a Basic-auth credential") + } +} + +type scimListBody struct { + TotalResults int `json:"totalResults"` + StartIndex int `json:"startIndex"` + ItemsPerPage int `json:"itemsPerPage"` + Resources []struct { + ID string `json:"id"` + UserName string `json:"userName"` + } `json:"Resources"` +} + +func TestSCIMUserFilterAndPagination(t *testing.T) { + _, request := newSCIMTestApp(t) + for _, name := range []string{"carol", "alice", "dave", "bob"} { + if res := request(http.MethodPost, "/scim/v2/Users", `{"userName":"`+name+`"}`); res.Code != http.StatusCreated { + t.Fatalf("create %s: %d %s", name, res.Code, res.Body.String()) + } + } + list := func(query string) scimListBody { + res := request(http.MethodGet, "/scim/v2/Users?"+query, "") + if res.Code != http.StatusOK { + t.Fatalf("list %s: %d %s", query, res.Code, res.Body.String()) + } + var body scimListBody + if err := json.Unmarshal(res.Body.Bytes(), &body); err != nil { + t.Fatal(err) + } + return body + } + found := list("filter=" + url.QueryEscape(`userName eq "bob"`)) + if found.TotalResults != 1 || len(found.Resources) != 1 || found.Resources[0].UserName != "bob" { + t.Fatalf("filter by userName: %+v", found) + } + if missing := list("filter=" + url.QueryEscape(`USERNAME EQ "nobody"`)); missing.TotalResults != 0 || len(missing.Resources) != 0 { + t.Fatalf("filter for a missing user: %+v", missing) + } + page := list("startIndex=2&count=2") + if page.TotalResults != 5 || page.StartIndex != 2 || page.ItemsPerPage != 2 || len(page.Resources) != 2 || page.Resources[0].ID != "alice" || page.Resources[1].ID != "bob" { + t.Fatalf("pagination (admin, alice, bob, carol, dave): %+v", page) + } + if last := list("startIndex=5&count=10"); last.ItemsPerPage != 1 || last.Resources[0].ID != "dave" { + t.Fatalf("last page: %+v", last) + } + if res := request(http.MethodGet, "/scim/v2/Users?filter="+url.QueryEscape(`emails co "x"`), ""); res.Code != http.StatusBadRequest || !strings.Contains(res.Body.String(), "invalidFilter") { + t.Fatalf("unsupported filter: %d %s", res.Code, res.Body.String()) + } +} + +func TestSCIMPatchPathsAndGroupRemoval(t *testing.T) { + a, request := newSCIMTestApp(t) + for _, name := range []string{"alice", "bob", "carol"} { + if _, err := a.store.addUser(name, false); err != nil { + t.Fatal(err) + } + } + if res := request(http.MethodPatch, "/scim/v2/Users/alice", `{"Operations":[{"op":"Replace","path":"active","value":"False"}]}`); res.Code != http.StatusOK { + t.Fatalf("path-style active patch: %d %s", res.Code, res.Body.String()) + } + db, _ := a.store.loadUsers() + if !db.Users["alice"].Disabled { + t.Fatal("path-style active=False did not disable the user") + } + if res := request(http.MethodPost, "/scim/v2/Groups", `{"displayName":"eng","members":[{"value":"alice"},{"value":"bob"},{"value":"carol"}]}`); res.Code != http.StatusCreated { + t.Fatalf("create group: %d %s", res.Code, res.Body.String()) + } + members := func() map[string]bool { + teams, err := a.store.loadTeams() + if err != nil { + t.Fatal(err) + } + return teams.Teams["eng"].Members + } + if res := request(http.MethodPatch, "/scim/v2/Groups/eng", `{"Operations":[{"op":"remove","path":"members[value eq \"alice\"]"}]}`); res.Code != http.StatusOK { + t.Fatalf("remove by filter path: %d %s", res.Code, res.Body.String()) + } + if m := members(); m["alice"] || !m["bob"] || !m["carol"] { + t.Fatalf("remove by filter path: %v", m) + } + if res := request(http.MethodPatch, "/scim/v2/Groups/eng", `{"Operations":[{"op":"Remove","path":"members","value":[{"value":"bob"}]}]}`); res.Code != http.StatusOK { + t.Fatalf("remove by value: %d %s", res.Code, res.Body.String()) + } + if m := members(); m["bob"] || !m["carol"] { + t.Fatalf("remove by value: %v", m) + } + if res := request(http.MethodPatch, "/scim/v2/Groups/eng", `{"Operations":[{"op":"add","path":"members","value":[{"value":"alice"}]},{"op":"replace","path":"displayName","value":"eng"}]}`); res.Code != http.StatusOK { + t.Fatalf("add with path: %d %s", res.Code, res.Body.String()) + } + if m := members(); !m["alice"] || !m["carol"] { + t.Fatalf("add with path: %v", m) + } + if res := request(http.MethodPatch, "/scim/v2/Groups/eng", `{"Operations":[{"op":"remove","path":"members"}]}`); res.Code != http.StatusOK { + t.Fatalf("remove all members: %d %s", res.Code, res.Body.String()) + } + if m := members(); len(m) != 0 { + t.Fatalf("remove all members: %v", m) + } + groups := request(http.MethodGet, "/scim/v2/Groups?filter="+url.QueryEscape(`displayName eq "eng"`), "") + if groups.Code != http.StatusOK || !strings.Contains(groups.Body.String(), `"totalResults":1`) { + t.Fatalf("group filter: %d %s", groups.Code, groups.Body.String()) + } +} diff --git a/cmd/trace/scim_query.go b/cmd/trace/scim_query.go new file mode 100644 index 0000000..2a8709c --- /dev/null +++ b/cmd/trace/scim_query.go @@ -0,0 +1,139 @@ +package main + +import ( + "encoding/json" + "errors" + "net/http" + "regexp" + "strconv" + "strings" +) + +// scimError writes an RFC 7644 section 3.12 error response. +func scimError(w http.ResponseWriter, status int, scimType, detail string) { + body := map[string]any{"schemas": []string{"urn:ietf:params:scim:api:messages:2.0:Error"}, "status": strconv.Itoa(status), "detail": detail} + if scimType != "" { + body["scimType"] = scimType + } + w.Header().Set("Content-Type", "application/scim+json") + w.Header().Set("Cache-Control", "no-store") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(body) +} + +var scimEqFilterPattern = regexp.MustCompile(`^\s*([A-Za-z.]+)\s+(?i:eq)\s+"((?:[^"\\]|\\.)*)"\s*$`) + +// parseSCIMEqualityFilter supports the filter identity providers use to look +// resources up: `ATTRIBUTE eq "value"` for one of the allowed attributes +// (compared case-insensitively). An empty filter matches everything. +func parseSCIMEqualityFilter(filter string, attributes ...string) (string, bool, error) { + if strings.TrimSpace(filter) == "" { + return "", false, nil + } + match := scimEqFilterPattern.FindStringSubmatch(filter) + if match == nil { + return "", false, errors.New(`only filters of the form ATTRIBUTE eq "value" are supported`) + } + for _, attribute := range attributes { + if strings.EqualFold(match[1], attribute) { + var value string + if err := json.Unmarshal([]byte(`"`+match[2]+`"`), &value); err != nil { + return "", false, errors.New("invalid filter value") + } + return value, true, nil + } + } + return "", false, errors.New("unsupported filter attribute " + match[1]) +} + +// scimPage applies RFC 7644 section 3.4.2.4 pagination (1-based startIndex, +// non-negative count) to n sorted resources and returns the slice bounds. +func scimPage(r *http.Request, n int) (start, end, startIndex int, err error) { + startIndex, count := 1, n + if raw := r.URL.Query().Get("startIndex"); raw != "" { + value, convErr := strconv.Atoi(raw) + if convErr != nil { + return 0, 0, 0, errors.New("startIndex must be an integer") + } + if value > 1 { + startIndex = value + } + } + if raw := r.URL.Query().Get("count"); raw != "" { + value, convErr := strconv.Atoi(raw) + if convErr != nil { + return 0, 0, 0, errors.New("count must be an integer") + } + if value < 0 { + value = 0 + } + count = value + } + start = startIndex - 1 + if start > n { + start = n + } + end = start + count + if end > n || end < start { + end = n + } + return start, end, startIndex, nil +} + +func scimListResponse(total, startIndex int, resources any, itemsPerPage int) map[string]any { + return map[string]any{"schemas": []string{"urn:ietf:params:scim:api:messages:2.0:ListResponse"}, "totalResults": total, "startIndex": startIndex, "itemsPerPage": itemsPerPage, "Resources": resources} +} + +type scimPatchOperation struct { + Op string `json:"op"` + Path string `json:"path"` + Value json.RawMessage `json:"value"` +} + +var scimMemberPathPattern = regexp.MustCompile(`^\s*(?i:members)\s*\[\s*(?i:value)\s+(?i:eq)\s+"((?:[^"\\]|\\.)*)"\s*\]\s*$`) + +// scimMembersValue decodes a members value given either as an array of +// members or as an object with a "members" attribute. +func scimMembersValue(raw json.RawMessage) ([]scimGroupMember, error) { + if len(raw) == 0 || string(raw) == "null" { + return nil, nil + } + var members []scimGroupMember + if err := json.Unmarshal(raw, &members); err == nil { + return members, nil + } + var object struct { + Members []scimGroupMember `json:"members"` + } + if err := json.Unmarshal(raw, &object); err != nil { + return nil, errors.New("members value must be a list of {\"value\":USER}") + } + return object.Members, nil +} + +// scimActiveValue extracts the active flag from a user PATCH operation, +// accepting {"active":false} with no path, or path "active" with a boolean or +// the string "True"/"False" that some providers send. +func scimActiveValue(operation scimPatchOperation) (bool, bool, error) { + if strings.EqualFold(strings.TrimSpace(operation.Path), "active") { + var flag bool + if err := json.Unmarshal(operation.Value, &flag); err == nil { + return flag, true, nil + } + var text string + if err := json.Unmarshal(operation.Value, &text); err == nil && (strings.EqualFold(text, "true") || strings.EqualFold(text, "false")) { + return strings.EqualFold(text, "true"), true, nil + } + return false, false, errors.New("active must be a boolean") + } + if strings.TrimSpace(operation.Path) != "" { + return false, false, nil + } + var object struct { + Active *bool `json:"active"` + } + if err := json.Unmarshal(operation.Value, &object); err != nil || object.Active == nil { + return false, false, nil + } + return *object.Active, true, nil +} From 3545d8fd070430c5a1e71a1261a57f7b5f019408 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 27 Sep 2026 04:18:38 +0530 Subject: [PATCH 21/30] fix(webhooks): refuse internal destinations and redirects on delivery Webhook URLs accepted any https host, and delivery used an http.Client with the default redirect policy and environment proxies. A forge administrator (or a leaked admin token) could make Trace POST signed JSON to internal services or, through a redirect, to http://169.254.169.254/, and read the status code from the persisted delivery record. Deliveries now use a client whose dialer checks the address actually connected to, after DNS resolution: loopback only for hooks configured with a loopback host, RFC 1918/CGNAT/ULA/NAT64 only with the new `trace serve -webhook-allow-private-networks`, and link-local (including metadata), multicast, unspecified and reserved ranges never. Redirects are not followed and proxy environment variables are ignored. Finding: F090 --- README.md | 2 +- cmd/trace/main.go | 8 ++- cmd/trace/webhook_client.go | 110 ++++++++++++++++++++++++++++++++ cmd/trace/webhooks.go | 4 +- cmd/trace/webhooks_ssrf_test.go | 96 ++++++++++++++++++++++++++++ 5 files changed, 215 insertions(+), 5 deletions(-) create mode 100644 cmd/trace/webhook_client.go create mode 100644 cmd/trace/webhooks_ssrf_test.go diff --git a/README.md b/README.md index e71874e..cc62630 100644 --- a/README.md +++ b/README.md @@ -633,7 +633,7 @@ Events are stored as JSON Lines in `data/audit.jsonl` with owner-only permission ## Webhooks -Admins can configure signed JSON webhooks for repository events. HTTPS is required for remote endpoints; plain HTTP is accepted only for loopback development endpoints. +Admins can configure signed JSON webhooks for repository events. HTTPS is required for remote endpoints; plain HTTP is accepted only for loopback development endpoints. Deliveries connect only to public addresses, checked when connecting (after DNS resolution): loopback addresses only for hooks configured with a loopback host, private networks only when the server runs with `-webhook-allow-private-networks`, and link-local or cloud-metadata addresses never. Redirects are not followed (a 3xx response is recorded as a failed delivery), and proxy environment variables are ignored. ```sh ./trace api webhook-create -url http://127.0.0.1:9000 -user admin \ diff --git a/cmd/trace/main.go b/cmd/trace/main.go index b227964..b0d99f6 100644 --- a/cmd/trace/main.go +++ b/cmd/trace/main.go @@ -57,6 +57,7 @@ func run(args []string) error { sshAddr := fs.String("ssh-listen", "", "SSH Git listen address (disabled by default)") federationInterval := fs.Duration("federation-interval", 0, "background federation peer sync interval (disabled by default)") actionsInterval := fs.Duration("actions-interval", 0, "scheduled workflow evaluation interval (disabled by default)") + webhookPrivate := fs.Bool("webhook-allow-private-networks", false, "allow webhook deliveries to private-network addresses (link-local and metadata addresses stay blocked)") trustedProxy := fs.String("trusted-proxy", "", "comma-separated IPs or CIDR prefixes of reverse proxies whose X-Forwarded-For/X-Real-IP identify clients for rate limiting") actionsMode := fs.String("actions", actionsModeSandboxed, "CI runner policy: off, sandboxed (only workflows with \"sandbox\":true), or trusted (also run unsandboxed workflows as the Trace service account)") if err := fs.Parse(args[1:]); err != nil { @@ -79,7 +80,7 @@ func run(args []string) error { if err != nil { return err } - return serve(serveOptions{data: *data, addr: *addr, sshAddr: *sshAddr, federationInterval: *federationInterval, actionsInterval: *actionsInterval, actionsMode: mode, trustedProxies: proxies}) + return serve(serveOptions{data: *data, addr: *addr, sshAddr: *sshAddr, federationInterval: *federationInterval, actionsInterval: *actionsInterval, actionsMode: mode, trustedProxies: proxies, webhookAllowPrivate: *webhookPrivate}) case "repo": if len(args) < 2 || (args[1] != "create" && args[1] != "import" && args[1] != "fork" && args[1] != "archive" && args[1] != "restore" && args[1] != "delete" && args[1] != "transfer" && args[1] != "topics") { return errors.New("usage: trace repo ...") @@ -450,6 +451,8 @@ type store struct { // actionsMode is the operator's CI runner policy (see parseActionsMode). // The zero value means actionsModeSandboxed. actionsMode string + // webhookAllowPrivate lets webhooks target private-network addresses. + webhookAllowPrivate bool } func openStore(data string) (*store, error) { @@ -958,6 +961,8 @@ type serveOptions struct { actionsInterval time.Duration actionsMode string trustedProxies []netip.Prefix + // webhookAllowPrivate permits webhook targets in private networks. + webhookAllowPrivate bool } func serve(opts serveOptions) error { @@ -969,6 +974,7 @@ func serve(opts serveOptions) error { } a.store.actionsMode = opts.actionsMode a.limiter.trustedProxies = opts.trustedProxies + a.store.webhookAllowPrivate = opts.webhookAllowPrivate log.Printf("trace CI actions mode: %s", a.store.actionsPolicy()) if err := a.store.ensureHooks(); err != nil { return err diff --git a/cmd/trace/webhook_client.go b/cmd/trace/webhook_client.go new file mode 100644 index 0000000..f1112cd --- /dev/null +++ b/cmd/trace/webhook_client.go @@ -0,0 +1,110 @@ +package main + +import ( + "errors" + "net" + "net/http" + "net/netip" + "net/url" + "syscall" + "time" +) + +var errWebhookDestination = errors.New("webhook destination address is not allowed") + +// Address ranges that are never public webhook destinations. Link-local +// (which includes cloud metadata services) is refused even when the operator +// allows private networks. +var ( + webhookAlwaysBlocked = []netip.Prefix{ + netip.MustParsePrefix("0.0.0.0/8"), + netip.MustParsePrefix("169.254.0.0/16"), + netip.MustParsePrefix("224.0.0.0/4"), + netip.MustParsePrefix("240.0.0.0/4"), + netip.MustParsePrefix("fe80::/10"), + netip.MustParsePrefix("ff00::/8"), + netip.MustParsePrefix("::/128"), + } + webhookPrivate = []netip.Prefix{ + netip.MustParsePrefix("10.0.0.0/8"), + netip.MustParsePrefix("172.16.0.0/12"), + netip.MustParsePrefix("192.168.0.0/16"), + netip.MustParsePrefix("100.64.0.0/10"), + netip.MustParsePrefix("192.0.0.0/24"), + netip.MustParsePrefix("198.18.0.0/15"), + netip.MustParsePrefix("fc00::/7"), + netip.MustParsePrefix("64:ff9b::/96"), + netip.MustParsePrefix("64:ff9b:1::/48"), + } +) + +// webhookAddressAllowed decides whether Trace may connect to addr for a +// webhook. Loopback is allowed only for hooks configured with a loopback +// host (local development); private ranges only when the operator enabled +// them with `trace serve -webhook-allow-private-networks`. +func webhookAddressAllowed(addr netip.Addr, allowLoopback, allowPrivate bool) bool { + addr = addr.Unmap() + if !addr.IsValid() { + return false + } + for _, prefix := range webhookAlwaysBlocked { + if prefix.Contains(addr) { + return false + } + } + if addr.Is4() && addr == netip.AddrFrom4([4]byte{255, 255, 255, 255}) { + return false + } + if addr.IsLoopback() { + return allowLoopback + } + for _, prefix := range webhookPrivate { + if prefix.Contains(addr) { + return allowPrivate + } + } + return !addr.IsUnspecified() && !addr.IsMulticast() && !addr.IsLinkLocalUnicast() +} + +// webhookHTTPClient returns a delivery client that checks every address it +// connects to (after DNS resolution, so rebinding cannot bypass it), never +// uses environment proxies, and does not follow redirects. +func webhookHTTPClient(allowLoopback, allowPrivate bool) *http.Client { + dialer := &net.Dialer{ + Timeout: 5 * time.Second, + Control: func(network, address string, _ syscall.RawConn) error { + host, _, err := net.SplitHostPort(address) + if err != nil { + return errWebhookDestination + } + addr, err := netip.ParseAddr(host) + if err != nil || !webhookAddressAllowed(addr, allowLoopback, allowPrivate) { + return errWebhookDestination + } + return nil + }, + } + transport := &http.Transport{ + Proxy: nil, + DialContext: dialer.DialContext, + ForceAttemptHTTP2: true, + TLSHandshakeTimeout: 5 * time.Second, + ResponseHeaderTimeout: 5 * time.Second, + MaxIdleConns: 4, + IdleConnTimeout: 30 * time.Second, + } + return &http.Client{ + Timeout: 5 * time.Second, + Transport: transport, + CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }, + } +} + +// clientForWebhook returns the delivery client for one configured hook URL. +func (s *store) clientForWebhook(rawURL string) *http.Client { + allowLoopback := false + if u, err := url.Parse(rawURL); err == nil { + allowLoopback = isLoopback(u.Hostname()) + } + return webhookHTTPClient(allowLoopback, s.webhookAllowPrivate) +} diff --git a/cmd/trace/webhooks.go b/cmd/trace/webhooks.go index 9cc6cf5..163a749 100644 --- a/cmd/trace/webhooks.go +++ b/cmd/trace/webhooks.go @@ -327,7 +327,7 @@ func (s *store) dispatchWebhook(event auditEvent) { req.Header.Set("Content-Type", "application/json") req.Header.Set("X-Trace-Event", eventName) req.Header.Set("X-Trace-Signature", "sha256="+hex.EncodeToString(mac.Sum(nil))) - status, attempts, deliveryErr := deliverWebhook(req, clientForWebhook()) + status, attempts, deliveryErr := deliverWebhook(req, s.clientForWebhook(hook.URL)) delivery := webhookDelivery{WebhookID: hook.ID, Repo: event.Repo, Event: eventName, Attempts: attempts, ResponseCode: status, CreatedAt: time.Now().UTC(), CompletedAt: time.Now().UTC()} if deliveryErr == nil { delivery.Status = "delivered" @@ -339,8 +339,6 @@ func (s *store) dispatchWebhook(event auditEvent) { } } -func clientForWebhook() *http.Client { return &http.Client{Timeout: 5 * time.Second} } - func deliverWebhook(req *http.Request, client *http.Client) (int, int, error) { var status int var lastErr error diff --git a/cmd/trace/webhooks_ssrf_test.go b/cmd/trace/webhooks_ssrf_test.go new file mode 100644 index 0000000..71f4491 --- /dev/null +++ b/cmd/trace/webhooks_ssrf_test.go @@ -0,0 +1,96 @@ +package main + +import ( + "net/http" + "net/http/httptest" + "net/netip" + "strings" + "sync/atomic" + "testing" + "time" +) + +func TestWebhookDestinationRules(t *testing.T) { + cases := []struct { + addr string + allowLoopback, allowPrivate bool + want bool + }{ + {"93.184.216.34", false, false, true}, + {"2606:2800:220:1:248:1893:25c8:1946", false, false, true}, + {"127.0.0.1", false, false, false}, + {"127.0.0.1", true, false, true}, + {"::1", false, false, false}, + {"10.1.2.3", false, false, false}, + {"10.1.2.3", false, true, true}, + {"172.16.0.1", false, false, false}, + {"192.168.1.1", false, false, false}, + {"100.64.0.1", false, false, false}, + {"fd00::1", false, false, false}, + {"169.254.169.254", false, false, false}, + {"169.254.169.254", true, true, false}, + {"fe80::1", false, true, false}, + {"0.0.0.0", false, true, false}, + {"224.0.0.1", false, true, false}, + {"255.255.255.255", false, true, false}, + {"64:ff9b::a00:1", false, false, false}, + {"::ffff:10.0.0.1", false, false, false}, + } + for _, tc := range cases { + if got := webhookAddressAllowed(netip.MustParseAddr(tc.addr), tc.allowLoopback, tc.allowPrivate); got != tc.want { + t.Fatalf("webhookAddressAllowed(%s, loopback=%v, private=%v) = %v, want %v", tc.addr, tc.allowLoopback, tc.allowPrivate, got, tc.want) + } + } +} + +// TestWebhookDeliveryDoesNotReachInternalTargets checks the delivery client: +// it refuses to connect to non-public addresses for non-loopback hooks +// (checked at connect time, after DNS), ignores environment proxies, and does +// not follow redirects. +func TestWebhookDeliveryDoesNotReachInternalTargets(t *testing.T) { + var internalHits atomic.Int32 + internal := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + internalHits.Add(1) + w.WriteHeader(http.StatusNoContent) + })) + defer internal.Close() + + // A hook configured with a public hostname must not be able to reach a + // loopback listener, whatever the name resolves to. + client := webhookHTTPClient(false, false) + if _, err := client.Post(internal.URL, "application/json", strings.NewReader("{}")); err == nil || !strings.Contains(err.Error(), "not allowed") { + t.Fatalf("delivery to a loopback address was not refused: %v", err) + } + if internalHits.Load() != 0 { + t.Fatal("internal listener was reached") + } + + root := t.TempDir() + if err := initData(root); err != nil { + t.Fatal(err) + } + s, err := openStore(root) + if err != nil { + t.Fatal(err) + } + if err := s.createRepo("team/demo", false); err != nil { + t.Fatal(err) + } + redirector := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, internal.URL+"/metadata", http.StatusTemporaryRedirect) + })) + defer redirector.Close() + t.Setenv("HTTP_PROXY", internal.URL) + t.Setenv("HTTPS_PROXY", internal.URL) + if _, err := s.addWebhook("team/demo", redirector.URL, "0123456789abcdef0123456789abcdef", []string{"issue.created"}); err != nil { + t.Fatal(err) + } + s.dispatchWebhook(auditEvent{ID: "e1", At: time.Now().UTC(), Actor: "alice", Action: "issue.create", Repo: "team/demo", Resource: "1"}) + if internalHits.Load() != 0 { + t.Fatalf("webhook delivery followed a redirect or used an environment proxy (%d internal hits)", internalHits.Load()) + } + db, err := s.loadWebhookDeliveries() + if err != nil || len(db.Deliveries) != 1 || db.Deliveries[0].Status != "failed" || db.Deliveries[0].ResponseCode != http.StatusTemporaryRedirect { + t.Fatalf("redirect should be recorded as a failed delivery: %+v %v", db.Deliveries, err) + } +} From a55aa426544d47f660b6d0c2454c072c75999f6a Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 27 Sep 2026 04:20:37 +0530 Subject: [PATCH 22/30] fix(lfs): store and serve LFS objects per repository LFS objects were stored globally as data/lfs/, and the only access check was read access to the repository named in the URL. Anyone who could read any one repository could download every LFS object on the node by OID (OIDs appear in pointer files and are easily shared), leaking private repositories' large files. - Objects live in data/lfs/OWNER/NAME/; batch, upload, and download only see the repository's own objects. - Forks get their own copy (hard links when possible), transfers move the directory, and deletes remove it. - At server start, legacy global objects are linked into each repository whose object database contains an LFS pointer to them; unreferenced ones move to data/lfs/.legacy-unreferenced and are not served. The existing LFS test asserted the old global path; it now checks the per-repository location. Finding: F091 --- README.md | 2 +- cmd/trace/lfs.go | 196 +++++++++++++++++++++++++++++++- cmd/trace/lfs_isolation_test.go | 169 +++++++++++++++++++++++++++ cmd/trace/lfs_test.go | 3 +- cmd/trace/main.go | 29 +++++ 5 files changed, 393 insertions(+), 6 deletions(-) create mode 100644 cmd/trace/lfs_isolation_test.go diff --git a/README.md b/README.md index cc62630..abfa8b6 100644 --- a/README.md +++ b/README.md @@ -351,7 +351,7 @@ Publishing again adds a new Git snapshot commit only when native sessions change ## Git LFS -Trace exposes the basic authenticated Git LFS batch protocol under `/lfs/OWNER/NAME.git/info/lfs`. Install `git-lfs`, point the Git remote at Trace, and use normal `git lfs track`, `git add`, and `git push` commands. Trace verifies each uploaded object's SHA-256 OID and size, stores objects under `data/lfs`, and caps individual objects at 100 MiB. LFS locking, object garbage collection, and distributed object storage are not implemented. +Trace exposes the basic authenticated Git LFS batch protocol under `/lfs/OWNER/NAME.git/info/lfs`. Install `git-lfs`, point the Git remote at Trace, and use normal `git lfs track`, `git add`, and `git push` commands. Trace verifies each uploaded object's SHA-256 OID and size, stores objects per repository under `data/lfs/OWNER/NAME`, and caps individual objects at 100 MiB. An object is only served through the repository it was uploaded to; forks get their own copy (hard links where possible), transfers move them, and deleting a repository removes them. When the server starts, objects that older versions stored directly under `data/lfs` are moved into every repository whose Git history contains a pointer to them; objects no repository references are kept in `data/lfs/.legacy-unreferenced` and are no longer served. LFS locking, object garbage collection, and distributed object storage are not implemented. ## Package artifacts diff --git a/cmd/trace/lfs.go b/cmd/trace/lfs.go index c86443a..a8a5a1d 100644 --- a/cmd/trace/lfs.go +++ b/cmd/trace/lfs.go @@ -1,16 +1,20 @@ package main import ( + "bytes" "crypto/sha256" "encoding/hex" "encoding/json" "errors" + "fmt" "io" "net/http" "net/url" "os" + "os/exec" "path/filepath" "regexp" + "strconv" "strings" ) @@ -54,11 +58,26 @@ func validLFSObject(oid string, size int64) bool { return lfsOIDPattern.MatchString(oid) && size >= 0 && size <= maxLFSObjectSize } -func (s *store) lfsObjectPath(oid string) (string, error) { +// lfsRepoDir is where a repository's LFS objects live. Objects are stored +// per repository, so read access to one repository never exposes another +// repository's objects, even when their OIDs are known. +func (s *store) lfsRepoDir(repo string) (string, error) { + if !validRepoName(repo) { + return "", errors.New("invalid repository name") + } + owner, name, _ := strings.Cut(repo, "/") + return filepath.Join(s.root, "lfs", owner, name), nil +} + +func (s *store) lfsObjectPath(repo, oid string) (string, error) { if !lfsOIDPattern.MatchString(oid) { return "", errors.New("invalid LFS object id") } - return filepath.Join(s.root, "lfs", strings.ToLower(oid)), nil + dir, err := s.lfsRepoDir(repo) + if err != nil { + return "", err + } + return filepath.Join(dir, strings.ToLower(oid)), nil } func lfsBaseURL(r *http.Request) string { @@ -122,7 +141,7 @@ func (a *app) lfsBatch(w http.ResponseWriter, r *http.Request, username string, response.Objects = append(response.Objects, item) continue } - path, _ := a.store.lfsObjectPath(item.OID) + path, _ := a.store.lfsObjectPath(repo, item.OID) _, statErr := os.Stat(path) href := lfsBaseURL(r) + "/lfs/" + repo + ".git/info/lfs/objects/" + item.OID if input.Operation == "upload" { @@ -143,7 +162,7 @@ func (a *app) lfsBatch(w http.ResponseWriter, r *http.Request, username string, } func (a *app) lfsObject(w http.ResponseWriter, r *http.Request, u userRecord, repo, oid string) { - path, err := a.store.lfsObjectPath(oid) + path, err := a.store.lfsObjectPath(repo, oid) if err != nil { http.NotFound(w, r) return @@ -227,3 +246,172 @@ func writeLFSJSON(w http.ResponseWriter, status int, value any) { w.WriteHeader(status) _ = json.NewEncoder(w).Encode(value) } + +var lfsPointerOIDPattern = regexp.MustCompile(`(?m)^oid sha256:([0-9a-f]{64})\s*$`) + +// lfsPointerOIDs returns the OIDs referenced by Git LFS pointer files stored +// anywhere in the repository's object database. +func lfsPointerOIDs(repoPath string) (map[string]bool, error) { + list := exec.Command("git", "--git-dir", repoPath, "cat-file", "--batch-all-objects", "--batch-check=%(objectname) %(objecttype) %(objectsize)") + out, err := list.Output() + if err != nil { + return nil, err + } + var candidates strings.Builder + for _, line := range strings.Split(string(out), "\n") { + fields := strings.Fields(line) + // Pointer files are small text blobs (the spec caps them at 1024 bytes). + if len(fields) == 3 && fields[1] == "blob" && len(fields[2]) <= 4 { + if size, convErr := strconv.Atoi(fields[2]); convErr == nil && size <= 1024 { + candidates.WriteString(fields[0] + "\n") + } + } + } + oids := map[string]bool{} + if candidates.Len() == 0 { + return oids, nil + } + read := exec.Command("git", "--git-dir", repoPath, "cat-file", "--batch") + read.Stdin = strings.NewReader(candidates.String()) + content, err := read.Output() + if err != nil { + return nil, err + } + if !bytes.Contains(content, []byte("git-lfs")) { + return oids, nil + } + for _, match := range lfsPointerOIDPattern.FindAllSubmatch(content, -1) { + oids[string(match[1])] = true + } + return oids, nil +} + +// linkOrCopy hard-links source to target, copying when linking fails. +func linkOrCopy(source, target string) error { + if err := os.MkdirAll(filepath.Dir(target), 0700); err != nil { + return err + } + if _, err := os.Stat(target); err == nil { + return nil + } + if err := os.Link(source, target); err == nil { + return nil + } + in, err := os.Open(source) + if err != nil { + return err + } + defer in.Close() + tmp, err := os.CreateTemp(filepath.Dir(target), ".object-*") + if err != nil { + return err + } + defer os.Remove(tmp.Name()) + if _, err := io.Copy(tmp, in); err != nil { + tmp.Close() + return err + } + if err := tmp.Close(); err != nil { + return err + } + return os.Rename(tmp.Name(), target) +} + +// copyLFSObjects gives target its own copy (hard links when possible) of the +// source repository's LFS objects, for forks. +func (s *store) copyLFSObjects(source, target string) error { + sourceDir, err := s.lfsRepoDir(source) + if err != nil { + return err + } + targetDir, err := s.lfsRepoDir(target) + if err != nil { + return err + } + entries, err := os.ReadDir(sourceDir) + if errors.Is(err, os.ErrNotExist) { + return nil + } + if err != nil { + return err + } + for _, entry := range entries { + if entry.Type().IsRegular() && lfsOIDPattern.MatchString(entry.Name()) { + if err := linkOrCopy(filepath.Join(sourceDir, entry.Name()), filepath.Join(targetDir, entry.Name())); err != nil { + return err + } + } + } + return nil +} + +// migrateLegacyLFS moves objects stored by older versions directly under +// data/lfs/ into the repositories whose Git history contains a pointer +// to them. Objects no repository references are moved to +// data/lfs/.legacy-unreferenced and are no longer served. It is idempotent +// and runs when the server starts. +func (s *store) migrateLegacyLFS() error { + lfsRoot := filepath.Join(s.root, "lfs") + entries, err := os.ReadDir(lfsRoot) + if errors.Is(err, os.ErrNotExist) { + return nil + } + if err != nil { + return err + } + legacy := map[string]bool{} + for _, entry := range entries { + if entry.Type().IsRegular() && lfsOIDPattern.MatchString(entry.Name()) { + legacy[strings.ToLower(entry.Name())] = true + } + } + if len(legacy) == 0 { + return nil + } + owners, err := os.ReadDir(s.repos) + if err != nil { + return err + } + for _, owner := range owners { + if !owner.IsDir() || !namePattern.MatchString(owner.Name()) { + continue + } + repos, err := os.ReadDir(filepath.Join(s.repos, owner.Name())) + if err != nil { + return err + } + for _, entry := range repos { + name := strings.TrimSuffix(entry.Name(), ".git") + if !entry.IsDir() || !strings.HasSuffix(entry.Name(), ".git") || !namePattern.MatchString(name) { + continue + } + repo := owner.Name() + "/" + name + oids, err := lfsPointerOIDs(filepath.Join(s.repos, owner.Name(), entry.Name())) + if err != nil { + return fmt.Errorf("scan LFS pointers in %s: %w", repo, err) + } + for oid := range oids { + if !legacy[oid] { + continue + } + target, err := s.lfsObjectPath(repo, oid) + if err != nil { + return err + } + if err := linkOrCopy(filepath.Join(lfsRoot, oid), target); err != nil { + return fmt.Errorf("migrate LFS object %s to %s: %w", oid, repo, err) + } + } + } + } + unreferenced := filepath.Join(lfsRoot, ".legacy-unreferenced") + if err := os.MkdirAll(unreferenced, 0700); err != nil { + return err + } + for oid := range legacy { + if err := os.Rename(filepath.Join(lfsRoot, oid), filepath.Join(unreferenced, oid)); err != nil { + return err + } + } + return nil +} diff --git a/cmd/trace/lfs_isolation_test.go b/cmd/trace/lfs_isolation_test.go new file mode 100644 index 0000000..3812411 --- /dev/null +++ b/cmd/trace/lfs_isolation_test.go @@ -0,0 +1,169 @@ +package main + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strconv" + "testing" +) + +func lfsUpload(t *testing.T, a *app, user, token, repo string, content []byte) string { + t.Helper() + sum := sha256.Sum256(content) + oid := hex.EncodeToString(sum[:]) + req := httptest.NewRequest(http.MethodPut, "/lfs/"+repo+".git/info/lfs/objects/"+oid, bytes.NewReader(content)) + req.SetBasicAuth(user, token) + req.ContentLength = int64(len(content)) + res := httptest.NewRecorder() + a.ServeHTTP(res, req) + if res.Code != http.StatusCreated { + t.Fatalf("LFS upload to %s: %d %s", repo, res.Code, res.Body.String()) + } + return oid +} + +func lfsDownload(a *app, user, token, repo, oid string) (int, []byte) { + req := httptest.NewRequest(http.MethodGet, "/lfs/"+repo+".git/info/lfs/objects/"+oid, nil) + req.SetBasicAuth(user, token) + res := httptest.NewRecorder() + a.ServeHTTP(res, req) + body, _ := io.ReadAll(res.Body) + return res.Code, body +} + +func lfsBatchDownloadError(t *testing.T, a *app, user, token, repo, oid string, size int) *lfsObjectError { + t.Helper() + body, _ := json.Marshal(lfsBatchRequest{Operation: "download", Objects: []lfsObjectRequest{{OID: oid, Size: int64(size)}}}) + req := httptest.NewRequest(http.MethodPost, "/lfs/"+repo+".git/info/lfs/objects", bytes.NewReader(body)) + req.SetBasicAuth(user, token) + res := httptest.NewRecorder() + a.ServeHTTP(res, req) + var out lfsBatchResponse + if err := json.Unmarshal(res.Body.Bytes(), &out); err != nil || len(out.Objects) != 1 { + t.Fatalf("batch response: %d %s", res.Code, res.Body.String()) + } + return out.Objects[0].Error +} + +// TestLFSObjectsAreScopedToTheirRepository reproduces the leak: a user who +// can read one repository could download any LFS object on the node by OID. +func TestLFSObjectsAreScopedToTheirRepository(t *testing.T) { + root := filepath.Join(t.TempDir(), "node") + if err := initData(root); err != nil { + t.Fatal(err) + } + a, err := newApp(root) + if err != nil { + t.Fatal(err) + } + for _, name := range []string{"team/public-ish", "team/secret"} { + if err := a.store.createRepo(name, false); err != nil { + t.Fatal(err) + } + } + aliceToken, err := a.store.addUser("alice", false) + if err != nil { + t.Fatal(err) + } + if err := a.store.grantUser("alice", "team/public-ish", "read"); err != nil { + t.Fatal(err) + } + admin := adminToken(t, root) + secret := []byte("confidential design document\n") + oid := lfsUpload(t, a, "admin", admin, "team/secret", secret) + + if code, body := lfsDownload(a, "alice", aliceToken, "team/public-ish", oid); code == http.StatusOK || bytes.Contains(body, secret) { + t.Fatalf("object from team/secret was downloadable through team/public-ish: %d", code) + } + if objErr := lfsBatchDownloadError(t, a, "alice", aliceToken, "team/public-ish", oid, len(secret)); objErr == nil || objErr.Code != http.StatusNotFound { + t.Fatalf("batch download advertised another repository's object: %+v", objErr) + } + if code, body := lfsDownload(a, "admin", admin, "team/secret", oid); code != http.StatusOK || !bytes.Equal(body, secret) { + t.Fatalf("owner repository download: %d", code) + } + + // Forks get their own copy; transfers carry objects; deletes remove them. + if err := a.store.forkRepo("team/secret", "team/fork"); err != nil { + t.Fatal(err) + } + if code, _ := lfsDownload(a, "admin", admin, "team/fork", oid); code != http.StatusOK { + t.Fatalf("fork lost LFS objects: %d", code) + } + if err := a.store.transferRepo("team/fork", "other/moved"); err != nil { + t.Fatal(err) + } + if code, _ := lfsDownload(a, "admin", admin, "other/moved", oid); code != http.StatusOK { + t.Fatalf("transfer lost LFS objects: %d", code) + } + if err := a.store.deleteRepo("other/moved"); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Join(root, "lfs", "other", "moved")); !os.IsNotExist(err) { + t.Fatalf("deleted repository kept its LFS objects: %v", err) + } + if code, _ := lfsDownload(a, "admin", admin, "team/secret", oid); code != http.StatusOK { + t.Fatalf("deleting a fork removed the source repository's object: %d", code) + } +} + +func TestLegacyLFSObjectsMigrateToReferencingRepositories(t *testing.T) { + root := filepath.Join(t.TempDir(), "node") + if err := initData(root); err != nil { + t.Fatal(err) + } + a, err := newApp(root) + if err != nil { + t.Fatal(err) + } + for _, name := range []string{"team/uses-it", "team/unrelated"} { + if err := a.store.createRepo(name, false); err != nil { + t.Fatal(err) + } + } + content := []byte("legacy object\n") + sum := sha256.Sum256(content) + oid := hex.EncodeToString(sum[:]) + orphan := bytes.Repeat([]byte("o"), 10) + orphanSum := sha256.Sum256(orphan) + orphanOID := hex.EncodeToString(orphanSum[:]) + if err := os.MkdirAll(filepath.Join(root, "lfs"), 0700); err != nil { + t.Fatal(err) + } + for id, data := range map[string][]byte{oid: content, orphanOID: orphan} { + if err := os.WriteFile(filepath.Join(root, "lfs", id), data, 0600); err != nil { + t.Fatal(err) + } + } + pointer := "version https://git-lfs.github.com/spec/v1\noid sha256:" + oid + "\nsize " + strconv.Itoa(len(content)) + "\n" + commitFiles(t, a.store, "team/uses-it", map[string][]byte{"asset.bin": []byte(pointer), ".gitattributes": []byte("*.bin filter=lfs diff=lfs merge=lfs -text\n")}) + commitFiles(t, a.store, "team/unrelated", map[string][]byte{"README.md": []byte("nothing here\n")}) + + if err := a.store.migrateLegacyLFS(); err != nil { + t.Fatal(err) + } + if err := a.store.migrateLegacyLFS(); err != nil { + t.Fatalf("migration is not idempotent: %v", err) + } + admin := adminToken(t, root) + if code, body := lfsDownload(a, "admin", admin, "team/uses-it", oid); code != http.StatusOK || !bytes.Equal(body, content) { + t.Fatalf("migrated object not served for its repository: %d", code) + } + if code, _ := lfsDownload(a, "admin", admin, "team/unrelated", oid); code == http.StatusOK { + t.Fatal("migrated object leaked into a repository that never referenced it") + } + for _, id := range []string{oid, orphanOID} { + if _, err := os.Stat(filepath.Join(root, "lfs", id)); !os.IsNotExist(err) { + t.Fatalf("legacy global object %s is still in place: %v", id, err) + } + } + if _, err := os.Stat(filepath.Join(root, "lfs", ".legacy-unreferenced", orphanOID)); err != nil { + t.Fatalf("unreferenced legacy object was not preserved: %v", err) + } +} diff --git a/cmd/trace/lfs_test.go b/cmd/trace/lfs_test.go index 71df63b..aeb73b7 100644 --- a/cmd/trace/lfs_test.go +++ b/cmd/trace/lfs_test.go @@ -66,7 +66,8 @@ func TestLFSBatchUploadAndDownload(t *testing.T) { if getRes.StatusCode != http.StatusOK || !bytes.Equal(got, content) { t.Fatalf("LFS download: %d %q", getRes.StatusCode, got) } - if _, err := os.Stat(filepath.Join(root, "lfs", oid)); err != nil { + // Objects are stored per repository. + if _, err := os.Stat(filepath.Join(root, "lfs", "team", "lfs", oid)); err != nil { t.Fatal(err) } } diff --git a/cmd/trace/main.go b/cmd/trace/main.go index b0d99f6..64c4271 100644 --- a/cmd/trace/main.go +++ b/cmd/trace/main.go @@ -596,6 +596,9 @@ func (s *store) deleteRepo(name string) error { if err := os.RemoveAll(path); err != nil { return fmt.Errorf("delete repository: %w", err) } + if lfsDir, err := s.lfsRepoDir(name); err == nil { + _ = os.RemoveAll(lfsDir) + } return nil } @@ -628,12 +631,32 @@ func (s *store) transferRepo(source, target string) error { if err := os.Rename(sourcePath, targetPath); err != nil { return fmt.Errorf("transfer repository: %w", err) } + if err := s.moveRepoDir("lfs", source, target); err != nil { + return fmt.Errorf("transfer LFS objects: %w", err) + } if err := s.rewriteRepoReferences(source, target); err != nil { return fmt.Errorf("rewrite repository references: %w", err) } return nil } +// moveRepoDir renames data/KIND/OWNER/NAME for a repository transfer. +func (s *store) moveRepoDir(kind, source, target string) error { + sourceOwner, sourceName, _ := strings.Cut(source, "/") + targetOwner, targetName, _ := strings.Cut(target, "/") + from := filepath.Join(s.root, kind, sourceOwner, sourceName) + to := filepath.Join(s.root, kind, targetOwner, targetName) + if _, err := os.Stat(from); errors.Is(err, os.ErrNotExist) { + return nil + } else if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(to), 0700); err != nil { + return err + } + return os.Rename(from, to) +} + func (s *store) rewriteRepoReferences(source, target string) error { entries, err := os.ReadDir(s.root) if err != nil { @@ -691,6 +714,9 @@ func (s *store) forkRepo(source, target string) error { return fmt.Errorf("configure fork: %w: %s", err, strings.TrimSpace(string(out))) } } + if err := s.copyLFSObjects(source, target); err != nil { + return fmt.Errorf("copy LFS objects: %w", err) + } return installHook(targetPath) } @@ -979,6 +1005,9 @@ func serve(opts serveOptions) error { if err := a.store.ensureHooks(); err != nil { return err } + if err := a.store.migrateLegacyLFS(); err != nil { + return fmt.Errorf("migrate legacy LFS objects: %w", err) + } if sshAddr != "" { go func() { if err := serveSSH(a.store, sshAddr); err != nil { From 474056d1b1b9f2c60c71df159d5b1e76d574174a Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 27 Sep 2026 04:21:25 +0530 Subject: [PATCH 23/30] fix(api): let maintainers run actions and manage secrets and Pages The action-run, secrets, and Pages APIs checked `roleFor(u, repo) != "write"`, comparing the raw grant string, so users granted `maintain` (documented as a superset of write) got 403 while `write` users were allowed. Use u.canWrite(repo), which already covers admin, write, maintain, and team-inherited grants. Finding: F092 --- cmd/trace/actions.go | 2 +- cmd/trace/maintainer_access_test.go | 66 +++++++++++++++++++++++++++++ cmd/trace/pages.go | 2 +- cmd/trace/secrets.go | 2 +- 4 files changed, 69 insertions(+), 3 deletions(-) create mode 100644 cmd/trace/maintainer_access_test.go diff --git a/cmd/trace/actions.go b/cmd/trace/actions.go index b8ecea2..a0c06ac 100644 --- a/cmd/trace/actions.go +++ b/cmd/trace/actions.go @@ -158,7 +158,7 @@ func (a *app) apiActions(w http.ResponseWriter, r *http.Request, u userRecord, u apiError(w, http.StatusMethodNotAllowed, "method not allowed") return } - if !u.Admin && roleFor(u, repo) != "write" { + if !u.canWrite(repo) { apiError(w, http.StatusForbidden, "write access required") return } diff --git a/cmd/trace/maintainer_access_test.go b/cmd/trace/maintainer_access_test.go new file mode 100644 index 0000000..6176b91 --- /dev/null +++ b/cmd/trace/maintainer_access_test.go @@ -0,0 +1,66 @@ +package main + +import ( + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "testing" +) + +// TestMaintainersCanUseWriterAPIs checks that the maintain role, a superset of +// write, is accepted by the action-run, secrets, and Pages APIs, while a +// read-only user is still refused. +func TestMaintainersCanUseWriterAPIs(t *testing.T) { + root := filepath.Join(t.TempDir(), "node") + if err := initData(root); err != nil { + t.Fatal(err) + } + a, err := newApp(root) + if err != nil { + t.Fatal(err) + } + a.store.actionsMode = actionsModeTrusted + if err := a.store.createRepo("team/app", false); err != nil { + t.Fatal(err) + } + commitFiles(t, a.store, "team/app", map[string][]byte{"index.html": []byte("

app

"), ".trace/workflow.json": []byte(`{"name":"ci","jobs":[{"name":"test","run":["true"]}]}`)}) + tokens := map[string]string{} + for user, role := range map[string]string{"mia": "maintain", "rita": "read"} { + token, err := a.store.addUser(user, false) + if err != nil { + t.Fatal(err) + } + if err := a.store.grantUser(user, "team/app", role); err != nil { + t.Fatal(err) + } + tokens[user] = token + } + call := func(user, method, path, body string) int { + req := httptest.NewRequest(method, path, strings.NewReader(body)) + req.SetBasicAuth(user, tokens[user]) + req.Header.Set("Content-Type", "application/json") + res := httptest.NewRecorder() + a.ServeHTTP(res, req) + return res.Code + } + requests := []struct{ method, path, body string }{ + {http.MethodPost, "/api/v1/repos/team/app/actions/runs", `{"ref":"main"}`}, + {http.MethodGet, "/api/v1/repos/team/app/secrets", ""}, + {http.MethodPut, "/api/v1/repos/team/app/secrets/DEPLOY", `{"value":"s3cret-value"}`}, + {http.MethodPost, "/api/v1/repos/team/app/pages", `{"branch":"main"}`}, + } + for _, rq := range requests { + if code := call("mia", rq.method, rq.path, rq.body); code == http.StatusForbidden || code >= 400 { + t.Fatalf("maintainer %s %s: %d", rq.method, rq.path, code) + } + if code := call("rita", rq.method, rq.path, rq.body); code != http.StatusForbidden { + t.Fatalf("read-only user %s %s: %d", rq.method, rq.path, code) + } + } + runs, err := a.store.listActionRuns("team/app") + if err != nil || len(runs) != 1 { + t.Fatalf("maintainer run was not queued: %+v %v", runs, err) + } + waitForActionRun(t, a.store, runs[0].ID) +} diff --git a/cmd/trace/pages.go b/cmd/trace/pages.go index 0997666..2a26036 100644 --- a/cmd/trace/pages.go +++ b/cmd/trace/pages.go @@ -180,7 +180,7 @@ func (a *app) apiPages(w http.ResponseWriter, r *http.Request, u userRecord, rep writeJSON(w, http.StatusOK, map[string]any{"enabled": config.Enabled, "branch": config.Branch, "root": config.Root, "url": "/pages/" + repo + "/"}) return } - if !u.Admin && roleFor(u, repo) != "write" { + if !u.canWrite(repo) { apiError(w, http.StatusForbidden, "write access required") return } diff --git a/cmd/trace/secrets.go b/cmd/trace/secrets.go index ad75410..7916766 100644 --- a/cmd/trace/secrets.go +++ b/cmd/trace/secrets.go @@ -157,7 +157,7 @@ func (s *store) actionSecrets(repo string) (map[string]string, error) { } func (a *app) apiSecrets(w http.ResponseWriter, r *http.Request, u userRecord, repo string, tail []string) { - if !u.Admin && roleFor(u, repo) != "write" { + if !u.canWrite(repo) { apiError(w, http.StatusForbidden, "write access required") return } From 0db5e1a522f4ad7758ccbccc7a3a73ca3b3f4b15 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 27 Sep 2026 04:22:58 +0530 Subject: [PATCH 24/30] fix(totp): reject reused codes and back off repeated failures per account TOTP verification accepted the previous, current, and next 30-second codes without remembering which step was last used, so an observed or intercepted code could be replayed for up to 90 seconds, and wrong codes were limited only by the per-address sign-in limiter. - Record the last accepted time step (totp_last_counter) under the users lock and refuse codes for that step or earlier (RFC 6238 section 5.2); enabling or disabling 2FA resets it. - After five consecutive wrong or replayed codes for an account, refuse attempts for 30 seconds, doubling up to 15 minutes. Only callers that presented the account's valid token reach this check, so it cannot be used to lock a user out without their token. Finding: F093 --- README.md | 2 +- cmd/trace/main.go | 3 +- cmd/trace/session.go | 21 ++++++- cmd/trace/totp.go | 104 +++++++++++++++++++++++++++++++--- cmd/trace/totp_replay_test.go | 101 +++++++++++++++++++++++++++++++++ cmd/trace/users.go | 3 + 6 files changed, 221 insertions(+), 13 deletions(-) create mode 100644 cmd/trace/totp_replay_test.go diff --git a/README.md b/README.md index abfa8b6..a035153 100644 --- a/README.md +++ b/README.md @@ -179,7 +179,7 @@ Admins can enable TOTP for browser sign-in. Git and API personal tokens remain u ./trace user 2fa disable -data ./data alice ``` -`enable` prints a provisioning URI once. Store the secret in an authenticator and keep the data directory private. Trace accepts a small clock skew and never stores generated one-time codes. SAML and provider-specific OIDC group-to-role mapping are not implemented. +`enable` prints a provisioning URI once. Store the secret in an authenticator and keep the data directory private. Trace accepts one 30-second step of clock skew and never stores generated one-time codes; it records only the last accepted time step, so a code cannot be used twice. After five consecutive wrong codes for an account, further attempts are refused for 30 seconds, doubling up to 15 minutes (this state is kept in memory and resets when Trace restarts). SAML and provider-specific OIDC group-to-role mapping are not implemented. ## Code and agent context search diff --git a/cmd/trace/main.go b/cmd/trace/main.go index 64c4271..c083cdc 100644 --- a/cmd/trace/main.go +++ b/cmd/trace/main.go @@ -953,6 +953,7 @@ type app struct { sessionKey []byte gitPath string limiter *rateLimiter + totp *totpLimiter } func newApp(data string) (*app, error) { @@ -975,7 +976,7 @@ func newApp(data string) (*app, error) { if _, err := rand.Read(sessionKey); err != nil { return nil, err } - return &app{store: s, csrf: base64.RawURLEncoding.EncodeToString(csrfBytes), sessionKey: sessionKey, gitPath: gitPath, limiter: newRateLimiter(s.root)}, nil + return &app{store: s, csrf: base64.RawURLEncoding.EncodeToString(csrfBytes), sessionKey: sessionKey, gitPath: gitPath, limiter: newRateLimiter(s.root), totp: newTOTPLimiter()}, nil } // serveOptions carries the operator's `trace serve` flags. diff --git a/cmd/trace/session.go b/cmd/trace/session.go index e157fec..ab559d7 100644 --- a/cmd/trace/session.go +++ b/cmd/trace/session.go @@ -62,9 +62,24 @@ func (a *app) login(w http.ResponseWriter, r *http.Request) { a.loginPage(w, r, "Username or token is incorrect.", http.StatusUnauthorized) return } - if u.TOTPEnabled && !verifyTOTP(u.TOTPSecret, r.PostForm.Get("totp"), time.Now()) { - a.loginPage(w, r, "The two-factor code is incorrect.", http.StatusUnauthorized) - return + if u.TOTPEnabled { + now := time.Now() + if wait := a.totp.locked(name, now); wait > 0 { + a.loginPage(w, r, "Too many incorrect two-factor codes. Try again in "+strconv.Itoa(int(wait.Seconds())+1)+" seconds.", http.StatusTooManyRequests) + return + } + step, ok := matchTOTP(u.TOTPSecret, r.PostForm.Get("totp"), now) + if !ok { + a.totp.fail(name, now) + a.loginPage(w, r, "The two-factor code is incorrect.", http.StatusUnauthorized) + return + } + if err := a.store.acceptTOTPCounter(name, step); err != nil { + a.totp.fail(name, now) + a.loginPage(w, r, "This two-factor code was already used. Wait for the next code.", http.StatusUnauthorized) + return + } + a.totp.reset(name) } a.issueSession(w, r, name, u) http.SetCookie(w, &http.Cookie{Name: "trace_login", Path: "/login", MaxAge: -1, HttpOnly: true, SameSite: http.SameSiteStrictMode, Secure: secureCookie(r)}) diff --git a/cmd/trace/totp.go b/cmd/trace/totp.go index 59318e2..2b2f43f 100644 --- a/cmd/trace/totp.go +++ b/cmd/trace/totp.go @@ -12,6 +12,7 @@ import ( "fmt" "net/url" "strings" + "sync" "time" ) @@ -24,11 +25,15 @@ func newTOTPSecret() (string, error) { } func totpCode(secret string, at time.Time) (string, error) { + return totpCodeForCounter(secret, at.Unix()/30) +} + +func totpCodeForCounter(secret string, step int64) (string, error) { raw, err := base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(strings.ToUpper(strings.TrimSpace(secret))) if err != nil || len(raw) < 10 { return "", errors.New("invalid TOTP secret") } - counter := uint64(at.Unix() / 30) + counter := uint64(step) var message [8]byte binary.BigEndian.PutUint64(message[:], counter) h := hmac.New(sha1.New, raw) // SHA-1 is part of the TOTP standard (RFC 6238). @@ -40,17 +45,100 @@ func totpCode(secret string, at time.Time) (string, error) { } func verifyTOTP(secret, supplied string, now time.Time) bool { + _, ok := matchTOTP(secret, supplied, now) + return ok +} + +// matchTOTP checks a code against the previous, current, and next 30-second +// steps and returns the matching step counter. Callers must also reject +// counters at or below the last one accepted for the account (RFC 6238 +// section 5.2) so a code cannot be replayed within the skew window. +func matchTOTP(secret, supplied string, now time.Time) (int64, bool) { supplied = strings.TrimSpace(supplied) if len(supplied) != 6 { - return false + return 0, false } - for _, delta := range []int64{-30, 0, 30} { - want, err := totpCode(secret, now.Add(time.Duration(delta)*time.Second)) + current := now.Unix() / 30 + for _, step := range []int64{current - 1, current, current + 1} { + want, err := totpCodeForCounter(secret, step) if err == nil && subtle.ConstantTimeCompare([]byte(want), []byte(supplied)) == 1 { - return true + return step, true } } - return false + return 0, false +} + +var errTOTPReplay = errors.New("two-factor code already used") + +// acceptTOTPCounter records step as the account's last accepted TOTP step, +// refusing it if an equal or later step was already accepted. +func (s *store) acceptTOTPCounter(name string, step int64) error { + return s.updateUsers(func(db *userDB) error { + u, ok := db.Users[name] + if !ok { + return errors.New("user not found") + } + if step <= u.TOTPLastCounter { + return errTOTPReplay + } + u.TOTPLastCounter = step + db.Users[name] = u + return nil + }) +} + +// After totpMaxFailures consecutive wrong codes for one account, further +// attempts are refused for an exponentially growing period (30 s doubling, +// at most 15 minutes). Only callers who already presented the account's +// valid token reach the code check, so this cannot be used to lock out a +// user without their token. State is per process and resets on restart. +const ( + totpMaxFailures = 5 + totpBaseLockout = 30 * time.Second + totpMaxLockout = 15 * time.Minute +) + +type totpAttempt struct { + failures int + lockedUntil time.Time +} + +type totpLimiter struct { + mu sync.Mutex + accounts map[string]totpAttempt +} + +func newTOTPLimiter() *totpLimiter { return &totpLimiter{accounts: map[string]totpAttempt{}} } + +// locked returns how long the account must still wait, or zero. +func (l *totpLimiter) locked(name string, now time.Time) time.Duration { + l.mu.Lock() + defer l.mu.Unlock() + if wait := l.accounts[name].lockedUntil.Sub(now); wait > 0 { + return wait + } + return 0 +} + +func (l *totpLimiter) fail(name string, now time.Time) { + l.mu.Lock() + defer l.mu.Unlock() + attempt := l.accounts[name] + attempt.failures++ + if attempt.failures >= totpMaxFailures { + lockout := totpBaseLockout << uint(attempt.failures-totpMaxFailures) + if lockout > totpMaxLockout || lockout <= 0 { + lockout = totpMaxLockout + } + attempt.lockedUntil = now.Add(lockout) + } + l.accounts[name] = attempt +} + +func (l *totpLimiter) reset(name string) { + l.mu.Lock() + defer l.mu.Unlock() + delete(l.accounts, name) } func totpCommand(args []string) error { @@ -81,7 +169,7 @@ func totpCommand(args []string) error { if !ok { return errors.New("user not found") } - u.TOTPSecret, u.TOTPEnabled = secret, true + u.TOTPSecret, u.TOTPEnabled, u.TOTPLastCounter = secret, true, 0 db.Users[name] = u return nil }); err != nil { @@ -97,7 +185,7 @@ func totpCommand(args []string) error { if !ok { return errors.New("user not found") } - u.TOTPSecret, u.TOTPEnabled = "", false + u.TOTPSecret, u.TOTPEnabled, u.TOTPLastCounter = "", false, 0 db.Users[name] = u fmt.Println("disabled 2FA for", name) return nil diff --git a/cmd/trace/totp_replay_test.go b/cmd/trace/totp_replay_test.go new file mode 100644 index 0000000..3cb722c --- /dev/null +++ b/cmd/trace/totp_replay_test.go @@ -0,0 +1,101 @@ +package main + +import ( + "net/http" + "net/http/httptest" + "net/url" + "path/filepath" + "strings" + "testing" + "time" +) + +func newTOTPApp(t *testing.T, secret string) (*app, string) { + t.Helper() + root := filepath.Join(t.TempDir(), "node") + if err := initData(root); err != nil { + t.Fatal(err) + } + a, err := newApp(root) + if err != nil { + t.Fatal(err) + } + if err := a.store.updateUsers(func(db *userDB) error { + u := db.Users["admin"] + u.TOTPSecret, u.TOTPEnabled = secret, true + db.Users["admin"] = u + return nil + }); err != nil { + t.Fatal(err) + } + return a, root +} + +func postLogin(t *testing.T, a *app, token, code string) *httptest.ResponseRecorder { + t.Helper() + page := httptest.NewRecorder() + a.loginPage(page, httptest.NewRequest(http.MethodGet, "/login", nil), "", http.StatusOK) + cookie := page.Result().Cookies()[0] + form := url.Values{"csrf": {cookie.Value}, "username": {"admin"}, "token": {token}, "totp": {code}} + req := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.AddCookie(cookie) + res := httptest.NewRecorder() + a.ServeHTTP(res, req) + return res +} + +func TestTOTPCodesCannotBeReplayed(t *testing.T) { + secret := "JBSWY3DPEHPK3PXP" + a, root := newTOTPApp(t, secret) + token := adminToken(t, root) + code, err := totpCode(secret, time.Now()) + if err != nil { + t.Fatal(err) + } + if res := postLogin(t, a, token, code); res.Code != http.StatusSeeOther { + t.Fatalf("first use of a valid code: %d %s", res.Code, res.Body.String()) + } + if res := postLogin(t, a, token, code); res.Code == http.StatusSeeOther { + t.Fatal("the same TOTP code was accepted twice") + } + previous, err := totpCode(secret, time.Now().Add(-30*time.Second)) + if err != nil { + t.Fatal(err) + } + if previous != code { + if res := postLogin(t, a, token, previous); res.Code == http.StatusSeeOther { + t.Fatal("an older code from the skew window was accepted after a newer one") + } + } + db, _ := a.store.loadUsers() + if db.Users["admin"].TOTPLastCounter == 0 { + t.Fatal("last accepted TOTP counter was not recorded") + } +} + +func TestTOTPFailuresBackOffPerAccount(t *testing.T) { + secret := "JBSWY3DPEHPK3PXP" + a, root := newTOTPApp(t, secret) + token := adminToken(t, root) + for i := 0; i < totpMaxFailures; i++ { + if res := postLogin(t, a, token, "000000"); res.Code != http.StatusUnauthorized { + t.Fatalf("wrong code %d: %d", i+1, res.Code) + } + } + code, err := totpCode(secret, time.Now()) + if err != nil { + t.Fatal(err) + } + if code == "000000" { + t.Skip("current TOTP code happens to be 000000") + } + res := postLogin(t, a, token, code) + if res.Code != http.StatusTooManyRequests || !strings.Contains(res.Body.String(), "Too many") { + t.Fatalf("a correct code was accepted during the lockout: %d", res.Code) + } + a.totp.reset("admin") + if res := postLogin(t, a, token, code); res.Code != http.StatusSeeOther { + t.Fatalf("correct code after the lockout ended: %d %s", res.Code, res.Body.String()) + } +} diff --git a/cmd/trace/users.go b/cmd/trace/users.go index 2acb060..ce545a7 100644 --- a/cmd/trace/users.go +++ b/cmd/trace/users.go @@ -27,6 +27,9 @@ type userRecord struct { SSHKeys []string `json:"ssh_keys,omitempty"` TOTPSecret string `json:"totp_secret,omitempty"` TOTPEnabled bool `json:"totp_enabled,omitempty"` + // TOTPLastCounter is the last accepted TOTP time step; codes for this + // step or earlier are refused. + TOTPLastCounter int64 `json:"totp_last_counter,omitempty"` // OIDCIssuer and OIDCSubject bind the account to one identity-provider // identity. OIDC sign-in only ever opens the account whose stored // (issuer, subject) pair matches; usernames and emails are not trusted. From 4127b3f8bbca142f23626e6ccbe5cc7411b12054 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 27 Sep 2026 04:23:36 +0530 Subject: [PATCH 25/30] fix(backup): refuse archive paths inside the data directory createBackup only rejected an output path equal to the data directory. `-out ./data/x.tar.gz` (or a path reaching data/ through a symlink) was accepted, and filepath.Walk then reached the archive while it was still growing, failing with tar.ErrWriteTooLong or embedding a partial copy of itself. Refuse any output path that resolves to the data directory or below it, comparing symlink-resolved paths. The README also states that backups of a running node are not point-in-time consistent. Finding: F094 --- README.md | 2 +- cmd/trace/backup.go | 35 +++++++++++++++++++++++++++++++++-- cmd/trace/backup_test.go | 32 ++++++++++++++++++++++++++++++++ 3 files changed, 66 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index a035153..37f7b25 100644 --- a/README.md +++ b/README.md @@ -70,7 +70,7 @@ Trace includes a local backup and restore utility. Backups contain the bare repo ./trace backup restore -data ./restored-data -out /secure/backups/trace-20260920.tar.gz ``` -Restore refuses to write into a non-empty directory unless `-force` is supplied. This is a single-node snapshot mechanism; schedule it and copy the archive to separate storage for disaster recovery. +Restore refuses to write into a non-empty directory unless `-force` is supplied, and `create` refuses an output path inside the data directory (including through symlinks). Backups of a running node are file-by-file copies taken while it keeps writing, so they are not a point-in-time snapshot; stop Trace or pause writes for a fully consistent backup. This is a single-node mechanism; schedule it and copy the archive to separate storage for disaster recovery. To add a teammate from the CLI: diff --git a/cmd/trace/backup.go b/cmd/trace/backup.go index 92555fe..1033a73 100644 --- a/cmd/trace/backup.go +++ b/cmd/trace/backup.go @@ -51,9 +51,11 @@ func createBackup(data, output string) error { if info, err := os.Stat(root); err != nil || !info.IsDir() { return errors.New("backup source must be a directory") } - if outputPath, err := filepath.Abs(output); err != nil { + if inside, err := pathWithin(output, root); err != nil { return err - } else if filepath.Clean(outputPath) == filepath.Clean(root) { + } else if inside { + // An archive inside the tree being archived would be walked while it + // grows, failing or embedding a partial copy of itself. return errors.New("backup output must be outside the data directory") } f, err := os.OpenFile(output, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600) @@ -235,3 +237,32 @@ func validateArchiveName(name string) error { } return nil } + +// pathWithin reports whether path is dir or lies below it, comparing +// symlink-resolved absolute paths. path itself need not exist yet. +func pathWithin(path, dir string) (bool, error) { + resolvedDir, err := filepath.Abs(dir) + if err != nil { + return false, err + } + if real, err := filepath.EvalSymlinks(resolvedDir); err == nil { + resolvedDir = real + } + absPath, err := filepath.Abs(path) + if err != nil { + return false, err + } + parent, base := filepath.Dir(absPath), filepath.Base(absPath) + if real, err := filepath.EvalSymlinks(parent); err == nil { + parent = real + } + candidate := filepath.Join(parent, base) + if real, err := filepath.EvalSymlinks(candidate); err == nil { + candidate = real + } + rel, err := filepath.Rel(resolvedDir, candidate) + if err != nil { + return false, nil + } + return rel == "." || (rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))), nil +} diff --git a/cmd/trace/backup_test.go b/cmd/trace/backup_test.go index 2542c47..aab2841 100644 --- a/cmd/trace/backup_test.go +++ b/cmd/trace/backup_test.go @@ -42,3 +42,35 @@ func TestBackupRejectsUnsafeEntry(t *testing.T) { t.Fatal("absolute archive path accepted") } } + +func TestBackupRefusesOutputInsideDataDirectory(t *testing.T) { + root := t.TempDir() + if err := initData(root); err != nil { + t.Fatal(err) + } + link := filepath.Join(t.TempDir(), "data-link") + if err := os.Symlink(root, link); err != nil { + t.Fatal(err) + } + for _, output := range []string{ + filepath.Join(root, "backup.tar.gz"), + filepath.Join(root, "repos", "nested.tar.gz"), + filepath.Join(link, "via-symlink.tar.gz"), + } { + if err := createBackup(root, output); err == nil { + t.Fatalf("backup written inside the data directory: %s", output) + } + if _, err := os.Stat(output); !os.IsNotExist(err) { + t.Fatalf("refused backup still created %s: %v", output, err) + } + } + // A sibling directory whose name merely starts with the data path is fine. + sibling := root + "-backups" + if err := os.MkdirAll(sibling, 0700); err != nil { + t.Fatal(err) + } + defer os.RemoveAll(sibling) + if err := createBackup(root, filepath.Join(sibling, "ok.tar.gz")); err != nil { + t.Fatalf("backup next to the data directory refused: %v", err) + } +} From bf6c9fbf779b81c0bee8dd5af1e10d2ffdcc7612 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 27 Sep 2026 04:25:56 +0530 Subject: [PATCH 26/30] fix(repo): rename transfer references structurally, atomically, and under locks Repository transfer ran bytes.ReplaceAll(`"old/name"`, `"new/name"`) over every *.json and *.jsonl file in the data directory and wrote them back with os.WriteFile, outside each store's lock. Any string value equal to the old name (issue titles, comment bodies, audit resources) was rewritten, the append-only audit ledger was modified, a crash part-way left stores partially renamed, and concurrent writers could lose updates. Package files and release assets stayed under the old name. - Rewrite only structural references: keys of repository-keyed maps (including "REPO\x00..." schedule keys) and "repo" fields. Free text, audit.jsonl, and oidc.json are never touched. - Rewrite each store under its own lock (and actionRunMu for actions.json) and replace files atomically. - Record the transfer in .transfer-journal.json before any change; all steps are idempotent and openStore completes an interrupted transfer. - Move data/lfs, data/packages and data/release-assets directories. Finding: F095 --- README.md | 2 +- cmd/trace/main.go | 90 +--------- cmd/trace/repo_transfer.go | 294 ++++++++++++++++++++++++++++++++ cmd/trace/repo_transfer_test.go | 147 ++++++++++++++++ 4 files changed, 450 insertions(+), 83 deletions(-) create mode 100644 cmd/trace/repo_transfer.go create mode 100644 cmd/trace/repo_transfer_test.go diff --git a/README.md b/README.md index 37f7b25..c63005d 100644 --- a/README.md +++ b/README.md @@ -547,7 +547,7 @@ Administrators can move or permanently remove a repository: ./trace repo delete -data ./data team/platform ``` -The authenticated API provides `POST /api/v1/repos/OWNER/NAME/transfer` with `{"name":"NEW_OWNER/NAME"}` and `DELETE /api/v1/repos/OWNER/NAME`. The dashboard exposes the same controls. Deletion removes the bare repository and is irreversible; transfer keeps Git configuration and rewrites repository references in Trace metadata. +The authenticated API provides `POST /api/v1/repos/OWNER/NAME/transfer` with `{"name":"NEW_OWNER/NAME"}` and `DELETE /api/v1/repos/OWNER/NAME`. The dashboard exposes the same controls. Deletion removes the bare repository and is irreversible; transfer keeps Git configuration, moves the repository's LFS objects, packages, and release assets, and renames repository references in Trace metadata (repository-keyed records and `repo` fields, each store under its own lock). Issue and comment text and the audit ledger are left unchanged. A transfer is recorded in `data/.transfer-journal.json` first, so an interrupted transfer is completed the next time Trace opens the data directory. Administrators can archive and restore repositories: diff --git a/cmd/trace/main.go b/cmd/trace/main.go index c083cdc..40fcf95 100644 --- a/cmd/trace/main.go +++ b/cmd/trace/main.go @@ -1,7 +1,6 @@ package main import ( - "bytes" "crypto/rand" "encoding/base64" "encoding/json" @@ -467,7 +466,14 @@ func openStore(data string) (*store, error) { if !info.IsDir() { return nil, errors.New("data path is not a directory") } - return &store{root: root, repos: filepath.Join(root, "repos")}, nil + s := &store{root: root, repos: filepath.Join(root, "repos")} + transferMu.Lock() + err = s.completePendingTransfer() + transferMu.Unlock() + if err != nil { + return nil, fmt.Errorf("complete interrupted repository transfer: %w", err) + } + return s, nil } func initData(data string) error { @@ -602,86 +608,6 @@ func (s *store) deleteRepo(name string) error { return nil } -func (s *store) transferRepo(source, target string) error { - if !validRepoName(source) || !validRepoName(target) { - return errors.New("repository names must be OWNER/NAME") - } - if source == target { - return errors.New("source and target repository are the same") - } - sourcePath, err := s.repoPath(source) - if err != nil { - return err - } - targetPath, err := s.repoPath(target) - if err != nil { - return err - } - if _, err := os.Stat(sourcePath); err != nil { - return errors.New("source repository not found") - } - if _, err := os.Stat(targetPath); err == nil { - return errors.New("target repository already exists") - } else if !errors.Is(err, os.ErrNotExist) { - return err - } - if err := os.MkdirAll(filepath.Dir(targetPath), 0700); err != nil { - return err - } - if err := os.Rename(sourcePath, targetPath); err != nil { - return fmt.Errorf("transfer repository: %w", err) - } - if err := s.moveRepoDir("lfs", source, target); err != nil { - return fmt.Errorf("transfer LFS objects: %w", err) - } - if err := s.rewriteRepoReferences(source, target); err != nil { - return fmt.Errorf("rewrite repository references: %w", err) - } - return nil -} - -// moveRepoDir renames data/KIND/OWNER/NAME for a repository transfer. -func (s *store) moveRepoDir(kind, source, target string) error { - sourceOwner, sourceName, _ := strings.Cut(source, "/") - targetOwner, targetName, _ := strings.Cut(target, "/") - from := filepath.Join(s.root, kind, sourceOwner, sourceName) - to := filepath.Join(s.root, kind, targetOwner, targetName) - if _, err := os.Stat(from); errors.Is(err, os.ErrNotExist) { - return nil - } else if err != nil { - return err - } - if err := os.MkdirAll(filepath.Dir(to), 0700); err != nil { - return err - } - return os.Rename(from, to) -} - -func (s *store) rewriteRepoReferences(source, target string) error { - entries, err := os.ReadDir(s.root) - if err != nil { - return err - } - for _, entry := range entries { - if entry.IsDir() || !(strings.HasSuffix(entry.Name(), ".json") || strings.HasSuffix(entry.Name(), ".jsonl")) { - continue - } - path := filepath.Join(s.root, entry.Name()) - b, err := os.ReadFile(path) - if err != nil { - return err - } - updated := bytes.ReplaceAll(b, []byte(`"`+source+`"`), []byte(`"`+target+`"`)) - if bytes.Equal(updated, b) { - continue - } - if err := os.WriteFile(path, updated, 0600); err != nil { - return err - } - } - return nil -} - func (s *store) forkRepo(source, target string) error { if !validRepoName(source) || !validRepoName(target) { return errors.New("repository names must be OWNER/NAME") diff --git a/cmd/trace/repo_transfer.go b/cmd/trace/repo_transfer.go new file mode 100644 index 0000000..bddf8bd --- /dev/null +++ b/cmd/trace/repo_transfer.go @@ -0,0 +1,294 @@ +package main + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "sync" + "syscall" +) + +// Repository transfer renames a repository and every metadata reference to +// it. The steps are idempotent and recorded in a journal first, so a crash +// part-way through is completed by the next openStore instead of leaving +// stores half renamed. + +const transferJournalFile = ".transfer-journal.json" + +// Per-repository data directories (data/KIND/OWNER/NAME) that move with the +// repository. +var repoDataDirs = []string{"lfs", "packages", "release-assets"} + +// Files that must never be rewritten: the audit ledger is append-only and +// records history under the names that were current at the time. +var transferSkippedFiles = map[string]bool{auditFile: true, oidcConfigFile: true, "rate-state.json": true} + +// transferMu serializes transfers within one process. +var transferMu sync.Mutex + +type transferJournal struct { + Source string `json:"source"` + Target string `json:"target"` +} + +func (s *store) transferRepo(source, target string) error { + if !validRepoName(source) || !validRepoName(target) { + return errors.New("repository names must be OWNER/NAME") + } + if source == target { + return errors.New("source and target repository are the same") + } + transferMu.Lock() + defer transferMu.Unlock() + if err := s.completePendingTransfer(); err != nil { + return fmt.Errorf("complete interrupted transfer: %w", err) + } + sourcePath, err := s.repoPath(source) + if err != nil { + return err + } + targetPath, err := s.repoPath(target) + if err != nil { + return err + } + if _, err := os.Stat(sourcePath); err != nil { + return errors.New("source repository not found") + } + if _, err := os.Stat(targetPath); err == nil { + return errors.New("target repository already exists") + } else if !errors.Is(err, os.ErrNotExist) { + return err + } + journal := transferJournal{Source: source, Target: target} + b, err := json.Marshal(journal) + if err != nil { + return err + } + if err := writeFileAtomic(filepath.Join(s.root, transferJournalFile), append(b, '\n')); err != nil { + return fmt.Errorf("record transfer: %w", err) + } + return s.applyTransfer(journal) +} + +// completePendingTransfer finishes a transfer that was interrupted. +func (s *store) completePendingTransfer() error { + b, err := os.ReadFile(filepath.Join(s.root, transferJournalFile)) + if errors.Is(err, os.ErrNotExist) { + return nil + } + if err != nil { + return err + } + var journal transferJournal + if err := json.Unmarshal(b, &journal); err != nil || !validRepoName(journal.Source) || !validRepoName(journal.Target) { + return errors.New("invalid transfer journal " + transferJournalFile) + } + return s.applyTransfer(journal) +} + +func (s *store) applyTransfer(journal transferJournal) error { + sourcePath, err := s.repoPath(journal.Source) + if err != nil { + return err + } + targetPath, err := s.repoPath(journal.Target) + if err != nil { + return err + } + if _, err := os.Stat(sourcePath); err == nil { + if _, err := os.Stat(targetPath); err == nil { + return fmt.Errorf("both %s and %s exist; resolve manually and remove %s", journal.Source, journal.Target, transferJournalFile) + } + if err := os.MkdirAll(filepath.Dir(targetPath), 0700); err != nil { + return err + } + if err := os.Rename(sourcePath, targetPath); err != nil { + return fmt.Errorf("transfer repository: %w", err) + } + } + for _, kind := range repoDataDirs { + if err := s.moveRepoDir(kind, journal.Source, journal.Target); err != nil { + return fmt.Errorf("transfer %s: %w", kind, err) + } + } + if err := s.rewriteRepoReferences(journal.Source, journal.Target); err != nil { + return fmt.Errorf("rewrite repository references: %w", err) + } + return os.Remove(filepath.Join(s.root, transferJournalFile)) +} + +// moveRepoDir renames data/KIND/OWNER/NAME for a repository transfer. +func (s *store) moveRepoDir(kind, source, target string) error { + sourceOwner, sourceName, _ := strings.Cut(source, "/") + targetOwner, targetName, _ := strings.Cut(target, "/") + from := filepath.Join(s.root, kind, sourceOwner, sourceName) + to := filepath.Join(s.root, kind, targetOwner, targetName) + if _, err := os.Stat(from); errors.Is(err, os.ErrNotExist) { + return nil + } else if err != nil { + return err + } + if _, err := os.Stat(to); err == nil { + // Leftovers of an earlier repository with the target name. + if err := os.RemoveAll(to); err != nil { + return err + } + } + if err := os.MkdirAll(filepath.Dir(to), 0700); err != nil { + return err + } + return os.Rename(from, to) +} + +// rewriteRepoReferences renames source to target in every JSON metadata +// store, one store at a time under that store's own lock, replacing each file +// atomically. Only structural references change: keys of repository-keyed +// maps (and "REPO\x00..." keys) and "repo" fields. Free text such as issue +// titles or comment bodies is never touched, nor is the audit ledger. +func (s *store) rewriteRepoReferences(source, target string) error { + entries, err := os.ReadDir(s.root) + if err != nil { + return err + } + var files []string + for _, entry := range entries { + name := entry.Name() + if entry.Type().IsRegular() && strings.HasSuffix(name, ".json") && !strings.HasPrefix(name, ".") && !transferSkippedFiles[name] { + files = append(files, name) + } + } + sort.Strings(files) + for _, name := range files { + if err := s.rewriteStoreFile(name, source, target); err != nil { + return fmt.Errorf("%s: %w", name, err) + } + } + return nil +} + +func (s *store) rewriteStoreFile(name, source, target string) error { + // Every file-backed store locks "..lock"; the action database is + // guarded by an in-process mutex. + unlock, err := lockStoreFile(filepath.Join(s.root, "."+strings.TrimSuffix(name, ".json")+".lock")) + if err != nil { + return err + } + defer unlock() + if name == "actions.json" { + actionRunMu.Lock() + defer actionRunMu.Unlock() + } + path := filepath.Join(s.root, name) + b, err := os.ReadFile(path) + if err != nil { + return err + } + if !bytes.Contains(b, []byte(source)) { + return nil + } + decoder := json.NewDecoder(bytes.NewReader(b)) + decoder.UseNumber() + var document any + if err := decoder.Decode(&document); err != nil { + return fmt.Errorf("parse: %w", err) + } + updated, changed := renameRepoReferences(document, source, target, "") + if !changed { + return nil + } + out, err := json.MarshalIndent(updated, "", " ") + if err != nil { + return err + } + return writeFileAtomic(path, append(out, '\n')) +} + +// renameRepoReferences walks a decoded JSON value. field is the key under +// which value appears in its parent object. +func renameRepoReferences(value any, source, target, field string) (any, bool) { + switch v := value.(type) { + case map[string]any: + changed := false + out := make(map[string]any, len(v)) + keys := make([]string, 0, len(v)) + for key := range v { + keys = append(keys, key) + } + sort.Strings(keys) + for _, key := range keys { + child, childChanged := renameRepoReferences(v[key], source, target, key) + newKey := key + if key == source { + newKey = target + } else if strings.HasPrefix(key, source+"\x00") { + newKey = target + strings.TrimPrefix(key, source) + } + if newKey != key || childChanged { + changed = true + } + out[newKey] = child + } + return out, changed + case []any: + changed := false + for i := range v { + child, childChanged := renameRepoReferences(v[i], source, target, field) + v[i] = child + changed = changed || childChanged + } + return v, changed + case string: + if field == "repo" && v == source { + return target, true + } + return v, false + default: + return v, false + } +} + +func lockStoreFile(path string) (func(), error) { + lock, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0600) + if err != nil { + return nil, err + } + if err := syscall.Flock(int(lock.Fd()), syscall.LOCK_EX); err != nil { + lock.Close() + return nil, err + } + return func() { + _ = syscall.Flock(int(lock.Fd()), syscall.LOCK_UN) + _ = lock.Close() + }, nil +} + +// writeFileAtomic replaces path with data via a synced temporary file. +func writeFileAtomic(path string, data []byte) error { + tmp, err := os.CreateTemp(filepath.Dir(path), ".tmp-"+filepath.Base(path)+"-*") + if err != nil { + return err + } + name := tmp.Name() + defer os.Remove(name) + if err := tmp.Chmod(0600); err != nil { + tmp.Close() + return err + } + if _, err := tmp.Write(data); err != nil { + tmp.Close() + return err + } + if err := tmp.Sync(); err != nil { + tmp.Close() + return err + } + if err := tmp.Close(); err != nil { + return err + } + return os.Rename(name, path) +} diff --git a/cmd/trace/repo_transfer_test.go b/cmd/trace/repo_transfer_test.go new file mode 100644 index 0000000..08f9ffd --- /dev/null +++ b/cmd/trace/repo_transfer_test.go @@ -0,0 +1,147 @@ +package main + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +// TestTransferRewritesOnlyRepositoryReferences reproduces the byte-level +// rewrite: free text equal to the old name was changed, and the append-only +// audit ledger was rewritten. +func TestTransferRewritesOnlyRepositoryReferences(t *testing.T) { + root := t.TempDir() + if err := initData(root); err != nil { + t.Fatal(err) + } + s, err := openStore(root) + if err != nil { + t.Fatal(err) + } + if err := s.createRepo("team/old", false); err != nil { + t.Fatal(err) + } + if _, err := s.addUser("alice", false); err != nil { + t.Fatal(err) + } + if err := s.grantUser("alice", "team/old", "write"); err != nil { + t.Fatal(err) + } + created, err := s.createIssue("team/old", "team/old", "Rename team/old soon", "alice", "", nil) + if err != nil { + t.Fatal(err) + } + if err := s.setStar("team/old", "alice", true); err != nil { + t.Fatal(err) + } + if err := s.setSecret("team/old", "DEPLOY", "value"); err != nil { + t.Fatal(err) + } + if _, err := s.publishPackage("team/old", "lib", "1.0.0", "lib.tgz", "alice", strings.NewReader("package bytes"), int64(len("package bytes"))); err != nil { + t.Fatal(err) + } + if err := s.recordAudit("alice", "issue.create", "team/old", "1", map[string]any{"title": "team/old"}); err != nil { + t.Fatal(err) + } + actions := actionDB{NextRunID: 2, LastScheduledAt: map[string]time.Time{"team/old\x00main": time.Now().UTC()}, Runs: []actionRun{{ID: 1, Repo: "team/old", Status: "success"}}} + if err := s.saveActions(actions); err != nil { + t.Fatal(err) + } + auditBefore, err := os.ReadFile(filepath.Join(root, auditFile)) + if err != nil { + t.Fatal(err) + } + + if err := s.transferRepo("team/old", "team/new"); err != nil { + t.Fatal(err) + } + + issues, err := s.loadIssues() + if err != nil { + t.Fatal(err) + } + moved := issues.Repos["team/new"] + if len(issues.Repos["team/old"]) != 0 || len(moved) != 1 || moved[0].ID != created.ID { + t.Fatalf("issues were not moved to the new name: %+v", issues.Repos) + } + if moved[0].Title != "team/old" || moved[0].Body != "Rename team/old soon" || moved[0].Repo != "team/new" { + t.Fatalf("issue text changed or repo field not updated: %+v", moved[0]) + } + auditAfter, err := os.ReadFile(filepath.Join(root, auditFile)) + if err != nil || !bytes.Equal(auditBefore, auditAfter) { + t.Fatalf("the append-only audit ledger was rewritten: %v", err) + } + users, _ := s.loadUsers() + if users.Users["alice"].Repos["team/new"] != "write" || users.Users["alice"].Repos["team/old"] != "" { + t.Fatalf("grant not moved: %+v", users.Users["alice"].Repos) + } + secrets, err := s.actionSecrets("team/new") + if err != nil || secrets["TRACE_SECRET_DEPLOY"] != "value" { + t.Fatalf("secrets not moved: %v %v", secrets, err) + } + loaded, err := s.loadActions() + if err != nil || loaded.Runs[0].Repo != "team/new" || loaded.LastScheduledAt["team/new\x00main"].IsZero() { + t.Fatalf("action state not moved: %+v %v", loaded, err) + } + newPackage, err := s.packagePath("team/new", "lib", "1.0.0", "lib.tgz") + if err != nil { + t.Fatal(err) + } + if b, err := os.ReadFile(newPackage); err != nil || string(b) != "package bytes" { + t.Fatalf("package files were orphaned by the transfer: %q %v", b, err) + } + if _, err := os.Stat(filepath.Join(root, transferJournalFile)); !os.IsNotExist(err) { + t.Fatalf("transfer journal left behind: %v", err) + } + var starsCheck map[string]any + raw, _ := os.ReadFile(filepath.Join(root, "stars.json")) + if err := json.Unmarshal(raw, &starsCheck); err != nil || strings.Contains(string(raw), `"team/old"`) { + t.Fatalf("stars.json still references the old name: %s", raw) + } +} + +func TestInterruptedTransferCompletesOnOpen(t *testing.T) { + root := t.TempDir() + if err := initData(root); err != nil { + t.Fatal(err) + } + s, err := openStore(root) + if err != nil { + t.Fatal(err) + } + if err := s.createRepo("team/old", false); err != nil { + t.Fatal(err) + } + if _, err := s.addUser("alice", false); err != nil { + t.Fatal(err) + } + if err := s.grantUser("alice", "team/old", "read"); err != nil { + t.Fatal(err) + } + // Simulate a crash after the journal was written and the Git directory + // was renamed, but before metadata was rewritten. + journal, _ := json.Marshal(transferJournal{Source: "team/old", Target: "team/new"}) + if err := os.WriteFile(filepath.Join(root, transferJournalFile), journal, 0600); err != nil { + t.Fatal(err) + } + oldPath, _ := s.repoPath("team/old") + newPath, _ := s.repoPath("team/new") + if err := os.Rename(oldPath, newPath); err != nil { + t.Fatal(err) + } + reopened, err := openStore(root) + if err != nil { + t.Fatal(err) + } + users, _ := reopened.loadUsers() + if users.Users["alice"].Repos["team/new"] != "read" || users.Users["alice"].Repos["team/old"] != "" { + t.Fatalf("interrupted transfer was not completed: %+v", users.Users["alice"].Repos) + } + if _, err := os.Stat(filepath.Join(root, transferJournalFile)); !os.IsNotExist(err) { + t.Fatalf("journal not removed after recovery: %v", err) + } +} From 714c6f5afb4724b1f7518e037b1e9de7bb963bc1 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 27 Sep 2026 04:28:54 +0530 Subject: [PATCH 27/30] fix(deps): bump golang.org/x/crypto to v0.56.0 govulncheck v1.1.4 reports two vulnerabilities reachable from Trace's SSH server in golang.org/x/crypto v0.55.0, both fixed in v0.56.0: - GO-2026-6355: DoS on a deadlocked established channel in x/crypto/ssh - GO-2026-6354: DoS on a deadlocked undecided channel in x/crypto/ssh `go get` also normalizes the go directive from 1.26 to 1.26.0, which x/crypto v0.56.0 requires; the language version is unchanged. --- go.mod | 4 ++-- go.sum | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/go.mod b/go.mod index 58e3e4b..8187f99 100644 --- a/go.mod +++ b/go.mod @@ -1,7 +1,7 @@ module trace -go 1.26 +go 1.26.0 -require golang.org/x/crypto v0.55.0 +require golang.org/x/crypto v0.56.0 require golang.org/x/sys v0.47.0 // indirect diff --git a/go.sum b/go.sum index d29e079..9952466 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,5 @@ -golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= -golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= +golang.org/x/crypto v0.56.0 h1:GUh5Ii4J5jtcseSMiRqr1jXCNHoxjeV9Fmekc2oLy6Y= +golang.org/x/crypto v0.56.0/go.mod h1:OMW5y6CY9l38uPLmxU6l6pwcXp1obtLo3e6gT7gQR2I= golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= From 46bf803ccb5789a432b80fd86104cae55ca4bb13 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 27 Sep 2026 04:34:44 +0530 Subject: [PATCH 28/30] ci: add GitHub Actions workflow with pinned Go and job timeouts Byte-identical to the workflow added in the release-readiness PR (F100), included so this PR is checked by CI too; identical additions merge cleanly in either order. It pins Go 1.26.6, sets job timeouts, runs the tests on Linux and macOS, runs the race detector, and runs govulncheck. --- .github/workflows/ci.yml | 84 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..cd2ff5c --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,84 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + # Pinned toolchain, matching the release workflow and sibling repositories. + GO_VERSION: "1.26.6" + # Keep this repository buildable regardless of any go.work file in a + # parent directory of a local multi-repository checkout. + GOWORK: "off" + +jobs: + quality: + name: tidy + fmt + build + vet + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + with: + go-version: ${{ env.GO_VERSION }} + cache: true + - name: Module tidy is clean + run: | + go mod tidy + git diff --exit-code -- go.mod go.sum + - name: gofmt + run: test -z "$(gofmt -l .)" + - run: go build ./... + - run: go vet ./... + + vulncheck: + name: govulncheck + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + with: + go-version: ${{ env.GO_VERSION }} + cache: true + - run: go run golang.org/x/vuln/cmd/govulncheck@v1.1.4 ./... + + test: + name: test (${{ matrix.os }}) + strategy: + fail-fast: false + matrix: + # macOS exercises the sandbox-exec runner; Linux the Docker path. + os: [ubuntu-latest, macos-latest] + runs-on: ${{ matrix.os }} + timeout-minutes: 30 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + with: + go-version: ${{ env.GO_VERSION }} + cache: true + # The suite starts real listeners and shells out to git and ssh. + - run: go test -count=1 -timeout=20m ./... + + race: + name: race + runs-on: ubuntu-latest + timeout-minutes: 40 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + with: + go-version: ${{ env.GO_VERSION }} + cache: true + - run: go test -race -count=1 -timeout=30m ./... From c71fe900a768915cec1f3a5367496594a3419e7e Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 27 Sep 2026 04:38:55 +0530 Subject: [PATCH 29/30] fix(lfs): keep serving when a repository cannot be scanned for migration migrateLegacyLFS runs at server start and returned an error, stopping `trace serve`, if any one repository could not be scanned for LFS pointers. Log and skip such a repository instead, and leave the legacy objects in place when any scan was incomplete so a later start can finish the migration after the repository is repaired. Follow-up to the F091 change. --- cmd/trace/lfs.go | 17 +++++++++++--- cmd/trace/lfs_isolation_test.go | 39 +++++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 3 deletions(-) diff --git a/cmd/trace/lfs.go b/cmd/trace/lfs.go index a8a5a1d..3b0ec95 100644 --- a/cmd/trace/lfs.go +++ b/cmd/trace/lfs.go @@ -8,6 +8,7 @@ import ( "errors" "fmt" "io" + "log" "net/http" "net/url" "os" @@ -349,7 +350,9 @@ func (s *store) copyLFSObjects(source, target string) error { // data/lfs/ into the repositories whose Git history contains a pointer // to them. Objects no repository references are moved to // data/lfs/.legacy-unreferenced and are no longer served. It is idempotent -// and runs when the server starts. +// and runs when the server starts. A repository that cannot be scanned is +// logged and skipped, and legacy objects then stay in place so the next +// start can finish the migration once the repository is repaired. func (s *store) migrateLegacyLFS() error { lfsRoot := filepath.Join(s.root, "lfs") entries, err := os.ReadDir(lfsRoot) @@ -372,13 +375,16 @@ func (s *store) migrateLegacyLFS() error { if err != nil { return err } + complete := true for _, owner := range owners { if !owner.IsDir() || !namePattern.MatchString(owner.Name()) { continue } repos, err := os.ReadDir(filepath.Join(s.repos, owner.Name())) if err != nil { - return err + log.Printf("trace: LFS migration cannot list %s: %v", owner.Name(), err) + complete = false + continue } for _, entry := range repos { name := strings.TrimSuffix(entry.Name(), ".git") @@ -388,7 +394,9 @@ func (s *store) migrateLegacyLFS() error { repo := owner.Name() + "/" + name oids, err := lfsPointerOIDs(filepath.Join(s.repos, owner.Name(), entry.Name())) if err != nil { - return fmt.Errorf("scan LFS pointers in %s: %w", repo, err) + log.Printf("trace: LFS migration cannot scan %s: %v", repo, err) + complete = false + continue } for oid := range oids { if !legacy[oid] { @@ -404,6 +412,9 @@ func (s *store) migrateLegacyLFS() error { } } } + if !complete { + return nil + } unreferenced := filepath.Join(lfsRoot, ".legacy-unreferenced") if err := os.MkdirAll(unreferenced, 0700); err != nil { return err diff --git a/cmd/trace/lfs_isolation_test.go b/cmd/trace/lfs_isolation_test.go index 3812411..73448c0 100644 --- a/cmd/trace/lfs_isolation_test.go +++ b/cmd/trace/lfs_isolation_test.go @@ -167,3 +167,42 @@ func TestLegacyLFSObjectsMigrateToReferencingRepositories(t *testing.T) { t.Fatalf("unreferenced legacy object was not preserved: %v", err) } } + +// TestLegacyLFSMigrationToleratesUnreadableRepositories checks that one +// broken repository neither blocks startup nor causes legacy objects to be +// set aside before every repository could be scanned. +func TestLegacyLFSMigrationToleratesUnreadableRepositories(t *testing.T) { + root := filepath.Join(t.TempDir(), "node") + if err := initData(root); err != nil { + t.Fatal(err) + } + s, err := openStore(root) + if err != nil { + t.Fatal(err) + } + if err := s.createRepo("team/broken", false); err != nil { + t.Fatal(err) + } + brokenPath, _ := s.repoPath("team/broken") + if err := os.RemoveAll(filepath.Join(brokenPath, "objects")); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(brokenPath, "HEAD"), []byte("garbage"), 0600); err != nil { + t.Fatal(err) + } + content := []byte("legacy\n") + sum := sha256.Sum256(content) + oid := hex.EncodeToString(sum[:]) + if err := os.MkdirAll(filepath.Join(root, "lfs"), 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "lfs", oid), content, 0600); err != nil { + t.Fatal(err) + } + if err := s.migrateLegacyLFS(); err != nil { + t.Fatalf("one unreadable repository blocked the migration: %v", err) + } + if _, err := os.Stat(filepath.Join(root, "lfs", oid)); err != nil { + t.Fatalf("legacy object was moved although a repository could not be scanned: %v", err) + } +} From 45d0efa5dfe92123ce7b0d7f84233efd5af4203e Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 27 Sep 2026 04:46:10 +0530 Subject: [PATCH 30/30] test: keep repository fixtures independent of a host Git LFS install CI failed TestLegacyLFSObjectsMigrateToReferencingRepositories on ubuntu and macOS runners, which have Git LFS installed: its pre-push hook tried to upload the fixture's pointer object to the local bare repository ("batch request: missing protocol"). The test passed locally only because this machine has no git-lfs. The commitFiles helper now pushes with client hooks disabled (core.hooksPath=/dev/null, GIT_LFS_SKIP_PUSH=1), and the migration fixture no longer adds an LFS .gitattributes, which the content-based pointer scan does not need. Reproduced and verified locally with a global Git config whose pre-push hook always fails. --- cmd/trace/lfs_isolation_test.go | 4 +++- cmd/trace/raw_limits_test.go | 6 ++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/cmd/trace/lfs_isolation_test.go b/cmd/trace/lfs_isolation_test.go index 73448c0..601c563 100644 --- a/cmd/trace/lfs_isolation_test.go +++ b/cmd/trace/lfs_isolation_test.go @@ -142,7 +142,9 @@ func TestLegacyLFSObjectsMigrateToReferencingRepositories(t *testing.T) { } } pointer := "version https://git-lfs.github.com/spec/v1\noid sha256:" + oid + "\nsize " + strconv.Itoa(len(content)) + "\n" - commitFiles(t, a.store, "team/uses-it", map[string][]byte{"asset.bin": []byte(pointer), ".gitattributes": []byte("*.bin filter=lfs diff=lfs merge=lfs -text\n")}) + // No .gitattributes: the migration finds pointers by content, and an LFS + // filter would make the fixture depend on the host's Git LFS setup. + commitFiles(t, a.store, "team/uses-it", map[string][]byte{"asset.bin": []byte(pointer)}) commitFiles(t, a.store, "team/unrelated", map[string][]byte{"README.md": []byte("nothing here\n")}) if err := a.store.migrateLegacyLFS(); err != nil { diff --git a/cmd/trace/raw_limits_test.go b/cmd/trace/raw_limits_test.go index 00dc121..90093f0 100644 --- a/cmd/trace/raw_limits_test.go +++ b/cmd/trace/raw_limits_test.go @@ -30,8 +30,10 @@ func commitFiles(t *testing.T, s *store, repo string, files map[string][]byte) s gitTest(t, work, "add", ".") gitTest(t, work, "commit", "-m", "files") repoPath, _ := s.repoPath(repo) - push := exec.Command("git", "-C", work, "push", "--force", repoPath, "main") - push.Env = append(os.Environ(), "TRACE_ADMIN=1") + // Disable client hooks: a host-wide Git LFS install adds a pre-push hook + // that would try to upload objects for fixture pointer files. + push := exec.Command("git", "-c", "core.hooksPath=/dev/null", "-C", work, "push", "--force", repoPath, "main") + push.Env = append(os.Environ(), "TRACE_ADMIN=1", "GIT_LFS_SKIP_PUSH=1") if out, err := push.CombinedOutput(); err != nil { t.Fatalf("push: %v\n%s", err, out) }