Skip to content

sec(api): allow PATCH in CORS and stop returning registration tokens to admins - #1263

Draft
IAmJSD wants to merge 1 commit into
LeanerCloud:mainfrom
IAmJSD:fix/issue-310
Draft

IAmJSD wants to merge 1 commit into
LeanerCloud:mainfrom
IAmJSD:fix/issue-310

Conversation

@IAmJSD

@IAmJSD IAmJSD commented Oct 23, 2026

Copy link
Copy Markdown
Collaborator

Summary

Issue #310 groups three low-severity findings from the 2026-09-02 audit. I re-checked each one against current main (a14dc8f):

Item Status on main This PR
A02-015: PATCH has no CSRF check and is missing from CORS CSRF part already fixed by #1104 (requiresCSRFValidation now exempts only GET/HEAD/OPTIONS, and there's a router-table guard). CORS part still open: buildResponseHeaders sends GET, POST, PUT, DELETE, OPTIONS, and the Lambda Function URL CORS config in both terraform/modules/compute/aws/lambda/main.tf and cloudformation/stacks/CUDly/template.yaml allows only GET, POST, PUT, DELETE. Fixed: PATCH added at all three sites.
A02-020: the registration list and detail responses expose reference_token Still open: config.AccountRegistration.ReferenceToken is tagged json:"reference_token", so GET /api/registrations and GET /api/registrations/{id} return each registrant's status credential. Fixed: the field is now json:"-".
A03-015: account scope matches on display name Partly mitigated on main by #1032 (a name matches only if exactly one account has it). The delete/rename-then-reuse case is still open. Deferred to the open PR #1116, which makes exactly the change this finding asks for: match on account ID only, migrate stored name entries to IDs (migration 000135), and drop MatchesAccount/CanAccessAccount. Changing the same code here would conflict with it.

Refs #310

This PR plus #1116 together cover the issue. Once both merge, #310 can be closed.

Root cause and fix

A02-015: CORS method list

The router serves PATCH /api/plans/{id} and PATCH /api/ladder/tranches/{id}, but none of the three CORS method lists included PATCH:

  • internal/api/handler.go buildResponseHeaders: this is what the handler (and the container/HTTP server that passes its headers through) sends in reply to a preflight.
  • terraform/modules/compute/aws/lambda/main.tf aws_lambda_function_url.main cors.allow_methods.
  • cloudformation/stacks/CUDly/template.yaml AWS::Lambda::Url Cors.AllowMethods.

When a Function URL has a CORS config, AWS answers the preflight from that config. Fixing only the Go header would therefore leave Lambda deployments refusing cross-origin PATCH. All three sites now list PATCH. The CloudFormation comment that says its lists match the Terraform module is still accurate.

Same-origin deployments, where the dashboard is served through CloudFront and the frontend module already forwards PATCH, did not hit this. It only affected deployments that call the API cross-origin.

A02-020: reference_token in admin responses

ReferenceToken is now tagged json:"-" on config.AccountRegistration. submitRegistration already returns the token through its own explicit map[string]string, so registrants and the federation IaC scripts (jq -r .reference_token) still get it. The admin list and detail endpoints, and anything else that serializes the struct later, no longer include it. rejectRegistration's filtered map is unchanged. No code JSON-decodes an AccountRegistration, and the DB layer scans the column directly, so storage and the GET /api/register/{token} lookup are unaffected. openapi.yaml doesn't document the field.

Verification

All runs were local on Linux with AWS_CONFIG_FILE=/dev/null AWS_SHARED_CREDENTIALS_FILE=/dev/null AWS_EC2_METADATA_DISABLED=true. No cloud APIs were called. The evidence is unit tests through Handler.HandleRequest with mocked auth and config stores, plus static checks that read the Terraform and CloudFormation source. There is no deployed or real-browser cross-origin run.

New tests:

  • internal/api/cors_methods_test.go
    • TestHandleRequest_PreflightAllowsPATCH: an OPTIONS preflight for /api/plans/{id} must list PATCH.
    • TestHandleRequest_PreflightAllowsEveryRoutedMethod: reads the real router table and requires every verb a route uses in the preflight's Access-Control-Allow-Methods. HEAD is excluded because it is CORS-safelisted and never preflighted. A floor assertion fails if the table read finds no GET/POST/PUT/PATCH/DELETE route.
  • internal/api/handler_registrations_token_test.go
    • TestListRegistrations_DoesNotExposeReferenceToken and TestGetRegistration_DoesNotExposeReferenceToken: admin session through HandleRequest. The body contains neither the reference_token key nor the token value, and it still contains the registration, so a fix that blanks the response would also fail.
    • TestAccountRegistration_JSONOmitsReferenceToken: pins the type itself.
    • The existing handler_registrations_submit_test.go already asserts that submitRegistration still returns reference_token.
  • terraform/modules/compute/aws/function_url_cors_test.go: TestFunctionURLCORS_TerraformAllowsRoutedMethods and TestFunctionURLCORS_CloudFormationAllowsRoutedMethods parse the Function URL CORS method list at each site and require GET/POST/PUT/PATCH/DELETE.

