Skip to content

CLDSRV-881: fix missing CORS headers on 403 responses#6126

Merged
bert-e merged 2 commits intodevelopment/9.2from
bugfix/CLDSRV-881-cors-headers-on-403-responses
Apr 30, 2026
Merged

CLDSRV-881: fix missing CORS headers on 403 responses#6126
bert-e merged 2 commits intodevelopment/9.2from
bugfix/CLDSRV-881-cors-headers-on-403-responses

Conversation

@tcarmet
Copy link
Copy Markdown
Contributor

@tcarmet tcarmet commented Apr 1, 2026

When a bucket has CORS configured, cloudserver was omitting Access-Control-* headers from 403 responses. Browsers treat such responses as opaque and block the error from the web app, even though AWS S3 returns CORS headers on error responses when a matching rule exists.

The existing collectCorsHeaders helper only runs inside an API handler, after bucket metadata has been loaded. Auth failures from Vault return before any handler runs, so the route-level error response path sent no CORS headers.

The fix wraps callApiMethod's callback: on error, when the request has an Origin header and a known bucket, it fetches the bucket's CORS config and sets the matching Access-Control-* headers directly on the HTTP response before propagating the error. The extra metadata lookup only happens on failed requests with an Origin header - no cost for non-CORS traffic or successful requests.

Fixes: CLDSRV-881

@bert-e
Copy link
Copy Markdown
Contributor

bert-e commented Apr 1, 2026

Hello tcarmet,

My role is to assist you with the merge of this
pull request. Please type @bert-e help to get information
on this process, or consult the user documentation.

Available options
name description privileged authored
/after_pull_request Wait for the given pull request id to be merged before continuing with the current one.
/bypass_author_approval Bypass the pull request author's approval
/bypass_build_status Bypass the build and test status
/bypass_commit_size Bypass the check on the size of the changeset TBA
/bypass_incompatible_branch Bypass the check on the source branch prefix
/bypass_jira_check Bypass the Jira issue check
/bypass_peer_approval Bypass the pull request peers' approval
/bypass_leader_approval Bypass the pull request leaders' approval
/approve Instruct Bert-E that the author has approved the pull request. ✍️
/create_pull_requests Allow the creation of integration pull requests.
/create_integration_branches Allow the creation of integration branches.
/no_octopus Prevent Wall-E from doing any octopus merge and use multiple consecutive merge instead
/unanimity Change review acceptance criteria from one reviewer at least to all reviewers
/wait Instruct Bert-E not to run until further notice.
Available commands
name description privileged
/help Print Bert-E's manual in the pull request.
/status Print Bert-E's current status in the pull request TBA
/clear Remove all comments from Bert-E from the history TBA
/retry Re-start a fresh build TBA
/build Re-start a fresh build TBA
/force_reset Delete integration branches & pull requests, and restart merge process from the beginning.
/reset Try to remove integration branches unless there are commits on them which do not appear on the source branch.

Status report is not available.

@bert-e
Copy link
Copy Markdown
Contributor

bert-e commented Apr 1, 2026

Incorrect fix version

The Fix Version/s in issue CLDSRV-881 contains:

  • None

Considering where you are trying to merge, I ignored possible hotfix versions and I expected to find:

  • 9.2.33

  • 9.3.6

  • 9.4.0

Please check the Fix Version/s of CLDSRV-881, or the target
branch of this pull request.

@claude
Copy link
Copy Markdown

claude Bot commented Apr 1, 2026

LGTM

The fix is correct: standardMetadataValidateBucketAndObj intentionally returns bucket on error (line 427 of metadataUtils.js: "still return bucket for cors headers"), but updateEncryption was dropping it by calling cb(err) without forwarding the bucket. This caused collectCorsHeaders to receive undefined and return empty headers on 403 responses. The tests cover the right cases (specific origin, wildcard, and no-origin).

Review by Claude Code

@codecov
Copy link
Copy Markdown

codecov Bot commented Apr 1, 2026

Codecov Report

❌ Patch coverage is 96.66667% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 84.50%. Comparing base (28e587a) to head (22f67e0).
⚠️ Report is 2 commits behind head on development/9.2.
✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
lib/api/api.js 96.29% 1 Missing ⚠️
Additional details and impacted files

Impacted file tree graph

