Skip to content

fix: harden Trace against the September 2026 security audit findings - #2

Open
Patel230 wants to merge 30 commits into
mainfrom
fix/security-hardening
Open

Patel230 wants to merge 30 commits into
mainfrom
fix/security-hardening

Conversation

@Patel230

@Patel230 Patel230 commented Sep 26, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Fixes the 22 security, correctness and reliability findings from the September 2026 audit of Trace (F076–F096, F098), plus six more problems found while writing the regression tests. Every fix has a test; where it was practical, the test was first run against the unfixed code to show it fails (details below). Each commit covers one concern.

Two changes are breaking for existing nodes:

  • trace serve now runs only sandboxed workflows by default. Use -actions trusted to keep running unsandboxed workflows, or -actions off to disable CI.
  • OIDC sign-in now uses the provider's (issuer, sub) pair to find the account. Accounts that earlier versions auto-provisioned are not linked yet and must be linked once with trace sso oidc link USER SUBJECT.

New operator flags: trace serve -actions off|sandboxed|trusted, -trusted-proxy CIDR,..., -webhook-allow-private-networks, and trace sso oidc link|unlink, trace sso oidc set -allowed-email-domains.

Findings addressed

ID Fix Commit
F076 Anonymous clone and fetch of public repositories now work: the POST git-upload-pack step is allowed too. The test runs a real git clone/fetch with no credential helper, over protocol v0 and v2. ff7a0cc
F077 Artifact collection goes through an os.Root bound to the workspace and refuses symlinks, special files and FIFOs, so a job can no longer copy out data/admin-token. dbaccc6
F078 The operator's -actions policy (default sandboxed) decides what runs; workflow files can no longer opt out of the sandbox. 5ac64f9
F079 OIDC maps sign-ins to accounts by (issuer, sub) only and never adopts an existing account by name. Userinfo sub must equal the ID token sub. Optional verified-email domain allowlist. Accounts with TOTP are refused through OIDC. Admins link accounts with link/unlink. 00353f4
F080 Protected-branch patterns follow a strict grammar and are single-quoted in the generated hook. A space-free ${IFS} payload that the old validator accepted is covered by a test. 0ab11fd
F081 /raw serves HTML, SVG, XML and JS as text/plain with CSP sandbox. /pages runs in a CSP sandbox without allow-same-origin. Trace's own pages allow their inline scripts by SHA-256 hash, so script-src has no 'unsafe-inline', and the inline onsubmit handlers became data-confirm. 2415bad
F082 SSH receive-pack refuses archived repositories, with the same checks as HTTP. 8ae3139
F083 SSH Git processes get an explicit environment with TRACE_ADMIN pinned to 0 or 1. 8c3f76d
F084 The rate limiter keys on the client only (no longer client plus path), keeps counters in memory, supports -trusted-proxy (rightmost untrusted X-Forwarded-For), groups IPv6 clients by /64, and no longer rewrites rate-state.json on every request. 7abb317
F085 Raw and Pages check the blob size with cat-file --batch-check before reading, then stream through a limit. The test measured about 134 MB allocated by the old code to refuse a 48 MiB blob. 0953577
F086 Private raw and Pages responses send Cache-Control: private, no-store and Vary: Cookie, Authorization. 33e2073
F087 Docker steps get -e NAME for the job variables and run as named containers that are docker killed on cancel or timeout. Local and sandbox-exec steps run in their own process group, which is killed on cancel, timeout and step exit. Output is captured through the existing capped buffer. f40759a
F088 Jobs get a minimal environment: PATH, HOME, TMPDIR, locale, TRACE_*, CI, and the repository's secrets. 6e098f6
F089 SCIM password is refused (it became an unsalted SHA-256 credential). Lists support filter=... eq, startIndex and count. Group remove works, including the members[value eq "x"] path form. User PATCH accepts path: "active". 4ce72d7
F090 Webhook delivery checks the IP it actually connects to (after DNS) against loopback, private and link-local ranges, ignores proxy environment variables, and does not follow redirects. 3545d8f
F091 LFS objects are stored per repository (data/lfs/OWNER/NAME). Forks copy them, transfers move them, deletes remove them. At startup, legacy global objects migrate into the repositories whose pointers reference them. a55aa42
F092 Maintainers can run actions and manage secrets and Pages (canWrite instead of a raw "write" string check). 474056d
F093 TOTP codes cannot be reused (the last accepted step is stored per user), and an account backs off after 5 wrong codes (30 s doubling, up to 15 min). 0db5e1a
F094 Backup refuses output paths inside the data directory, including through symlinks. 4127b3f
F095 Transfer renames references structurally (repository-keyed maps and repo fields only) under each store's lock, with atomic writes and a crash-safe journal. It moves the LFS, package and release-asset directories and never touches audit.jsonl or free text. bf6c9fb
F096 Job logs move to data/action-logs/RUN/JOB.log. Old runs are pruned to 200 finished runs per repository. The run list is a summary, and single-run GET and the actions page load logs. 9d0c14b
F098 OIDC: nonce, required iss, RSA keys of at least 2048 bits, and a provider client with a 10 s timeout and no redirects (619af3a). CSRF: constant-time comparison, and a Basic header that does not authenticate no longer waives the check (ff2924e). 619af3a, ff2924e

