Merge pull request 'Complete secure source-branch deletion in operator mode' (#1369) from timmy/1368-secure-source-branch-deletion into main
All checks were successful
CI / lint (push) Successful in 3m15s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 5m13s
CI / release-candidate (push) Successful in 6s

Closes #1368
This commit is contained in:
rockachopa 2026-08-24 22:40:22 +00:00
commit 400534f446
4 changed files with 118 additions and 1 deletions

View File

@ -497,6 +497,7 @@ class FollowingNotificationPayload(BaseModel):
StepUpAction = Literal[
"merge_pull",
"delete_source_branch",
"submit_pull_review",
"close_issue",
"delete_comment",

View File

@ -8,7 +8,7 @@ API_MUTATION_BODY_LIMIT = 64 * 1024
ISSUE_ATTACHMENT_BODY_LIMIT = 2 * 1024 * 1024 + 64 * 1024
LEGACY_JSON_ATTACHMENT_BODY_LIMIT = 3 * 1024 * 1024
UNFILED_DRAFT_SYNC_BODY_LIMIT = 17 * 1024 * 1024
MUTATION_METHODS = frozenset({"POST", "PUT", "PATCH"})
MUTATION_METHODS = frozenset({"POST", "PUT", "PATCH", "DELETE"})
def request_body_limit(method: str, path: str) -> int | None:

View File

@ -43,6 +43,84 @@ async def fresh_grant(client, action: str, target: str) -> str:
return response.json()["grant"]
@pytest.mark.anyio
async def test_source_branch_deletion_accepts_exact_one_time_fresh_authorization(
access_control, monkeypatch
):
lifecycle = []
class Journal:
def record(self, *_args, **_kwargs):
pass
def reserve(self, kind, *, target):
lifecycle.append(("reserve", kind, target))
return "cleanup-operation"
def finalize(self, operation_id):
lifecycle.append(("finalize", operation_id))
def discard(self, operation_id):
lifecycle.append(("discard", operation_id))
async def cleanup(repository, number, source_branch, expected_head_sha):
lifecycle.append(
("delete", repository, number, source_branch, expected_head_sha)
)
return {"number": number, "deleted": True}
monkeypatch.setattr(main, "_security_event_store", lambda: Journal())
monkeypatch.setattr(main.gitea_proxy, "delete_merged_source_branch", cleanup)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
signed_in = await client.post(
"/api/v1/session",
json={"access_token": "correct horse battery staple", "device_label": "Phone"},
)
assert signed_in.status_code == 200
headers = {
"Origin": "https://test",
"X-CSRF-Token": client.cookies["stackchain_csrf"],
}
request = {
"source_branch": "timmy/feature",
"expected_head_sha": "abc1234",
}
path = "/api/v1/repos/stackchain/api/pulls/7/source-branch"
challenged = await client.request("DELETE", path, json=request, headers=headers)
grant = await fresh_grant(
client, "delete_source_branch", "stackchain/api#7@abc1234"
)
deleted = await client.request(
"DELETE",
path,
json=request,
headers={**headers, "X-Step-Up-Grant": grant},
)
replayed = await client.request(
"DELETE",
path,
json=request,
headers={**headers, "X-Step-Up-Grant": grant},
)
assert challenged.status_code == 428
assert challenged.json()["detail"] == {
"detail": "Fresh authorization required",
"code": "step_up_required",
"action": "delete_source_branch",
"target": "stackchain/api#7@abc1234",
}
assert deleted.status_code == 200
assert replayed.status_code == 428
assert lifecycle == [
("reserve", "source_branch_deleted", "stackchain/api#7@abc1234"),
("delete", "stackchain/api", 7, "timmy/feature", "abc1234"),
("finalize", "cleanup-operation"),
]
@pytest.mark.anyio
async def test_passkey_enrollment_options_require_fresh_authorization_and_are_one_time(
access_control,

View File

@ -123,6 +123,44 @@ def test_request_limits_are_route_specific_and_cover_api_mutations():
assert main.request_body_limit("POST", "/unrelated") is None
@pytest.mark.anyio
async def test_oversized_source_branch_delete_is_rejected_before_endpoint_work():
downstream_called = False
sent = []
async def downstream(_scope, _receive, _send):
nonlocal downstream_called
downstream_called = True
messages = iter(
[
{"type": "http.request", "body": b"x" * 40_000, "more_body": True},
{"type": "http.request", "body": b"y" * 40_000, "more_body": False},
]
)
async def receive():
return next(messages)
async def send(message):
sent.append(message)
middleware = RequestBodyLimitMiddleware(downstream)
await middleware(
{
"type": "http",
"method": "DELETE",
"path": "/api/v1/repos/stackchain/api/pulls/7/source-branch",
"headers": [],
},
receive,
send,
)
assert sent[0]["status"] == 413
assert downstream_called is False
def test_pull_screenshot_upload_uses_binary_attachment_boundary():
assert (
main.request_body_limit(