Files with missing lines Coverage Δ
lib/api/bucketGetCors.js 100.00% <100.00%> (+3.57%) ⬆️
lib/api/bucketGetLocation.js 100.00% <100.00%> (+4.00%) ⬆️
lib/api/bucketGetWebsite.js 100.00% <100.00%> (+3.57%) ⬆️
lib/utilities/collectCorsHeaders.js 100.00% <ø> (ø)
lib/api/api.js 91.30% <96.29%> (+0.54%) ⬆️

... and 3 files with indirect coverage changes

@@                 Coverage Diff                 @@
##           development/9.2    #6126      +/-   ##
===================================================
+ Coverage            84.36%   84.50%   +0.14%     
===================================================
  Files                  204      204              
  Lines                13159    13180      +21     
===================================================
+ Hits                 11101    11138      +37     
+ Misses                2058     2042      -16     
Flag Coverage Δ
file-ft-tests 68.13% <96.66%> (+0.17%) ⬆️
kmip-ft-tests 28.49% <20.00%> (+<0.01%) ⬆️
mongo-v0-ft-tests 69.37% <96.66%> (+0.17%) ⬆️
mongo-v1-ft-tests 69.41% <96.66%> (+0.22%) ⬆️
multiple-backend 35.62% <20.00%> (-0.02%) ⬇️
sur-tests 36.92% <20.00%> (+0.01%) ⬆️
sur-tests-inflights 37.90% <20.00%> (-0.02%) ⬇️
unit 70.28% <83.33%> (+0.35%) ⬆️
utapi-v2-tests 34.78% <20.00%> (-0.01%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

🚀 New features to boost your workflow:
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@tcarmet tcarmet force-pushed the bugfix/CLDSRV-881-cors-headers-on-403-responses branch from d7eec24 to f073231 Compare April 13, 2026 22:53
@bert-e
Copy link
Copy Markdown
Contributor

bert-e commented Apr 13, 2026

Incorrect fix version

The Fix Version/s in issue CLDSRV-881 contains:

  • None

Considering where you are trying to merge, I ignored possible hotfix versions and I expected to find:

  • 9.2.36

  • 9.3.7

  • 9.4.0

Please check the Fix Version/s of CLDSRV-881, or the target
branch of this pull request.

@claude
Copy link
Copy Markdown

claude Bot commented Apr 13, 2026

LGTM

The one-line fix in updateEncryption correctly passes bucket through the error path, matching the documented callback signature (err, bucket, objMD). All 5 callers already destructure (err, bucket, ...) and will benefit from receiving the bucket reference on errors, enabling collectCorsHeaders to return proper CORS headers on 403 responses. Tests cover specific origin, wildcard, and no-origin scenarios.

Review by Claude Code

@tcarmet tcarmet force-pushed the bugfix/CLDSRV-881-cors-headers-on-403-responses branch from f073231 to 66233f9 Compare April 13, 2026 23:00
@claude
Copy link
Copy Markdown

claude Bot commented Apr 13, 2026

LGTM

The fix is minimal and correct: passing bucket in the error callback at updateEncryption.js:111 ensures all five callers (objectGet, objectHead, objectPut, objectCopy, initiateMultipartUpload) can compute CORS headers on error responses. Without it, collectCorsHeaders(origin, method, undefined) returned {}, stripping CORS headers from error responses like 403. Tests cover the key scenarios for objectGet (specific origin, wildcard, no origin) and objectHead.

Review by Claude Code

@tcarmet tcarmet force-pushed the bugfix/CLDSRV-881-cors-headers-on-403-responses branch from 66233f9 to 196a998 Compare April 21, 2026 22:05
@bert-e
Copy link
Copy Markdown
Contributor

bert-e commented Apr 21, 2026

Incorrect fix version

The Fix Version/s in issue CLDSRV-881 contains:

  • None

Considering where you are trying to merge, I ignored possible hotfix versions and I expected to find:

  • 9.2.37

  • 9.3.7

  • 9.4.0

Please check the Fix Version/s of CLDSRV-881, or the target
branch of this pull request.

Comment thread tests/unit/api/corsErrorHeaders.js
Comment thread tests/unit/api/corsErrorHeaders.js
@claude
Copy link
Copy Markdown

claude Bot commented Apr 21, 2026

LGTM — the callback-wrapping approach in api.js is clean: it only pays the extra metadata lookup on error+Origin, guards headersSent, and correctly short-circuits on success. Two minor test quality notes posted inline.

- tests/unit/api/corsErrorHeaders.js:238 — require() calls inside describe block should be at file top
- Move the two require statements to join the imports at lines 1-13
- tests/unit/api/corsErrorHeaders.js:199 — stale comment describes default CorsConfigTester rules, not the custom config from setupBucketWithCors
- Update to reflect that rule 0 was overridden and rule 1 (GET from *) still exists

Review by Claude Code

@tcarmet tcarmet force-pushed the bugfix/CLDSRV-881-cors-headers-on-403-responses branch 3 times, most recently from 406c33e to bf9577a Compare April 21, 2026 22:58
@tcarmet tcarmet marked this pull request as ready for review April 21, 2026 23:16
@tcarmet tcarmet requested a review from a team April 21, 2026 23:16
Comment thread lib/api/api.js
Comment thread lib/api/api.js Outdated
@bert-e
Copy link
Copy Markdown
Contributor

bert-e commented Apr 23, 2026

Incorrect fix version

The Fix Version/s in issue CLDSRV-881 contains:

  • None

Considering where you are trying to merge, I ignored possible hotfix versions and I expected to find:

  • 9.2.38

  • 9.3.7

  • 9.4.0

Please check the Fix Version/s of CLDSRV-881, or the target
branch of this pull request.

@tcarmet tcarmet requested a review from dvasilas April 23, 2026 18:19
Comment thread lib/api/api.js
@claude
Copy link
Copy Markdown

claude Bot commented Apr 23, 2026

Clean, well-structured fix. The wrapper approach correctly limits the extra metadata lookup to error + Origin scenarios, and the fast-path check via hasCorsHeaders avoids redundant lookups when handlers already computed CORS headers.

- Stale comment in collectCorsHeaders.js (lines 12-20) now contradicts the new behavior and should be updated
- It says the server opts not to return CORS headers for pre-handler errors, which is exactly what this PR fixes

Review by Claude Code

@tcarmet tcarmet force-pushed the bugfix/CLDSRV-881-cors-headers-on-403-responses branch from 5d9b95f to 5ce3f67 Compare April 23, 2026 18:25
Comment thread tests/unit/api/corsErrorHeaders.js
@claude
Copy link
Copy Markdown

claude Bot commented Apr 23, 2026

LGTM — clean approach wrapping the callback at the callApiMethod boundary to inject CORS headers on pre-handler errors without touching individual API handlers.

One minor nit:
- tests/unit/api/corsErrorHeaders.js:120 — arrow function captures wrong this in newRequestLoggerFromSerializedUids (see inline comment)

Review by Claude Code

@tcarmet tcarmet requested a review from a team April 23, 2026 18:36
@claude
Copy link
Copy Markdown

claude Bot commented Apr 23, 2026

LGTM

Review by Claude Code

@claude
Copy link
Copy Markdown

claude Bot commented Apr 23, 2026

LGTM

The approach is sound: wrapping callApiMethod's callback intercepts pre-handler errors (auth failures, header-size checks) exactly where CORS headers were missing, without touching each individual route or handler. The hasCorsHeaders scan prevents redundant metadata lookups when the handler already computed CORS headers. The bucketGetCors / bucketGetWebsite changes fix a pre-existing bug where corsHeaders was passed at the wrong callback position ((err, corsHeaders) vs the success-path (null, xml, corsHeaders)). Unit and functional tests cover both the fallback path and the fast path, including negative cases (no origin, non-matching origin).

Review by Claude Code

Comment thread lib/api/bucketGetCors.js
Comment thread lib/api/api.js Outdated
// We only pay the extra metadata lookup when an Origin header is present,
// matching AWS behavior and the existing contract of collectCorsHeaders.
function wrapCallbackWithErrorCorsHeaders(request, response, log, callback) {
return (err, ...rest) => {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the handler computes corsHeaders and gets back {} (bucket has no CORS config), and then calls callback(err, null, {}), the wrapper will still do metadata.getBucket, right? (but it does not need to, I think).

Not sure if it's a good idea, but we could cache the bucket in request (something like request._loadedBucket) and first check there before metadata.getBucket

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Was able to confirm this with a extra test, indeed there would be 2 metadata call in this case.

So yeah we could consider {} as there's no header, and skip the bucket call in this case, it seems to be implicitly true, or we cache the bucket as you stated.

I'm debating between both right now, I pushed a code with the bucket cache on request. seems like the safest option between the two. Let me know what you think.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Something I didn't consider when I suggested the cache approach: I think objectCopy and objectPutCopyPart call standardMetadataValidateBucketAndObj twice (once for the destination, once for the source), so the second call will overwrite the first, and we may not get the headers for the bucket we want.
You could confirm with a test.

Copy link
Copy Markdown
Contributor Author

@tcarmet tcarmet Apr 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes confirmed with the tests, nice catch again. At the end I ended up droping that cache entirely and accepting up to 2 metadata.getBucket calls on the error path. I believe the trade-off is fine here because:

  • This only fires on failing requests with an Origin header where the handler returned without surfacing CORS headers (no rule match, no CORS config, or handler dropped the bucket between waterfall steps. Small slice of overall traffic.
  • Turns out going the other way and trusting {} as "no CORS to apply" would have regressed handlers that drop the bucket internally their collectCorsHeaders runs with bucket=undefined and returns {} even when a matching CORS rule exists. We'd silently lose CORS headers we currently apply.
  • A smarter cache (map keyed by bucketName so objectCopy doesn't overwrite the destination with the source) would have preserved the optimization without the leak you flagged. So that is an option we could arguably go this way, but I honestly felt like it was a over-optimization for an error-path issue, so I went with the simpler code.

To sum things up I removed the cache, documented the behavior in case we decide to optimize in the future and added/adapted the test cases (objectCopy and limiting number of calls to getBucket) so that if we do decide to mess with it, we ensure what we discussed is not lost and will fail if we handle it badly.

Copy link
Copy Markdown
Contributor

@dvasilas dvasilas Apr 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's good with me. Thanks for taking the time to look at the optimization.

@claude
Copy link
Copy Markdown

claude Bot commented Apr 24, 2026

LGTM

The approach is sound: wrapping the callApiMethod callback to inject CORS headers on pre-handler failures (auth denials, header-size checks) fills the gap where the existing collectCorsHeaders call inside handlers never runs. The three-tier fallback (handler-provided headers → cached bucket → metadata lookup) avoids redundant work on the common path while still covering the edge cases.

The callback signature fix in bucketGetCors, bucketGetLocation, and bucketGetWebsite is correct — previously on error these passed corsHeaders as the second arg, but routeGET destructures as (err, xml, corsHeaders), so the headers were landing in the xml position.

Review by Claude Code

@tcarmet tcarmet requested a review from dvasilas April 24, 2026 18:07
@claude
Copy link
Copy Markdown

claude Bot commented Apr 27, 2026

LGTM

The wrapper approach in wrapCallbackWithErrorCorsHeaders is clean: it only pays the extra metadata lookup on error paths with an Origin header, and the hasCorsHeaders fast path avoids redundant lookups when handlers already surface CORS headers. The callback signature normalization in bucketGetCors, bucketGetLocation, and bucketGetWebsite (callback(err, null, corsHeaders) instead of callback(err, corsHeaders)) also fixes a latent bug where corsHeaders was passed in the xml position on AccessDenied errors. Tests cover auth failures, the fast path, copy-operation bucket disambiguation, non-matching origins, and success regressions.

Review by Claude Code

@tcarmet tcarmet requested a review from a team April 28, 2026 16:15
Comment thread lib/api/api.js Outdated
Comment thread lib/api/api.js Outdated
// Callback arity varies per route ((err, corsHeaders), (err, xml,
// corsHeaders), (err, dataGetInfo, resMetaHeaders, range), ...) so we
// scan the args instead of relying on a fixed position.
function hasCorsHeaders(rest) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nitpick: not fond of rest as a name, what about callbackArgs.

Also wondering if there's a reason to check every argument, i.e. if the CORS headers shouldn't always be returned as the third callback argument as a convention?

Copy link
Copy Markdown
Contributor Author

@tcarmet tcarmet Apr 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's no clear separation of the CORS headers, it's not always returned as the third callback, it's mix of the second or third depending on the routing it takes. (I added a comment already stating, maybe it wasn't clear. let me know)

Tackling a standard position would be a refactor of it's own IMHO not in scope right now.

Comment thread lib/api/api.js Outdated
Comment thread lib/api/api.js Outdated
Comment thread lib/api/api.js Outdated
@claude
Copy link
Copy Markdown

claude Bot commented Apr 28, 2026

LGTM

The centralized CORS-on-error wrapper in callApiMethod is a clean approach. The fast path correctly avoids extra metadata lookups when handlers already supply CORS headers, and the fallback path handles pre-handler failures (auth, header validation, copy-source parsing) where the bucket was never loaded. The callback signature normalization in bucketGetCors, bucketGetLocation, and bucketGetWebsite also fixes a pre-existing bug: the old callback(err, corsHeaders) was placing corsHeaders in the xml parameter position, so the route layer never actually applied them to the response. Comprehensive unit and functional test coverage, including the copy-operation edge case (CORS evaluated against destination bucket, not source).

Review by Claude Code

@bert-e
Copy link
Copy Markdown
Contributor

bert-e commented Apr 30, 2026

Incorrect fix version

The Fix Version/s in issue CLDSRV-881 contains:

  • None

Considering where you are trying to merge, I ignored possible hotfix versions and I expected to find:

  • 9.2.39

  • 9.3.7

  • 9.4.0

Please check the Fix Version/s of CLDSRV-881, or the target
branch of this pull request.

Cross-origin browser clients lost Access-Control-* headers whenever
cloudserver returned 403, even though the bucket had a matching CORS
rule. Responses now include the CORS headers for error responses on
buckets with CORS configured, matching AWS behavior.
@tcarmet tcarmet force-pushed the bugfix/CLDSRV-881-cors-headers-on-403-responses branch from 1ab24f2 to 3adf9ff Compare April 30, 2026 20:42
@scality scality deleted a comment from bert-e Apr 30, 2026
@bert-e
Copy link
Copy Markdown
Contributor

bert-e commented Apr 30, 2026

Integration data created

I have created the integration data for the additional destination branches.

The following branches will NOT be impacted:

  • development/7.10
  • development/7.4
  • development/7.70
  • development/8.8
  • development/9.0
  • development/9.1

You can set option create_pull_requests if you need me to create
integration pull requests in addition to integration branches, with:

@bert-e create_pull_requests

The following options are set: approve

@claude
Copy link
Copy Markdown

claude Bot commented Apr 30, 2026

LGTM

The approach is solid: wrapping the callback at the callApiMethod level catches all pre-handler error paths (auth failures, header-size checks, copy-source parse errors) while the fast-path scan of callback args avoids redundant metadata lookups when the handler already computed CORS headers. The callback signature normalization in bucketGetCors/Location/Website ((err, null, corsHeaders) instead of the old position-2 hack for AccessDenied only) is correct and consistent with the success path. The headersSent guard and try/catch around setHeader handle edge cases properly. Test coverage is thorough across auth failures, handler-level ACL denials (fast path), copy-operation source/dest isolation, and the 2-call ceiling contract.

Review by Claude Code

@claude
Copy link
Copy Markdown

claude Bot commented Apr 30, 2026

LGTM

Review by Claude Code

@bert-e
Copy link
Copy Markdown
Contributor

bert-e commented Apr 30, 2026

Build failed

The build for commit did not succeed in branch w/9.3/bugfix/CLDSRV-881-cors-headers-on-403-responses

The following options are set: approve

@bert-e
Copy link
Copy Markdown
Contributor

bert-e commented Apr 30, 2026

Request integration branches

Waiting for integration branch creation to be requested by the user.

To request integration branches, please comment on this pull request with the following command:

/create_integration_branches

Alternatively, the /approve and /create_pull_requests commands will automatically
create the integration branches.

@tcarmet
Copy link
Copy Markdown
Contributor Author

tcarmet commented Apr 30, 2026

/approve

@bert-e
Copy link
Copy Markdown
Contributor

bert-e commented Apr 30, 2026

Integration data created

I have created the integration data for the additional destination branches.

The following branches will NOT be impacted:

  • development/7.10
  • development/7.4
  • development/7.70
  • development/8.8
  • development/9.0
  • development/9.1

You can set option create_pull_requests if you need me to create
integration pull requests in addition to integration branches, with:

@bert-e create_pull_requests

The following options are set: approve

@bert-e
Copy link
Copy Markdown
Contributor

bert-e commented Apr 30, 2026

Build failed

The build for commit did not succeed in branch w/9.4/bugfix/CLDSRV-881-cors-headers-on-403-responses

The following options are set: approve

@bert-e
Copy link
Copy Markdown
Contributor

bert-e commented Apr 30, 2026

I have successfully merged the changeset of this pull request
into targetted development branches:

  • ✔️ development/9.2

  • ✔️ development/9.3

  • ✔️ development/9.4

The following branches have NOT changed:

  • development/7.10
  • development/7.4
  • development/7.70
  • development/8.8
  • development/9.0
  • development/9.1

Please check the status of the associated issue CLDSRV-881.

Goodbye tcarmet.

The following options are set: approve

@bert-e bert-e merged commit 22f67e0 into development/9.2 Apr 30, 2026
33 checks passed
@bert-e bert-e deleted the bugfix/CLDSRV-881-cors-headers-on-403-responses branch April 30, 2026 23:49
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.

4 participants