-
Notifications
You must be signed in to change notification settings - Fork 255
CLDSRV-881: fix missing CORS headers on 403 responses #6126
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: development/9.2
Are you sure you want to change the base?
Changes from all commits
bf9577a
b5a6a51
5ce3f67
ba2362c
ffcdd63
eb38d96
3b23679
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -81,6 +81,8 @@ const { isRequesterASessionUser } = require('./apiUtils/authorization/permission | |
| const checkHttpHeadersSize = require('./apiUtils/object/checkHttpHeadersSize'); | ||
| const constants = require('../../constants'); | ||
| const { config } = require('../Config.js'); | ||
| const metadata = require('../metadata/wrapper'); | ||
| const collectCorsHeaders = require('../utilities/collectCorsHeaders'); | ||
| const { validateMethodChecksumNoChunking } = require('./apiUtils/integrity/validateChecksums'); | ||
| const { | ||
| getRateLimitFromCache, | ||
|
|
@@ -92,6 +94,88 @@ const monitoringMap = policies.actionMaps.actionMonitoringMapS3; | |
|
|
||
| auth.setHandler(vault); | ||
|
|
||
| // Detect CORS headers the handler already attached to its callback args. | ||
| // 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) { | ||
| for (const arg of rest) { | ||
| if (!arg || typeof arg !== 'object' || Array.isArray(arg) | ||
| || Buffer.isBuffer(arg)) { | ||
| continue; | ||
| } | ||
| if ('access-control-allow-origin' in arg) { | ||
| return true; | ||
| } | ||
| } | ||
| return false; | ||
| } | ||
|
|
||
| // Wrap the API callback so that, on error, we look up the bucket's CORS | ||
| // configuration and set the matching Access-Control-* headers directly on | ||
| // the HTTP response. This is needed because auth / pre-handler errors | ||
| // return before the API handler retrieves the bucket (where the existing | ||
| // collectCorsHeaders call lives), so the route-level error response path | ||
| // otherwise sends no CORS headers - breaking cross-origin browser clients. | ||
| // 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) { | ||
| function applyHeadersFromBucket(bucket) { | ||
| if (!bucket || response.headersSent) { | ||
| return; | ||
| } | ||
| const headers = collectCorsHeaders( | ||
| request.headers.origin, request.method, bucket); | ||
| Object.keys(headers).forEach(key => { | ||
| if (headers[key] === undefined) { | ||
| return; | ||
| } | ||
| try { | ||
| response.setHeader(key, headers[key]); | ||
| } catch (e) { | ||
| log.debug('could not set cors header on error', { | ||
| header: key, error: e.message, | ||
| }); | ||
| } | ||
| }); | ||
| } | ||
|
|
||
| return (err, ...rest) => { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If the handler computes Not sure if it's a good idea, but we could cache the bucket in
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 I'm debating between both right now, I pushed a code with the bucket cache on |
||
| if (!err || !request.headers || !request.headers.origin | ||
| || !request.bucketName) { | ||
| return callback(err, ...rest); | ||
| } | ||
| // Fast path: most post-auth failures come back with corsHeaders | ||
| // already computed by the handler. The route will forward them | ||
| // to responseXMLBody/responseNoBody, so no extra lookup is needed. | ||
| if (hasCorsHeaders(rest)) { | ||
| return callback(err, ...rest); | ||
| } | ||
| // Reuse bucket cached on the request by standardMetadataValidate* | ||
| // to avoid a redundant metadata.getBucket call. | ||
| if (request._loadedBucket) { | ||
| applyHeadersFromBucket(request._loadedBucket); | ||
| return callback(err, ...rest); | ||
| } | ||
| // Fallback: bucket was never loaded (pre-handler failure like | ||
| // auth.server.doAuth denial, header-size check, copy-source parse | ||
| // error). Look the bucket up so we can still reply with CORS | ||
| // headers. | ||
| return metadata.getBucket(request.bucketName, log, (mdErr, bucket) => { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Are there any cases in which api handlers pass
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There are, good catch. The only annoying part is that the parameter placement in which they can be returned is a bit inconsistent but I've added a a little helper to help us identify the cases where there can be one. Also added a extra testcase for it.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Also your suggestion found a couple of pre-existing bug on |
||
| if (mdErr) { | ||
| log.warn('could not fetch bucket CORS config for error ' | ||
| + 'response', { | ||
| bucketName: request.bucketName, | ||
| error: mdErr.code || mdErr.message, | ||
| }); | ||
| return callback(err, ...rest); | ||
| } | ||
| applyHeadersFromBucket(bucket); | ||
| return callback(err, ...rest); | ||
| }); | ||
| }; | ||
| } | ||
|
|
||
| function checkAuthResults(authResults, apiMethod, log) { | ||
| let returnTagCount = true; | ||
| const isImplicitDeny = {}; | ||
|
|
@@ -158,6 +242,8 @@ const api = { | |
| callApiMethod(apiMethod, request, response, log, callback) { | ||
| // Attach the apiMethod method to the request, so it can used by monitoring in the server | ||
| request.apiMethod = apiMethod; | ||
| callback = wrapCallbackWithErrorCorsHeaders( | ||
| request, response, log, callback); | ||
| // Array of end of API callbacks, used to perform some logic | ||
| // at the end of an API. | ||
| request.finalizerHooks = []; | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||
|---|---|---|---|---|
|
|
@@ -29,10 +29,7 @@ function bucketGetCors(authInfo, request, log, callback) { | |||
| const corsHeaders = collectCorsHeaders(request.headers.origin, request.method, bucket); | ||||
| if (err) { | ||||
| monitoring.promMetrics('GET', bucketName, err.code, METRICS_ACTION); | ||||
| if (err?.is?.AccessDenied) { | ||||
| return callback(err, corsHeaders); | ||||
| } | ||||
| return callback(err); | ||||
| return callback(err, null, corsHeaders); | ||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The same issue seems to exist here: cloudserver/lib/api/bucketGetLocation.js Line 33 in bb5ca88
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. good catch, thanks. There are other calls where corsHeaders is in the same position but on those cases the routing is correct. I think this should be the last one in the wrong position. |
||||
| } | ||||
|
|
||||
| const cors = bucket.getCors(); | ||||
|
|
||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,152 @@ | ||
| const { S3 } = require('aws-sdk'); | ||
| const assert = require('assert'); | ||
| const async = require('async'); | ||
|
|
||
| const getConfig = require('../support/config'); | ||
| const { methodRequest, generateCorsParams } = | ||
| require('../../lib/utility/cors-util'); | ||
|
|
||
| const config = getConfig('default', { signatureVersion: 'v4' }); | ||
| const s3 = new S3(config); | ||
|
|
||
| const bucket = 'corserrorheadertest'; | ||
| const objectKey = 'objectKey'; | ||
| const allowedOrigin = 'http://www.allowed.test'; | ||
| const vary = 'Origin, Access-Control-Request-Headers, ' | ||
| + 'Access-Control-Request-Method'; | ||
|
|
||
| const expectedCorsHeaders = { | ||
| 'access-control-allow-origin': allowedOrigin, | ||
| 'access-control-allow-methods': 'GET, PUT, POST, DELETE, HEAD', | ||
| 'access-control-allow-credentials': 'true', | ||
| vary, | ||
| }; | ||
|
|
||
| const corsParams = generateCorsParams(bucket, { | ||
| allowedMethods: ['GET', 'PUT', 'POST', 'DELETE', 'HEAD'], | ||
| allowedOrigins: [allowedOrigin], | ||
| allowedHeaders: ['*'], | ||
| }); | ||
|
|
||
| // Raw unauthenticated requests - they always return 403. | ||
| // Each spec describes (method, path, query) against the bucket. | ||
| const unauthenticatedRequests = [ | ||
| { description: 'GET bucket (list objects)', | ||
| method: 'GET', query: null, objectKey: null }, | ||
| { description: 'HEAD bucket', | ||
| method: 'HEAD', query: null, objectKey: null }, | ||
| { description: 'DELETE bucket', | ||
| method: 'DELETE', query: null, objectKey: null }, | ||
| { description: 'GET bucket ACL', | ||
| method: 'GET', query: 'acl', objectKey: null }, | ||
| { description: 'GET bucket CORS', | ||
| method: 'GET', query: 'cors', objectKey: null }, | ||
| { description: 'GET bucket versioning', | ||
| method: 'GET', query: 'versioning', objectKey: null }, | ||
| { description: 'GET bucket website', | ||
| method: 'GET', query: 'website', objectKey: null }, | ||
| { description: 'GET bucket tagging', | ||
| method: 'GET', query: 'tagging', objectKey: null }, | ||
| { description: 'GET object', | ||
| method: 'GET', query: null, objectKey }, | ||
| { description: 'HEAD object', | ||
| method: 'HEAD', query: null, objectKey }, | ||
| { description: 'PUT object', | ||
| method: 'PUT', query: null, objectKey }, | ||
| { description: 'DELETE object', | ||
| method: 'DELETE', query: null, objectKey }, | ||
| { description: 'GET bucket uploads (list multipart uploads)', | ||
| method: 'GET', query: 'uploads', objectKey: null }, | ||
| // GET bucket policy and POST multi-delete are not covered here: the | ||
| // first returns 405 (method rejected pre-auth), the second returns 400 | ||
| // (missing XML body fails validation pre-auth). Neither reaches the | ||
| // 403 path. Both are exercised via the unit test that stubs auth | ||
| // failure directly. | ||
| ]; | ||
|
|
||
| function _waitForAWS(callback, err) { | ||
| if (err) { | ||
| return setTimeout(() => callback(err), 500); | ||
| } | ||
| return setTimeout(() => callback(), 500); | ||
| } | ||
|
|
||
| describe('CORS headers on 403 responses when bucket has CORS configured', () => { | ||
| before(done => async.series([ | ||
| cb => s3.createBucket({ Bucket: bucket }, err => _waitForAWS(cb, err)), | ||
| cb => s3.putBucketCors(corsParams, err => _waitForAWS(cb, err)), | ||
| ], done)); | ||
|
|
||
| after(done => s3.deleteBucket({ Bucket: bucket }, | ||
| err => _waitForAWS(done, err))); | ||
|
|
||
| unauthenticatedRequests.forEach(spec => { | ||
| it(`returns CORS headers on 403 for ${spec.description} ` | ||
| + 'when Origin matches a rule', done => { | ||
| methodRequest({ | ||
| method: spec.method, | ||
| bucket, | ||
| objectKey: spec.objectKey, | ||
| query: spec.query, | ||
| headers: { origin: allowedOrigin }, | ||
| // Use numeric status: HEAD responses have no body, and some | ||
| // endpoints (bucket policy, multi-delete) can fail with a | ||
| // non-AccessDenied body before auth even runs. We only care | ||
| // about the 403 status and the CORS headers here. | ||
| code: 403, | ||
| headersResponse: expectedCorsHeaders, | ||
| }, done); | ||
| }); | ||
| }); | ||
|
|
||
| it('omits CORS headers on 403 when Origin does not match any rule', | ||
| done => { | ||
| methodRequest({ | ||
| method: 'GET', | ||
| bucket, | ||
| query: null, | ||
| objectKey: null, | ||
| headers: { origin: 'http://not-allowed.test' }, | ||
| code: 403, | ||
| // headersResponse unset -> cors-util asserts CORS headers | ||
| // are NOT present. | ||
| }, done); | ||
| }); | ||
|
|
||
| it('omits CORS headers on 403 when no Origin header is sent', | ||
| done => { | ||
| methodRequest({ | ||
| method: 'GET', | ||
| bucket, | ||
| query: null, | ||
| objectKey: null, | ||
| headers: {}, | ||
| code: 403, | ||
| }, done); | ||
| }); | ||
| }); | ||
|
|
||
| describe('CORS headers on 200 responses (regression guard)', () => { | ||
| before(done => async.series([ | ||
| cb => s3.createBucket({ Bucket: bucket }, err => _waitForAWS(cb, err)), | ||
| cb => s3.putBucketCors(corsParams, err => _waitForAWS(cb, err)), | ||
| ], done)); | ||
|
|
||
| after(done => s3.deleteBucket({ Bucket: bucket }, | ||
| err => _waitForAWS(done, err))); | ||
|
|
||
| it('returns CORS headers on a successful list objects (200)', done => { | ||
| const request = s3.listObjects({ Bucket: bucket }); | ||
| request.on('build', () => { | ||
| request.httpRequest.headers.origin = allowedOrigin; | ||
| }); | ||
| request.on('success', response => { | ||
| const h = response.httpResponse.headers; | ||
| assert.strictEqual(h['access-control-allow-origin'], | ||
| allowedOrigin); | ||
| done(); | ||
| }); | ||
| request.on('error', err => done(err)); | ||
| request.send(); | ||
| }); | ||
| }); |
Uh oh!
There was an error while loading. Please reload this page.