Fail before, pass after: I reverse-applied the four non-test changes, kept the new tests, and ran go test -count=1 -v -run 'Preflight|ReferenceToken|FunctionURLCORS' ./internal/api/ ./terraform/modules/compute/aws/. All seven new tests failed:

--- FAIL: TestHandleRequest_PreflightAllowsPATCH          (allowed: map[DELETE GET OPTIONS POST PUT])
--- FAIL: TestHandleRequest_PreflightAllowsEveryRoutedMethod  (PATCH /api/plans/*, PATCH /api/ladder/tranches/*)
--- FAIL: TestListRegistrations_DoesNotExposeReferenceToken   (key and value exposed)
--- FAIL: TestGetRegistration_DoesNotExposeReferenceToken     (key and value exposed)
--- FAIL: TestAccountRegistration_JSONOmitsReferenceToken
--- FAIL: TestFunctionURLCORS_TerraformAllowsRoutedMethods    ([GET POST PUT DELETE] is missing PATCH)
--- FAIL: TestFunctionURLCORS_CloudFormationAllowsRoutedMethods

With the fix re-applied, all seven pass. The existing TestHandler_HandleRequest_CORS_Preflight passes both before and after.

Other checks on the branch head:

  • go build ./..., go vet ./..., go vet -tags integration ./...: pass.
  • go test -count=1 run one package at a time on ./internal/api/, ./internal/config/, ./terraform/modules/compute/aws/, ./internal/mocks/ and ./internal/server/: all ok.
  • golangci-lint v2.10.1 run --allow-parallel-runners ./internal/api/... ./internal/config/... ./terraform/modules/compute/aws/...: 0 issues.
  • gocyclo -over 10 -ignore "_test\.go" internal/api/handler.go internal/config/types.go: clean.
  • terraform fmt -check and terraform validate (init -backend=false with a temporary TF_DATA_DIR) on terraform/modules/compute/aws/lambda: pass. No plan or apply.
  • No integration tests were added or changed. Neither fix touches the DB, and no concurrent path is involved.

Combined-tree verification. This branch was tested together with the other queued branches on a local, never-pushed tree: queue/platform @ 936996ad = origin/main a14dc8f2 plus these heads, merged in this order:

Branch Tested head
fix/issue-337 ff3420c
fix/issue-169 7d75eb5
fix/issue-342 66b221f
fix/issue-310 5e11c21
fix/issue-326 5f0ea59
fix/issue-243 365e959
fix/issue-343 6c94036
fix/issue-312 d2848a8
fix/issue-316 acd60dd
fix/issue-339 75ea9b9
fix/issue-339-commitment-type 73bdb19
fix/issue-353 5d25fcd
fix/issue-313 3156bc4
fix/issue-357 53d9e58
fix/issue-368 0ba727d
fix/issue-419 61648c9
fix/issue-111 b04c372
fix/issue-686 0ad90b7
fix/issue-674 793095d
fix/issue-1055 4ec96f1
fix/issue-559 dfa9525
fix/issue-558 034eed4
fix/issue-874 1720539
fix/issue-666 1e120da
fix/issue-668 fb26345
fix/issue-568 ce04173
fix/issue-311 e54f250
fix/issue-177 0cbb88a
fix/issue-151 c21377c
fix/issue-166 41fb77b
fix/issue-27 2d67622
fix/issue-323 69288ae
fix/issue-59 36463e8
fix/issue-43 35e4069
fix/issue-158 6f76f8a
fix/issue-104 bb00491
fix/issue-55 971ef1b
fix/issue-361 89ac220
fix/issue-120 b458719
fix/issue-36 42bac6e
fix/issue-525 57c97dd
fix/issue-712 e5bcda0
fix/issue-72 6cc63ed

fix/issue-72 and fix/issue-312 conflict on one line in internal/api/handler_dashboard.go (both change the calculateCommitmentMetrics call). The tree used #72's side with #312's validated provider variable; whichever PR merges second takes that same resolution.

Linux, Go 1.26.9, AWS_CONFIG_FILE=/dev/null AWS_SHARED_CREDENTIALS_FILE=/dev/null AWS_EC2_METADATA_DISABLED=true; Docker Postgres via testcontainers:

  • Passed at 936996ad: go build ./..., go vet ./..., go vet -tags integration ./... and the full go test -race -short -count=1 ./....
  • Static checks at 936996ad: golangci-lint v2.10.1 found 0 issues on every changed package, with and without --build-tags integration. tests/e2e is a separate module, so it was vetted under its own e2e tag. gocyclo -over 10 is silent on the changed non-test files.
  • Integration tests, every changed package, one package at a time (-tags integration -count=1, 60-minute local timeout because the VM was shared):
    • At 936996ad, these pass: internal/server, internal/credentials, internal/scheduler, internal/database/postgres/migrations, internal/purchase and internal/api.
    • internal/config passes except TestPostgresStoreDB_ResolveStaleRIExchange_ValidatesCloudCheck, which fails the same way on unmodified main (fixed by fix(config): compare resolve checked_at at the database's microsecond precision #1252).
    • These were run on the first build of this tree (26795376, same branches at earlier heads): internal/analytics, internal/auth, internal/commitmentopts, internal/database and cmd/lambda/clear-rate-limit all pass. The later fixes don't touch those packages.
  • What the combined run caught, each fixed on its own branch before this PR opened:
    • a compile break between fix/issue-104's test and fix/issue-326's httpToLambdaRequest signature change;
    • fix/issue-316 rejecting legacy-keyed plans;
    • fix/issue-120 versus main's global-filter test.
  • Secrets: git-secrets, using the repo's scripts/setup-git-secrets.sh patterns in a fresh repo with an isolated HOME, found nothing in the changed files, and a planted fake key was flagged. scripts/test-git-secrets-allowlist.sh: 137 PASS, 0 FAIL.
  • Nothing was applied to any cloud.

Out of scope

  • A03-015 is deferred to fix(auth): bind group account scope entries to account IDs #1116, as described above. That PR also covers the related chore(api): medium/low findings from the 2026-07-28 full review #100 checklist note about name-based entries.
  • internal/server/health.go sets its own Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS on the health endpoint. That endpoint is GET-only, so it was left as is.
  • The Function URL CORS allow_headers lists differ from the handler's Access-Control-Allow-Headers (for example, X-API-Key and X-Authorization are not in the Function URL list). That is not part of this finding and was not changed.
  • Deploying the Terraform and CloudFormation change is needed for Lambda deployments to pick up the PATCH preflight. No CHANGELOG entry was added.

Frontend follow-up for the UI owner

frontend/src/api/registrations.ts declares reference_token: string; on the AccountRegistration interface. Nothing in frontend/src reads it, so nothing breaks at runtime. The field is now always absent from GET /api/registrations and GET /api/registrations/{id}, so it should be removed from the interface (or made optional) to keep the type accurate.

Labels

Could a maintainer mirror the issue's triage labels onto this PR: triaged, priority/p3, severity/low, urgency/eventually, impact/few, effort/s, type/security?

🤖 Generated with Claude Code

…to admins

Two of the three low-severity findings grouped in the 2026-09-02 audit
issue.

A02-015: the CSRF half was fixed by LeanerCloud#1104. The CORS half was not: the
handler's Access-Control-Allow-Methods and the Lambda Function URL CORS
config (Terraform lambda module and the CloudFormation stack) listed
GET, POST, PUT and DELETE only, while the router serves PATCH
/api/plans/{id} and PATCH /api/ladder/tranches/{id}. All three now list
PATCH. A test reads the router table and requires every routed verb
(HEAD excepted, it is CORS-safelisted) in the preflight's list.

A02-020: config.AccountRegistration.ReferenceToken was tagged
json:"reference_token", so GET /api/registrations and
GET /api/registrations/{id} returned each registrant's private status
token to admins. It is now json:"-"; submitRegistration still returns
it from its own explicit map.

A03-015 (account scope matching display names) is left to the open
group account-scope binding PR LeanerCloud#1116, which makes the same change.

Refs LeanerCloud#310

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@IAmJSD IAmJSD added triaged Item has been triaged priority/p3 Polish / idea / may never ship severity/low Minor harm urgency/eventually No deadline impact/few Limited audience effort/s Hours type/security Security finding labels Oct 23, 2026
@coderabbitai

coderabbitai Bot commented Oct 23, 2026

Copy link
Copy Markdown
Contributor

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Comment @coderabbitai help to get the list of available commands.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

effort/s Hours impact/few Limited audience priority/p3 Polish / idea / may never ship severity/low Minor harm triaged Item has been triaged type/security Security finding urgency/eventually No deadline

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant