Skip to content

Commit b5a6a51

Browse files
committed
CLDSRV-881: add fast-path to cors header to avoid extra md call
1 parent bf9577a commit b5a6a51

2 files changed

Lines changed: 99 additions & 8 deletions

File tree

lib/api/api.js

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,23 @@ const monitoringMap = policies.actionMaps.actionMonitoringMapS3;
9494

9595
auth.setHandler(vault);
9696

97+
// Detect CORS headers the handler already attached to its callback args.
98+
// Callback arity varies per route ((err, corsHeaders), (err, xml,
99+
// corsHeaders), (err, dataGetInfo, resMetaHeaders, range), ...) so we
100+
// scan the args instead of relying on a fixed position.
101+
function hasCorsHeaders(rest) {
102+
for (const arg of rest) {
103+
if (!arg || typeof arg !== 'object' || Array.isArray(arg)
104+
|| Buffer.isBuffer(arg)) {
105+
continue;
106+
}
107+
if ('access-control-allow-origin' in arg) {
108+
return true;
109+
}
110+
}
111+
return false;
112+
}
113+
97114
// Wrap the API callback so that, on error, we look up the bucket's CORS
98115
// configuration and set the matching Access-Control-* headers directly on
99116
// the HTTP response. This is needed because auth / pre-handler errors
@@ -108,8 +125,26 @@ function wrapCallbackWithErrorCorsHeaders(request, response, log, callback) {
108125
|| !request.bucketName) {
109126
return callback(err, ...rest);
110127
}
128+
// Fast path: most post-auth failures come back with corsHeaders
129+
// already computed by the handler. The route will forward them
130+
// to responseXMLBody/responseNoBody, so no extra lookup is needed.
131+
if (hasCorsHeaders(rest)) {
132+
return callback(err, ...rest);
133+
}
134+
// Fallback: bucket was never loaded (pre-handler failure like
135+
// auth.server.doAuth denial, header-size check, copy-source parse
136+
// error). Look the bucket up so we can still reply with CORS
137+
// headers.
111138
return metadata.getBucket(request.bucketName, log, (mdErr, bucket) => {
112-
if (!mdErr && bucket && !response.headersSent) {
139+
if (mdErr) {
140+
log.warn('could not fetch bucket CORS config for error '
141+
+ 'response', {
142+
bucketName: request.bucketName,
143+
error: mdErr.code || mdErr.message,
144+
});
145+
return callback(err, ...rest);
146+
}
147+
if (bucket && !response.headersSent) {
113148
const headers = collectCorsHeaders(
114149
request.headers.origin, request.method, bucket);
115150
Object.keys(headers).forEach(key => {

tests/unit/api/corsErrorHeaders.js

Lines changed: 63 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ const { bucketGet } = require('../../../lib/api/bucketGet');
77
const bucketGetCors = require('../../../lib/api/bucketGetCors');
88
const { bucketPut } = require('../../../lib/api/bucketPut');
99
const bucketPutCors = require('../../../lib/api/bucketPutCors');
10+
const DummyRequest = require('../DummyRequest');
1011
const {
1112
CorsConfigTester,
1213
DummyRequestLogger,
@@ -86,7 +87,10 @@ function setupBucketWithCors(done) {
8687
}
8788

8889
function buildRequest(spec) {
89-
const req = {
90+
// DummyRequest is an http.IncomingMessage stream that emits 'end'
91+
// synchronously. We need that because callApiMethod's waterfall
92+
// waits for the request body on non-objectPut paths.
93+
return new DummyRequest({
9094
bucketName,
9195
objectKey: spec.objectKey,
9296
headers: {
@@ -96,12 +100,7 @@ function buildRequest(spec) {
96100
url: spec.url,
97101
query: spec.query,
98102
method: spec.httpMethod,
99-
actionImplicitDenies: false,
100-
socket: { remoteAddress: '127.0.0.1', destroy: () => {} },
101-
};
102-
// Pretend the HTTP body is already drained so on('end') does not fire.
103-
req.on = () => req;
104-
return req;
103+
}, Buffer.alloc(0));
105104
}
106105

107106
function buildResponseSpy(sandbox) {
@@ -218,6 +217,63 @@ describe('CORS headers on 403 auth failures (api.callApiMethod)', () => {
218217
});
219218
});
220219

220+
describe('CORS headers on 403 via handler (fast path)', () => {
221+
// Verifies the wrapper's fast path: when auth succeeds but the
222+
// handler's own ACL/policy check denies, the handler has already
223+
// loaded the bucket and passed corsHeaders through its callback.
224+
// The wrapper should forward them without calling metadata.getBucket
225+
// itself.
226+
let sandbox;
227+
228+
before(done => setupBucketWithCors(done));
229+
230+
beforeEach(() => {
231+
sandbox = sinon.createSandbox();
232+
// Stub auth to succeed as a *different* account. The handler then
233+
// runs standardMetadataValidateBucket which denies because the
234+
// bucket is owned by accessKey1.
235+
const otherAuth = makeAuthInfo('accessKey2');
236+
const authServer = {
237+
doAuth: sandbox.stub().callsArgWith(2, null, otherAuth,
238+
[{ isAllowed: true, isImplicit: false }], null, {}),
239+
};
240+
sandbox.stub(auth, 'server').value(authServer);
241+
});
242+
243+
afterEach(() => sandbox.restore());
244+
245+
it('forwards handler-provided corsHeaders without setting headers '
246+
+ 'on the response directly', done => {
247+
const request = buildRequest({
248+
apiMethod: 'bucketGet', httpMethod: 'GET',
249+
url: '/', query: {},
250+
});
251+
const response = buildResponseSpy(sandbox);
252+
const log = buildLog(sandbox);
253+
254+
api.callApiMethod('bucketGet', request, response, log,
255+
(err, xml, corsHeaders) => {
256+
assert(err, 'expected an error');
257+
assert(err.is && err.is.AccessDenied,
258+
`expected AccessDenied, got ${err.code}`);
259+
assert(corsHeaders,
260+
'handler should have supplied corsHeaders');
261+
assert.strictEqual(
262+
corsHeaders['access-control-allow-origin'], origin,
263+
'corsHeaders should include access-control-allow-origin');
264+
// Fast path: the wrapper did not setHeader on the response.
265+
// The route-level transport is what would ultimately call
266+
// setCommonResponseHeaders in production.
267+
assert.strictEqual(
268+
response.getHeader('access-control-allow-origin'),
269+
undefined,
270+
'wrapper should not set CORS headers directly when the '
271+
+ 'handler already provided them');
272+
done();
273+
});
274+
});
275+
});
276+
221277
describe('CORS headers on 200 successful responses (per-handler)', () => {
222278
// Sanity-check that the existing per-handler path continues to work.
223279
// Pass-through tests: a request with matching CORS should receive

0 commit comments

Comments
 (0)