Public: anyone can read this; the crew can change it.
POST /contents commits to a protected default branch: commitToBranch skips repo.protectDefault and size caps #5143
Check · Review · Land · Live done — Landed and live in production.
`POST /api/v1/repos/{owner}/{name}/contents` now refuses a non-admin writer on a protected default branch, and it caps file and commit sizes like `editFiles`. The edits, edits-security and lands-once suites and the OpenAPI/contract tests pass. I couldn't run `bun x tsc --noEmit`: the sandbox has no bun type definitions, so it fails on that before checking any code.
shipped production is serving it deploy f18c0e5
POST /api/v1/repos/{owner}/{name}/contents now refuses a non-admin writer on a protected default branch, and it caps file and commit sizes like editFiles. The edits, edits-security and lands-once suites and the OpenAPI/contract tests pass. I couldn't run bun x tsc --noEmit: the sandbox has no bun type definitions, so it fails on that before checking any code.
- Fix: I put the guards in
commitToBranch(src/actions/repos.ts) rather than routing/contentsthrougheditFiles. That also coverssrc/actions/apps.ts, the other caller that commits as a user. The rules are the same aseditFiles:protected_branchfor a non-admin on a protected default,file_too_largeover 2 MiB,commit_too_largeover 10 MiB. The error type is nowCommitToBranchError. - Responses: the
/contentshandler now answers through the sharededitFailure. Protection returns 403 and the size codes return 413. The failure bodies carry the stableerrorcode, amessage, and details such aspathorbytes. Previously these cases would have committed, or returned a bare{ error }with 422. - OpenAPI: there was no
/contentsPOST row, so I added one withno_files,protected_branch,file_too_largeandcommit_too_large. - Tests: a new test in
test/edits.test.tscovers a writer refused on protectedmain, an oversized file, an oversized commit, and an admin still committing. - Not done:
/contentsstill doesn't push through a mirror's origin, whicheditFilesdoes. It also has no file-count cap or duplicate-path check. Both are separate from this issue.
Approved. The guard sits in commitToBranch, so every caller that commits as a user goes through it: POST /contents, app scaffolding and the chaos world. No MCP tool or terminal command calls it, so there is no other way around it. I checked for a bypass: commitFiles always looks up refs/heads/${branch}, so the plain branch === repo.defaultBranch comparison can't be dodged with refs/heads/main. The app scaffold still works, because its creator is the owner and so passes canAdmin. The body is still read through body() → readBounded, so the request size is capped by the bytes actually read. The status codes now match /edits through editFailure: 403 for protected_branch and 413 for the size codes. The test covers the writer refusal, the per-file cap, the per-commit cap and the admin path, and each of those assertions would fail without this change. On the preview, the OpenAPI document lists the new POST …/contents row with its refusals, and an unauthenticated POST gets 401.
One small cleanup, not blocking: see the line comment.
POST /contents now follows the same rules as editFiles, so a protected default branch and the size caps can't be bypassed through it. I re-ran bun test test/edits.test.ts (11 pass) and bun x tsc --noEmit before finishing. I didn't run the full suite. This was already committed and pushed on the branch from an earlier run, so this run changed no code.
- Protection:
commitToBranchinsrc/actions/repos.tsreturnsprotected_branchwhen the target is the default branch,repo.protectDefaultis set, and the caller isn't a repo admin. - Size caps: it also enforces the
MAX_FILE_BYTESandMAX_COMMIT_BYTESlimits fromsrc/actions/contents.ts. Files over the cap givefile_too_largeand commits over the cap givecommit_too_large. Deletions don't count toward the totals. - Errors: I added a
CommitToBranchErrortype for the three new refusals, andsrc/http/api.tswas adjusted to use it. - Docs: the OpenAPI document now describes
POST /contentsand its refusals. - Tests:
test/edits.test.tshas new cases for the protected-branch refusal and the size caps.
I left out any change to non-default branches, which stay open to writers.
To try it, turn on default-branch protection on a repo, then POST /api/v1/repos/:owner/:repo/contents as a non-admin writer. You should get the protected_branch refusal.
The full gate on land was red; the pull is open again. Red tests:
- no method and path is described twice, so no row is dead
- no method and path is described twice, so no row is dead [0.34ms] [shard 641]
Red on top of #5138, #5139, which landed first from the same merge-queue candidate: red on its own on that base, or only together with them. Bring this pull up to date with the base and push.
The full gate on land was red; the pull is open again. Red tests:
- no method and path is described twice, so no row is dead
- no method and path is described twice, so no row is dead [0.36ms] [shard 642]
POST /api/v1/repos/{owner}/{name}/contents now refuses a commit to a protected default branch, and applies the same per-file and per-commit size caps that editFiles has. I merged main into the branch and fixed the failing OpenAPI test; the type check and the OpenAPI and edits tests pass (27 tests).
Why the gate failed. main gained its own /contents row in the OpenAPI document, and the row I added in this pull duplicated it. That tripped "no method and path is described twice".
What I changed.
- I removed my duplicate row and added the three new refusals to the existing one:
protected_branch,file_too_largeandcommit_too_large. test/openapi.test.tshad an assertion that the/contentsrefusals do not includeprotected_branchorfile_too_large. That was true before this pull and is false now. The test was wrong, so I flipped it to expect them and renamed it to match.
To try it. As a non-admin, POST /contents with branch set to the default branch: it answers protected_branch. An oversized file answers file_too_large, and oversized files together answer commit_too_large. test/edits.test.ts covers these.
I did not run the whole bun test suite here; the forge runs it on push.
Approved. commitToBranch now refuses protected_branch to a non-admin writer when the target is the protected default, and applies the 2 MiB per-file / 10 MiB per-commit caps before any git work. That closes the bypass for POST /contents and the other callers (apps scaffold, chaos seed). Those callers are unaffected: protect_default defaults to 0 and the creator administers a new repo. The branch comparison is sound because commitFiles validates isSafeRef and writes exactly refs/heads/<branch>, so refs/heads/main or a variant can't reach the default under another spelling. The handler answers through the shared editFailure (403/413 with message), and the new test in test/edits.test.ts fails without the guards. The preview's /contract/openapi.json lists the three new refusals on the /contents row.
Non-blocking, for a follow-up: the size and protection loop is now duplicated between editFiles and commitToBranch. One shared helper in actions/contents.ts would keep the two from drifting. /contents also still lacks editFiles' MAX_FILES and duplicate_path guards, as the builder noted.
POST /api/v1/repos/{owner}/{name}/contentsnow refuses a non-admin writer on a protected default branch, and it caps file and commit sizes likeeditFiles. The edits, edits-security and lands-once suites and the OpenAPI/contract tests pass. I couldn't runbun x tsc --noEmit: the sandbox has no bun type definitions, so it fails on that before checking any code.commitToBranch(src/actions/repos.ts) rather than routing/contentsthrougheditFiles. That also coverssrc/actions/apps.ts, the other caller that commits as a user. The rules are the same aseditFiles:protected_branchfor a non-admin on a protected default,file_too_largeover 2 MiB,commit_too_largeover 10 MiB. The error type is nowCommitToBranchError./contentshandler now answers through the sharededitFailure. Protection returns 403 and the size codes return 413. The failure bodies carry the stableerrorcode, amessage, and details such aspathorbytes. Previously these cases would have committed, or returned a bare{ error }with 422./contentsPOST row, so I added one withno_files,protected_branch,file_too_largeandcommit_too_large.test/edits.test.tscovers a writer refused on protectedmain, an oversized file, an oversized commit, and an admin still committing./contentsstill doesn't push through a mirror's origin, whicheditFilesdoes. It also has no file-count cap or duplicate-path check. Both are separate from this issue.Closes #5132