Found and fixed while testing (not in the audit)

  • c71fe90: the F091 migration now logs and skips a repository it cannot scan instead of stopping trace serve from starting.
  • a380ab1: on a fresh node whose first run is scheduled, loadActions returned a nil LastScheduledAt map and the scheduler panicked, killing trace serve.
  • a138ec0: the macOS sandbox-exec profile denied /, /private/var/select/sh and /dev/null, and it did not resolve /var -> /private/var. On macOS 26, every "sandbox":true job died before running.
  • 30bdbc4: ensureHooks (run at every serve start) reinstalled hooks with the default policy, so configured protected branches such as release/* stopped being enforced after a restart. It now uses the stored policy and fails closed (protects every branch) if the stored policy is invalid.
  • ff2924e: the CSRF bypass through a non-authenticating Authorization: Basic header (described under F098).
  • 9d0c14b: the actions page used {{$.ID}} for artifact links, a field its data does not have, so rendering a run with artifacts failed mid-page.
  • 714c6f5: golang.org/x/crypto v0.55.0 → v0.56.0 for GO-2026-6354 and GO-2026-6355 (SSH channel DoS), which govulncheck reports as reachable from Trace's SSH server.

Not reproduced / deferred

Every assigned finding reproduced from source and is fixed. These are known limits, recorded rather than hidden:

  • F081: browsers do not send the session cookie with a sandboxed private Pages site's sub-resource requests, so private sites should inline their assets (documented in the README). The complete fix is to serve Pages from a separate origin.
  • F084: counters are per process and reset on restart. The README tells operators to use the reverse proxy's limiter for limits shared across processes.
  • F087: a process that leaves its group with setsid is not stopped. Full containment needs cgroups or containers.
  • F079/F089: SCIM-provisioned users must still be linked to their OIDC identity by an administrator; mapping SCIM externalId to sub is not implemented.

Verification

Gates run from this worktree (Go 1.26.5 darwin/arm64, GOWORK=off):

$ test -z "$(gofmt -l .)"                              # rc=0 (no files)
$ GOWORK=off go vet ./...                              # rc=0
$ GOWORK=off go build ./...                            # rc=0
$ GOWORK=off go test -race -count=1 -timeout 20m ./... # ok trace/cmd/trace 92.932s at c71fe90; ok 100.016s at 45d0efa

CI history, for the record: the first CI run (at c71fe90) failed TestLegacyLFSObjectsMigrateToReferencingRepositories on all three test jobs. The runners have Git LFS installed, and its pre-push hook tried to upload the fixture's pointer object (batch request: missing protocol); this machine has no git-lfs. 45d0efa makes the test fixtures push with client hooks disabled. It was reproduced and verified locally with a global Git config whose pre-push hook always fails, and CI at 45d0efa passes: quality, govulncheck, race, and tests on ubuntu and macOS.

Baseline on unchanged main (16ea592), same commands: gofmt empty, vet ok, build ok, go test -race ok. So nothing here depends on a pre-existing failure.

Regression tests run against the unfixed code, with the observed failures:

  • F076: fatal: could not read Username ... terminal prompts disabled
  • F077: collection blocked on a FIFO (10 s timeout)
  • F080: the pattern main)exit${IFS}0;;esac;touch${IFS}pwned;case${IFS}x${IFS}in(x was accepted
  • F082 and F083: the SSH push to an archived repository succeeded, and a non-admin SSH push to main succeeded with TRACE_ADMIN=1 in the server environment
  • F084: varying the path evaded the per-client limit
  • F085: 134,307,048 bytes allocated to refuse a 48 MiB blob
  • F086: private raw returned public, max-age=60
  • F088: the job inherited TRACE_TEST_SERVER_CREDENTIAL
  • F090: the delivery followed a redirect to an internal listener
  • F092: the maintainer got 403
  • F093: the same code was accepted twice
  • F094: the backup was written inside the data directory
  • F095: the issue title team/old was rewritten to team/new
  • CSRF: a write with a bogus Basic header got 201
  • Scheduler: panic: assignment to entry in nil map
  • macOS sandbox: signal: abort trap

Other checks:

  • go run golang.org/x/vuln/cmd/govulncheck@v1.1.4 ./... after the x/crypto bump: no module findings. The six standard-library findings it reports are all fixed in Go 1.26.6, which CI pins; the local toolchain is 1.26.5.
  • git merge-tree --write-tree fix/security-hardening chore/release-readiness: clean, no conflicts. merge-tree is also clean at 45d0efa + 1f9ac1b. A local merge of c71fe90 + 1f9ac1b (never pushed; 45d0efa only changes test fixtures) also passes gofmt, vet, build, and go test -race (ok, 85.882s). The release-readiness PR is chore: prepare Trace for public release #1; its CI (quality, govulncheck, race, and tests on ubuntu and macOS) passed.
  • Not run locally: the Docker sandbox end to end (the local Docker client is not wired to a daemon). The Docker command shape, env forwarding and cancel hook are unit-tested, and CI runs on Linux.

Follow-ups

  • Serve Pages from a separate origin so private sites can load sub-resources.
  • Delete a repository's metadata (issues, PRs, secrets, webhooks, grants) on delete. Today, recreating a deleted name inherits them; this predates the PR and is unchanged here.
  • Add a total artifact size and count cap per job.
  • Add actor_is_agent-style audit attribution and a per-repository AI-contribution policy (see FEATURES.md in the release-readiness PR).
  • Add this PR's changes to CHANGELOG.md (added by the release-readiness PR, chore: prepare Trace for public release #1) before tagging 0.0.1.
  • .github/workflows/ci.yml here is byte-identical to the copy in the release-readiness PR, so the two merge cleanly in either order.

🤖 Generated with Claude Code

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
collectActionArtifacts ran in the unsandboxed Trace process and used
os.Stat/os.Open, which follow symlinks. Any repository writer could run
`ln -s <data>/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
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.
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
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.
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
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
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
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
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.
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
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
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
- 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)
… 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)
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
…heable

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
/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
…xies

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
…emoval

- 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
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
LFS objects were stored globally as data/lfs/<oid>, 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/<oid>; 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
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
…ount

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
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
…nder 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
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.
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.
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.
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant