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

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

`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

opened this

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 /contents through editFiles. That also covers src/actions/apps.ts, the other caller that commits as a user. The rules are the same as editFiles: protected_branch for a non-admin on a protected default, file_too_large over 2 MiB, commit_too_large over 10 MiB. The error type is now CommitToBranchError.
  • Responses: the /contents handler now answers through the shared editFailure. Protection returns 403 and the size codes return 413. The failure bodies carry the stable error code, a message, and details such as path or bytes. Previously these cases would have committed, or returned a bare { error } with 422.
  • OpenAPI: there was no /contents POST row, so I added one with no_files, protected_branch, file_too_large and commit_too_large.
  • Tests: a new test in test/edits.test.ts covers a writer refused on protected main, an oversized file, an oversized commit, and an admin still committing.
  • Not done: /contents still doesn't push through a mirror's origin, which editFiles does. It also has no file-count cap or duplicate-path check. Both are separate from this issue.

Closes #5132

commented

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 /contents through editFiles. That also covers src/actions/apps.ts, the other caller that commits as a user. The rules are the same as editFiles: protected_branch for a non-admin on a protected default, file_too_large over 2 MiB, commit_too_large over 10 MiB. The error type is now CommitToBranchError.
  • Responses: the /contents handler now answers through the shared editFailure. Protection returns 403 and the size codes return 413. The failure bodies carry the stable error code, a message, and details such as path or bytes. Previously these cases would have committed, or returned a bare { error } with 422.
  • OpenAPI: there was no /contents POST row, so I added one with no_files, protected_branch, file_too_large and commit_too_large.
  • Tests: a new test in test/edits.test.ts covers a writer refused on protected main, an oversized file, an oversized commit, and an admin still committing.
  • Not done: /contents still doesn't push through a mirror's origin, which editFiles does. It also has no file-count cap or duplicate-path check. Both are separate from this issue.
Approved — before the latest push

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.

commented

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: commitToBranch in src/actions/repos.ts returns protected_branch when the target is the default branch, repo.protectDefault is set, and the caller isn't a repo admin.
  • Size caps: it also enforces the MAX_FILE_BYTES and MAX_COMMIT_BYTES limits from src/actions/contents.ts. Files over the cap give file_too_large and commits over the cap give commit_too_large. Deletions don't count toward the totals.
  • Errors: I added a CommitToBranchError type for the three new refusals, and src/http/api.ts was adjusted to use it.
  • Docs: the OpenAPI document now describes POST /contents and its refusals.
  • Tests: test/edits.test.ts has 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.

commented

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.

commented

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]
commented

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_large and commit_too_large.
  • test/openapi.test.ts had an assertion that the /contents refusals do not include protected_branch or file_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

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.

Sign in to join this conversation.

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs