-
Notifications
You must be signed in to change notification settings - Fork 188
fix(event-handler): handle set-cookie header values with multiple attributes #4990
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: main
Are you sure you want to change the base?
Changes from all commits
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 |
|---|---|---|
|
|
@@ -228,13 +228,26 @@ const webHeadersToApiGatewayV1Headers = (webHeaders: Headers) => { | |
| const headers: Record<string, string> = {}; | ||
| const multiValueHeaders: Record<string, Array<string>> = {}; | ||
|
|
||
| const cookies = webHeaders.getSetCookie(); | ||
| const allCookies: string[] = []; | ||
| for (const cookie of cookies) { | ||
| allCookies.push(...cookie.split(',').map((v) => v.trimStart())); | ||
| } | ||
|
|
||
| if (allCookies.length > 1) { | ||
| multiValueHeaders['set-cookie'] = allCookies; | ||
| } else if (allCookies.length === 1) { | ||
| headers['set-cookie'] = allCookies[0]; | ||
| } | ||
|
|
||
| for (const [key, value] of webHeaders.entries()) { | ||
| if (key.toLowerCase() === 'set-cookie') { | ||
| continue; | ||
| } | ||
|
|
||
| const values = value.split(/[;,]/).map((v) => v.trimStart()); | ||
|
|
||
| if (headers[key]) { | ||
| multiValueHeaders[key] = [headers[key], ...values]; | ||
| delete headers[key]; | ||
| } else if (values.length > 1) { | ||
| if (values.length > 1) { | ||
|
Comment on lines
-234
to
+250
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. Why are we removing this logic?
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. To my knowledge |
||
| multiValueHeaders[key] = values; | ||
| } else { | ||
| headers[key] = value; | ||
|
|
||
|
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. There's a lot of diffs that seem to be unrelated to this PR - any chance you could revert?
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. Ah, yeah. Looks like my own biome got the best of me.
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. Actually, it looks like these changes were automatically made via |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -29,73 +29,71 @@ describe.each([ | |
| ['HEAD', 'head'], | ||
| ['OPTIONS', 'options'], | ||
| ]; | ||
| it.each(httpMethods)( | ||
| 'routes %s requests with object response', | ||
| async (method, verb) => { | ||
| // Prepare | ||
| const app = new Router(); | ||
| ( | ||
| app[verb as Lowercase<HttpMethod>] as ( | ||
| path: string, | ||
| handler: RouteHandler | ||
| ) => void | ||
| )('/test', async () => ({ result: `${verb}-test` })); | ||
|
|
||
| // Act | ||
| const actual = await app.resolve(createEvent('/test', method), context); | ||
|
|
||
| // Assess | ||
| expect(actual.statusCode).toBe(200); | ||
| expect(actual.body).toBe(JSON.stringify({ result: `${verb}-test` })); | ||
| expect(actual.headers?.['content-type']).toBe('application/json'); | ||
| expect(actual.isBase64Encoded).toBe(false); | ||
| } | ||
| ); | ||
|
|
||
| it.each(httpMethods)( | ||
| 'routes %s requests with array response', | ||
| async (method, verb) => { | ||
| // Prepare | ||
| const app = new Router(); | ||
| ( | ||
| app[verb as Lowercase<HttpMethod>] as ( | ||
| path: string, | ||
| handler: RouteHandler | ||
| ) => void | ||
| )('/test', async () => [ | ||
| it.each( | ||
| httpMethods | ||
| )('routes %s requests with object response', async (method, verb) => { | ||
| // Prepare | ||
| const app = new Router(); | ||
| ( | ||
| app[verb as Lowercase<HttpMethod>] as ( | ||
| path: string, | ||
| handler: RouteHandler | ||
| ) => void | ||
| )('/test', async () => ({ result: `${verb}-test` })); | ||
|
|
||
| // Act | ||
| const actual = await app.resolve(createEvent('/test', method), context); | ||
|
|
||
| // Assess | ||
| expect(actual.statusCode).toBe(200); | ||
| expect(actual.body).toBe(JSON.stringify({ result: `${verb}-test` })); | ||
| expect(actual.headers?.['content-type']).toBe('application/json'); | ||
| expect(actual.isBase64Encoded).toBe(false); | ||
| }); | ||
|
|
||
| it.each( | ||
| httpMethods | ||
| )('routes %s requests with array response', async (method, verb) => { | ||
| // Prepare | ||
| const app = new Router(); | ||
| ( | ||
| app[verb as Lowercase<HttpMethod>] as ( | ||
| path: string, | ||
| handler: RouteHandler | ||
| ) => void | ||
| )('/test', async () => [ | ||
| { id: 1, result: `${verb}-test-1` }, | ||
| { id: 2, result: `${verb}-test-2` }, | ||
| ]); | ||
|
|
||
| // Act | ||
| const actual = await app.resolve(createEvent('/test', method), context); | ||
|
|
||
| // Assess | ||
| expect(actual.statusCode).toBe(200); | ||
| expect(actual.body).toBe( | ||
| JSON.stringify([ | ||
| { id: 1, result: `${verb}-test-1` }, | ||
| { id: 2, result: `${verb}-test-2` }, | ||
| ]); | ||
|
|
||
| // Act | ||
| const actual = await app.resolve(createEvent('/test', method), context); | ||
|
|
||
| // Assess | ||
| expect(actual.statusCode).toBe(200); | ||
| expect(actual.body).toBe( | ||
| JSON.stringify([ | ||
| { id: 1, result: `${verb}-test-1` }, | ||
| { id: 2, result: `${verb}-test-2` }, | ||
| ]) | ||
| ); | ||
| expect(actual.headers?.['content-type']).toBe('application/json'); | ||
| expect(actual.isBase64Encoded).toBe(false); | ||
| } | ||
| ); | ||
| ]) | ||
| ); | ||
| expect(actual.headers?.['content-type']).toBe('application/json'); | ||
| expect(actual.isBase64Encoded).toBe(false); | ||
| }); | ||
|
|
||
| it.each([['CONNECT'], ['TRACE']])( | ||
| 'throws MethodNotAllowedError for %s requests', | ||
| async (method) => { | ||
| // Prepare | ||
| const app = new Router(); | ||
| it.each([ | ||
| ['CONNECT'], | ||
| ['TRACE'], | ||
| ])('throws MethodNotAllowedError for %s requests', async (method) => { | ||
| // Prepare | ||
| const app = new Router(); | ||
|
|
||
| // Act & Assess | ||
| const result = await app.resolve(createEvent('/test', method), context); | ||
| // Act & Assess | ||
| const result = await app.resolve(createEvent('/test', method), context); | ||
|
|
||
| expect(result.statusCode).toBe(HttpStatusCodes.METHOD_NOT_ALLOWED); | ||
| expect(result.body ?? '').toBe(''); | ||
| } | ||
| ); | ||
| expect(result.statusCode).toBe(HttpStatusCodes.METHOD_NOT_ALLOWED); | ||
| expect(result.body ?? '').toBe(''); | ||
| }); | ||
|
|
||
| it('accepts multiple HTTP methods', async () => { | ||
| // Act | ||
|
|
@@ -273,7 +271,10 @@ describe('Class: Router - V1 Multivalue Headers Support', () => { | |
| statusCode: 200, | ||
| body: JSON.stringify({ message: 'success' }), | ||
| headers: { 'content-type': 'application/json' }, | ||
| multiValueHeaders: { 'set-cookie': ['session=abc123', 'theme=dark'] }, | ||
| multiValueHeaders: { | ||
| 'cache-control': ['no-cache', 'no-store'], | ||
|
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. Where are these
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. I added them to illustrate a 'simple' multi-value header pair along with the set-cookie multi-value header which contains a semicolon. |
||
| 'set-cookie': ['session=abc123; Max-Age=3600', 'theme=dark'], | ||
| }, | ||
| })); | ||
|
|
||
| // Act | ||
|
|
@@ -284,7 +285,10 @@ describe('Class: Router - V1 Multivalue Headers Support', () => { | |
| statusCode: 200, | ||
| body: JSON.stringify({ message: 'success' }), | ||
| headers: { 'content-type': 'application/json' }, | ||
| multiValueHeaders: { 'set-cookie': ['session=abc123', 'theme=dark'] }, | ||
| multiValueHeaders: { | ||
| 'cache-control': ['no-cache', 'no-store'], | ||
| 'set-cookie': ['session=abc123; Max-Age=3600', 'theme=dark'], | ||
| }, | ||
| isBase64Encoded: false, | ||
| }); | ||
| }); | ||
|
|
@@ -298,7 +302,7 @@ describe('Class: Router - V2 Cookies Support', () => { | |
| statusCode: 200, | ||
| body: JSON.stringify({ message: 'success' }), | ||
| headers: { 'content-type': 'application/json' }, | ||
| cookies: ['session=abc123', 'theme=dark'], | ||
| cookies: ['session=abc123; Max-Age=3600', 'theme=dark'], | ||
| })); | ||
|
|
||
| // Act | ||
|
|
@@ -312,7 +316,7 @@ describe('Class: Router - V2 Cookies Support', () => { | |
| statusCode: 200, | ||
| body: JSON.stringify({ message: 'success' }), | ||
| headers: { 'content-type': 'application/json' }, | ||
| cookies: ['session=abc123', 'theme=dark'], | ||
| cookies: ['session=abc123; Max-Age=3600', 'theme=dark'], | ||
| isBase64Encoded: false, | ||
| }); | ||
| }); | ||
|
|
@@ -347,7 +351,10 @@ describe('Class: Router - ALB Support', () => { | |
| statusCode: 200, | ||
| body: JSON.stringify({ message: 'success' }), | ||
| headers: { 'content-type': 'application/json' }, | ||
| multiValueHeaders: { 'set-cookie': ['session=abc123', 'theme=dark'] }, | ||
| multiValueHeaders: { | ||
| 'cache-control': ['no-cache', 'no-store'], | ||
| 'set-cookie': ['session=abc123; Max-Age=3600', 'theme=dark'], | ||
| }, | ||
| })); | ||
|
|
||
| // Act | ||
|
|
@@ -362,7 +369,10 @@ describe('Class: Router - ALB Support', () => { | |
| statusDescription: '200 OK', | ||
| body: JSON.stringify({ message: 'success' }), | ||
| headers: { 'content-type': 'application/json' }, | ||
| multiValueHeaders: { 'set-cookie': ['session=abc123', 'theme=dark'] }, | ||
| multiValueHeaders: { | ||
| 'cache-control': ['no-cache', 'no-store'], | ||
| 'set-cookie': ['session=abc123; Max-Age=3600', 'theme=dark'], | ||
| }, | ||
| isBase64Encoded: false, | ||
| }); | ||
| }); | ||
|
|
@@ -391,31 +401,31 @@ describe('Class: Router - ALB Support', () => { | |
| statusCode: Number(code), | ||
| expectedDescription: `${code} ${text}`, | ||
| })) | ||
| )( | ||
| 'returns statusDescription "$expectedDescription" for status code $statusCode', | ||
| async ({ statusCode, expectedDescription }) => { | ||
| // Prepare | ||
| const app = new Router(); | ||
| const noBodyStatuses = new Set([201, 204, 205, 304]); | ||
| const body = noBodyStatuses.has(statusCode) | ||
| ? null | ||
| : JSON.stringify({ status: statusCode }); | ||
| app.get('/test', () => new Response(body, { status: statusCode })); | ||
|
|
||
| // Act | ||
| const result = await app.resolve( | ||
| createTestALBEvent('/test', 'GET'), | ||
| context | ||
| ); | ||
|
|
||
| // Assess | ||
| expect(result.statusCode).toBe(statusCode); | ||
| expect(result).toHaveProperty('statusDescription', expectedDescription); | ||
| if (!noBodyStatuses.has(statusCode)) { | ||
| expect(result.body).toBe(JSON.stringify({ status: statusCode })); | ||
| } | ||
| )('returns statusDescription "$expectedDescription" for status code $statusCode', async ({ | ||
| statusCode, | ||
| expectedDescription, | ||
| }) => { | ||
| // Prepare | ||
| const app = new Router(); | ||
| const noBodyStatuses = new Set([201, 204, 205, 304]); | ||
| const body = noBodyStatuses.has(statusCode) | ||
| ? null | ||
| : JSON.stringify({ status: statusCode }); | ||
| app.get('/test', () => new Response(body, { status: statusCode })); | ||
|
|
||
| // Act | ||
| const result = await app.resolve( | ||
| createTestALBEvent('/test', 'GET'), | ||
| context | ||
| ); | ||
|
|
||
| // Assess | ||
| expect(result.statusCode).toBe(statusCode); | ||
| expect(result).toHaveProperty('statusDescription', expectedDescription); | ||
| if (!noBodyStatuses.has(statusCode)) { | ||
| expect(result.body).toBe(JSON.stringify({ status: statusCode })); | ||
| } | ||
| ); | ||
| }); | ||
| }); | ||
|
|
||
| describe.each([ | ||
|
|
@@ -475,29 +485,31 @@ describe.each([ | |
| expect(result.isBase64Encoded).toBe(true); | ||
| }); | ||
|
|
||
| it.each([['image/png'], ['image/jpeg'], ['audio/mpeg'], ['video/mp4']])( | ||
| 'sets isBase64Encoded for %s content-type', | ||
| async (contentType) => { | ||
| // Prepare | ||
| const app = new Router(); | ||
| app.get( | ||
| '/media', | ||
| () => | ||
| new Response('binary data', { | ||
| headers: { 'content-type': contentType }, | ||
| }) | ||
| ); | ||
|
|
||
| // Act | ||
| const result = await app.resolve(createEvent('/media', 'GET'), context); | ||
|
|
||
| // Assess | ||
| expect(result.statusCode).toBe(200); | ||
| expect(result.body).toBe(Buffer.from('binary data').toString('base64')); | ||
| expect(result.headers?.['content-type']).toBe(contentType); | ||
| expect(result.isBase64Encoded).toBe(true); | ||
| } | ||
| ); | ||
| it.each([ | ||
| ['image/png'], | ||
| ['image/jpeg'], | ||
| ['audio/mpeg'], | ||
| ['video/mp4'], | ||
| ])('sets isBase64Encoded for %s content-type', async (contentType) => { | ||
| // Prepare | ||
| const app = new Router(); | ||
| app.get( | ||
| '/media', | ||
| () => | ||
| new Response('binary data', { | ||
| headers: { 'content-type': contentType }, | ||
| }) | ||
| ); | ||
|
|
||
| // Act | ||
| const result = await app.resolve(createEvent('/media', 'GET'), context); | ||
|
|
||
| // Assess | ||
| expect(result.statusCode).toBe(200); | ||
| expect(result.body).toBe(Buffer.from('binary data').toString('base64')); | ||
| expect(result.headers?.['content-type']).toBe(contentType); | ||
| expect(result.isBase64Encoded).toBe(true); | ||
| }); | ||
|
|
||
| it('does not set isBase64Encoded for text content-types', async () => { | ||
| // Prepare | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ultimately, splitting on a semicolon seems out of place and is the root of this entire issue. If a goal wasn't to keep backwards compatibility I would suggest removing it and using a comma only. Headers by default a separated by a comma.
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
So the reason I wrote it this way was because I was under the impression that cookies were an exception in that they could be separated by a semicolon. I got this from the RFC 6265:
It looks like I made a mistake and thought this referred to the
Set-Cookieheader rather than theCookieheader as indicated in the quote above.Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What this means though is that to handle the
Cookieheader case correctly we do need to have special handling for the semicolon. Btw, regarding this:We don't need to preserve backward compatibility for what was a bug. If we change this so only
Cookiegets this handling and that's fine.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks for the references to the RFC. In this particular circumstance the webHeadersToApiGatewayV1Headers function is converting from server to user agent (not also user agent to server) so we should be able to omit special handling of the
Cookieheader as per RFC6265 4.2 theCookieheader is a user agent to server header.It seems like the semicolon was included to handle the special usage of the 'Cookie' header which we don't actually need here.
I see your point here; however I think only
set-cookiewith a semicolon is a bug. Since the current implementation accepts any header with a semicolon separated value (while it may not be popular) removing it might break current implementations.I'm unsure of the best path here. Ultimately it seems like dropping the semicolon would simplify and unify the usage across the board (all headers are treated the same).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The issue here is larger though, splitting on
;causes worse problems. The accepted way to inidicate parameters in a header is with a semi colon, e.g.,Accept: text/html; q=0.5, text/plain; q=0.2. With the current implementation, this ends up with a multivalue header value which is clearly wrong:It's far more important that we handle this case correctly than headers that aren't
Cookieerroneously delimited by;.