mirror of
https://github.com/nestjs/nest.git
synced 2026-10-28 09:45:43 +08:00
fix(express,fastify): parse quoted media type version parameters (#18178)
* fix(express,fastify): parse quoted media type version parameters * fix(express,fastify): split quoted headers in linear time * style(express,fastify): format media type version helpers * refactor(express,fastify): add document media type parsing * style(express,fastify): revert code style * style(express,fastify): revert code style * fix(express,fastify): read a version before an unterminated quote An unterminated quoted string now runs to the end of the header value instead of discarding the whole value, so a version parameter before it is read as before this change, while one after its opening quote is still not. --------- Co-authored-by: Kamil Myśliwiec <mail@kamilmysliwiec.com>
This commit is contained in:
1 parent
b080b4cbb1
commit
c8923bd7c9
6 files changed
+282
-14
No files matched your search
@@ -50,6 +50,34 @@ describe('Media Type Versioning (fastify)', () => {
|
||||
.expect('Hello World V2!');
|
||||
});
|
||||
|
||||
it.each([
|
||||
'application/json;v="2"',
|
||||
'application/json;note="a;v=1";v=2',
|
||||
'text/html;note="a,application/json;v=1", application/json;v=2',
|
||||
'application/json;note="a\\";v=1";v=2',
|
||||
'application/json;v="\\2"',
|
||||
'application/json;v=2;note="unterminated',
|
||||
])('V2 with quoted parameters: %s', async acceptHeader => {
|
||||
await request(app.getHttpServer())
|
||||
.get('/')
|
||||
.set({ Accept: acceptHeader })
|
||||
.expect(200)
|
||||
.expect('Hello World V2!');
|
||||
});
|
||||
|
||||
it.each([
|
||||
'application/json;note="a;v=2',
|
||||
'text/html;note="a,application/json;v=2',
|
||||
])(
|
||||
'ignores a version after an unterminated quote: %s',
|
||||
async acceptHeader => {
|
||||
await request(app.getHttpServer())
|
||||
.get('/')
|
||||
.set({ Accept: acceptHeader })
|
||||
.expect(404);
|
||||
},
|
||||
);
|
||||
|
||||
it('V2 after other parameters', () => {
|
||||
return request(app.getHttpServer())
|
||||
.get('/')
|
||||
@@ -389,6 +417,31 @@ describe('Media Type Versioning (fastify)', () => {
|
||||
.expect('Multiple Versions Neutral or 2');
|
||||
});
|
||||
|
||||
it.each([
|
||||
'application/json;note="a;v=1"',
|
||||
'text/html;note="a,application/json;v=1"',
|
||||
])('No Version with a quoted parameter: %s', async acceptHeader => {
|
||||
await request(app.getHttpServer())
|
||||
.get('/multiple-neutral')
|
||||
.set({ Accept: acceptHeader })
|
||||
.expect(200)
|
||||
.expect('Multiple Versions Neutral or 2');
|
||||
});
|
||||
|
||||
it.each([
|
||||
'application/json;note="a;v=2',
|
||||
'text/html;note="a,application/json;v=2',
|
||||
])(
|
||||
'treats a version after an unterminated quote as unversioned: %s',
|
||||
async acceptHeader => {
|
||||
await request(app.getHttpServer())
|
||||
.get('/multiple-neutral')
|
||||
.set({ Accept: acceptHeader })
|
||||
.expect(200)
|
||||
.expect('Multiple Versions Neutral or 2');
|
||||
},
|
||||
);
|
||||
|
||||
it('No Version with other parameters', () => {
|
||||
return request(app.getHttpServer())
|
||||
.get('/multiple-neutral')
|
||||
|
||||
@@ -43,6 +43,34 @@ describe('Media Type Versioning', () => {
|
||||
.expect('Hello World V2!');
|
||||
});
|
||||
|
||||
it.each([
|
||||
'application/json;v="2"',
|
||||
'application/json;note="a;v=1";v=2',
|
||||
'text/html;note="a,application/json;v=1", application/json;v=2',
|
||||
'application/json;note="a\\";v=1";v=2',
|
||||
'application/json;v="\\2"',
|
||||
'application/json;v=2;note="unterminated',
|
||||
])('V2 with quoted parameters: %s', async acceptHeader => {
|
||||
await request(app.getHttpServer())
|
||||
.get('/')
|
||||
.set({ Accept: acceptHeader })
|
||||
.expect(200)
|
||||
.expect('Hello World V2!');
|
||||
});
|
||||
|
||||
it.each([
|
||||
'application/json;note="a;v=2',
|
||||
'text/html;note="a,application/json;v=2',
|
||||
])(
|
||||
'ignores a version after an unterminated quote: %s',
|
||||
async acceptHeader => {
|
||||
await request(app.getHttpServer())
|
||||
.get('/')
|
||||
.set({ Accept: acceptHeader })
|
||||
.expect(404);
|
||||
},
|
||||
);
|
||||
|
||||
it('V2 after other parameters', () => {
|
||||
return request(app.getHttpServer())
|
||||
.get('/')
|
||||
@@ -382,6 +410,31 @@ describe('Media Type Versioning', () => {
|
||||
.expect('Multiple Versions Neutral or 2');
|
||||
});
|
||||
|
||||
it.each([
|
||||
'application/json;note="a;v=1"',
|
||||
'text/html;note="a,application/json;v=1"',
|
||||
])('No Version with a quoted parameter: %s', async acceptHeader => {
|
||||
await request(app.getHttpServer())
|
||||
.get('/multiple-neutral')
|
||||
.set({ Accept: acceptHeader })
|
||||
.expect(200)
|
||||
.expect('Multiple Versions Neutral or 2');
|
||||
});
|
||||
|
||||
it.each([
|
||||
'application/json;note="a;v=2',
|
||||
'text/html;note="a,application/json;v=2',
|
||||
])(
|
||||
'treats a version after an unterminated quote as unversioned: %s',
|
||||
async acceptHeader => {
|
||||
await request(app.getHttpServer())
|
||||
.get('/multiple-neutral')
|
||||
.set({ Accept: acceptHeader })
|
||||
.expect(200)
|
||||
.expect('Multiple Versions Neutral or 2');
|
||||
},
|
||||
);
|
||||
|
||||
it('No Version with other parameters', () => {
|
||||
return request(app.getHttpServer())
|
||||
.get('/multiple-neutral')
|
||||
|
||||
@@ -1,3 +1,28 @@
|
||||
/**
|
||||
* Splits a header value at separators outside quoted strings in linear time.
|
||||
* Backslash-escaped characters inside quoted strings are treated as literals.
|
||||
*/
|
||||
const splitOutsideQuotes = (value: string, separator: ',' | ';'): string[] => {
|
||||
const parts: string[] = [];
|
||||
let start = 0;
|
||||
let isQuoted = false;
|
||||
const len = value.length;
|
||||
for (let index = 0; index < len; index++) {
|
||||
const character = value[index];
|
||||
if (isQuoted && character === '\\') {
|
||||
// A quoted-pair consumes the next character, including an escaped quote.
|
||||
index++;
|
||||
} else if (character === '"') {
|
||||
isQuoted = !isQuoted;
|
||||
} else if (character === separator && !isQuoted) {
|
||||
parts.push(value.slice(start, index));
|
||||
start = index + 1;
|
||||
}
|
||||
}
|
||||
parts.push(value.slice(start));
|
||||
return parts;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the version carried by an `Accept` header for media type versioning,
|
||||
* e.g. `2` for `application/json;v=2` with the `v=` key.
|
||||
@@ -6,6 +31,10 @@
|
||||
* media range of a list (`text/html, application/json;v=2`); the first match
|
||||
* wins. Returns `undefined` when no parameter starts with the key.
|
||||
*
|
||||
* Quoted parameter values are unescaped, and separators inside them are ignored.
|
||||
* An unterminated quoted string runs to the end of the header value, so a
|
||||
* version that follows its opening quote is not read.
|
||||
*
|
||||
* Duplicated in each platform package so that a platform package never
|
||||
* depends on a newer `@nestjs/core` than the one installed.
|
||||
*/
|
||||
@@ -15,14 +44,18 @@ export function getMediaTypeVersion(
|
||||
): string | undefined {
|
||||
const mediaRanges = ([] as string[])
|
||||
.concat(acceptHeader ?? [])
|
||||
.flatMap(value => value.split(','));
|
||||
.flatMap(value => splitOutsideQuotes(value, ','));
|
||||
|
||||
for (const mediaRange of mediaRanges) {
|
||||
const [, ...parameters] = mediaRange.split(';');
|
||||
const [, ...parameters] = splitOutsideQuotes(mediaRange, ';');
|
||||
for (const parameter of parameters) {
|
||||
const trimmedParameter = parameter.trim();
|
||||
if (trimmedParameter.startsWith(key)) {
|
||||
return trimmedParameter.slice(key.length);
|
||||
const version = trimmedParameter.slice(key.length);
|
||||
if (version.startsWith('"') && version.endsWith('"')) {
|
||||
return version.slice(1, -1).replace(/\\([\s\S])/g, '$1');
|
||||
}
|
||||
return version;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,21 @@ describe('getMediaTypeVersion', () => {
|
||||
['text/html, application/json;v=2, */*;q=0.1', '2'],
|
||||
['application/json;v=1, application/json;v=2', '1'],
|
||||
[['text/html', 'application/json;v=4'], '4'],
|
||||
['application/json;v="2"', '2'],
|
||||
['application/json;v=', ''],
|
||||
['application/json;v=""', ''],
|
||||
['application/json; v="3" ;q=0.8', '3'],
|
||||
['application/json;note="a;v=999";v=2', '2'],
|
||||
['text/html;note="a,application/json;v=999", application/json;v=2', '2'],
|
||||
['application/json;note="a\\";v=999";v=2', '2'],
|
||||
['application/json;note="a\\\\";v=2', '2'],
|
||||
['application/json;v="beta;next,2"', 'beta;next,2'],
|
||||
['application/json;v="\\2"', '2'],
|
||||
['application/json;v="a\\"b\\\\c"', 'a"b\\c'],
|
||||
['application/json;v="1", application/json;v=2', '1'],
|
||||
[['text/html;note="a;v=999"', 'application/json;v="4"'], '4'],
|
||||
[['application/json;note="unterminated', 'application/json;v="4"'], '4'],
|
||||
[['application/json;v="4"', 'application/json;note="unterminated'], '4'],
|
||||
])('should read the version from "%s"', (acceptHeader, expected) => {
|
||||
expect(getMediaTypeVersion(acceptHeader, 'v=')).toBe(expected);
|
||||
});
|
||||
@@ -20,13 +35,46 @@ describe('getMediaTypeVersion', () => {
|
||||
'application/json;q=0.9',
|
||||
'application/json;version=2',
|
||||
'application/v=2',
|
||||
'application/json;note="a;v=2"',
|
||||
'text/html;note="a,application/json;v=2"',
|
||||
])('should return undefined for "%s"', acceptHeader => {
|
||||
expect(getMediaTypeVersion(acceptHeader, 'v=')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should use the configured key', () => {
|
||||
expect(
|
||||
getMediaTypeVersion('application/json;v=1;version=4', 'version='),
|
||||
).toBe('4');
|
||||
it.each([
|
||||
'application/json;v=1;version=4',
|
||||
'application/json;v=1;version="4"',
|
||||
'application/json;note="a;version=999";version="4"',
|
||||
])('should use the configured key in "%s"', acceptHeader => {
|
||||
expect(getMediaTypeVersion(acceptHeader, 'version=')).toBe('4');
|
||||
});
|
||||
|
||||
it.each([
|
||||
'application/json;note="a;v=2',
|
||||
'text/html;note="a,application/json;v=2',
|
||||
'application/json;note="a\\";v=2',
|
||||
'application/json;note="a\\',
|
||||
])(
|
||||
'should not read a version after an unterminated quote: %s',
|
||||
acceptHeader => {
|
||||
expect(getMediaTypeVersion(acceptHeader, 'v=')).toBeUndefined();
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
'application/json;v=2;note="unterminated',
|
||||
'application/json;v=2, text/html;note="unterminated',
|
||||
])('should read a version before an unterminated quote: %s', acceptHeader => {
|
||||
expect(getMediaTypeVersion(acceptHeader, 'v=')).toBe('2');
|
||||
});
|
||||
|
||||
it('should keep an unterminated quoted version as is', () => {
|
||||
expect(getMediaTypeVersion('application/json;v="2', 'v=')).toBe('"2');
|
||||
});
|
||||
|
||||
it('should ignore a long unterminated quoted value with escaped quotes', () => {
|
||||
const acceptHeader =
|
||||
'application/json;note="' + '\\"'.repeat(8000) + ';v=2';
|
||||
expect(getMediaTypeVersion(acceptHeader, 'v=')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,28 @@
|
||||
/**
|
||||
* Splits a header value at separators outside quoted strings in linear time.
|
||||
* Backslash-escaped characters inside quoted strings are treated as literals.
|
||||
*/
|
||||
const splitOutsideQuotes = (value: string, separator: ',' | ';'): string[] => {
|
||||
const parts: string[] = [];
|
||||
let start = 0;
|
||||
let isQuoted = false;
|
||||
const len = value.length;
|
||||
for (let index = 0; index < len; index++) {
|
||||
const character = value[index];
|
||||
if (isQuoted && character === '\\') {
|
||||
// A quoted-pair consumes the next character, including an escaped quote.
|
||||
index++;
|
||||
} else if (character === '"') {
|
||||
isQuoted = !isQuoted;
|
||||
} else if (character === separator && !isQuoted) {
|
||||
parts.push(value.slice(start, index));
|
||||
start = index + 1;
|
||||
}
|
||||
}
|
||||
parts.push(value.slice(start));
|
||||
return parts;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the version carried by an `Accept` header for media type versioning,
|
||||
* e.g. `2` for `application/json;v=2` with the `v=` key.
|
||||
@@ -6,6 +31,10 @@
|
||||
* media range of a list (`text/html, application/json;v=2`); the first match
|
||||
* wins. Returns `undefined` when no parameter starts with the key.
|
||||
*
|
||||
* Quoted parameter values are unescaped, and separators inside them are ignored.
|
||||
* An unterminated quoted string runs to the end of the header value, so a
|
||||
* version that follows its opening quote is not read.
|
||||
*
|
||||
* Duplicated in each platform package so that a platform package never
|
||||
* depends on a newer `@nestjs/core` than the one installed.
|
||||
*/
|
||||
@@ -15,14 +44,18 @@ export function getMediaTypeVersion(
|
||||
): string | undefined {
|
||||
const mediaRanges = ([] as string[])
|
||||
.concat(acceptHeader ?? [])
|
||||
.flatMap(value => value.split(','));
|
||||
.flatMap(value => splitOutsideQuotes(value, ','));
|
||||
|
||||
for (const mediaRange of mediaRanges) {
|
||||
const [, ...parameters] = mediaRange.split(';');
|
||||
const [, ...parameters] = splitOutsideQuotes(mediaRange, ';');
|
||||
for (const parameter of parameters) {
|
||||
const trimmedParameter = parameter.trim();
|
||||
if (trimmedParameter.startsWith(key)) {
|
||||
return trimmedParameter.slice(key.length);
|
||||
const version = trimmedParameter.slice(key.length);
|
||||
if (version.startsWith('"') && version.endsWith('"')) {
|
||||
return version.slice(1, -1).replace(/\\([\s\S])/g, '$1');
|
||||
}
|
||||
return version;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,21 @@ describe('getMediaTypeVersion', () => {
|
||||
['text/html, application/json;v=2, */*;q=0.1', '2'],
|
||||
['application/json;v=1, application/json;v=2', '1'],
|
||||
[['text/html', 'application/json;v=4'], '4'],
|
||||
['application/json;v="2"', '2'],
|
||||
['application/json;v=', ''],
|
||||
['application/json;v=""', ''],
|
||||
['application/json; v="3" ;q=0.8', '3'],
|
||||
['application/json;note="a;v=999";v=2', '2'],
|
||||
['text/html;note="a,application/json;v=999", application/json;v=2', '2'],
|
||||
['application/json;note="a\\";v=999";v=2', '2'],
|
||||
['application/json;note="a\\\\";v=2', '2'],
|
||||
['application/json;v="beta;next,2"', 'beta;next,2'],
|
||||
['application/json;v="\\2"', '2'],
|
||||
['application/json;v="a\\"b\\\\c"', 'a"b\\c'],
|
||||
['application/json;v="1", application/json;v=2', '1'],
|
||||
[['text/html;note="a;v=999"', 'application/json;v="4"'], '4'],
|
||||
[['application/json;note="unterminated', 'application/json;v="4"'], '4'],
|
||||
[['application/json;v="4"', 'application/json;note="unterminated'], '4'],
|
||||
])('should read the version from "%s"', (acceptHeader, expected) => {
|
||||
expect(getMediaTypeVersion(acceptHeader, 'v=')).toBe(expected);
|
||||
});
|
||||
@@ -20,13 +35,46 @@ describe('getMediaTypeVersion', () => {
|
||||
'application/json;q=0.9',
|
||||
'application/json;version=2',
|
||||
'application/v=2',
|
||||
'application/json;note="a;v=2"',
|
||||
'text/html;note="a,application/json;v=2"',
|
||||
])('should return undefined for "%s"', acceptHeader => {
|
||||
expect(getMediaTypeVersion(acceptHeader, 'v=')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should use the configured key', () => {
|
||||
expect(
|
||||
getMediaTypeVersion('application/json;v=1;version=4', 'version='),
|
||||
).toBe('4');
|
||||
it.each([
|
||||
'application/json;v=1;version=4',
|
||||
'application/json;v=1;version="4"',
|
||||
'application/json;note="a;version=999";version="4"',
|
||||
])('should use the configured key in "%s"', acceptHeader => {
|
||||
expect(getMediaTypeVersion(acceptHeader, 'version=')).toBe('4');
|
||||
});
|
||||
|
||||
it.each([
|
||||
'application/json;note="a;v=2',
|
||||
'text/html;note="a,application/json;v=2',
|
||||
'application/json;note="a\\";v=2',
|
||||
'application/json;note="a\\',
|
||||
])(
|
||||
'should not read a version after an unterminated quote: %s',
|
||||
acceptHeader => {
|
||||
expect(getMediaTypeVersion(acceptHeader, 'v=')).toBeUndefined();
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
'application/json;v=2;note="unterminated',
|
||||
'application/json;v=2, text/html;note="unterminated',
|
||||
])('should read a version before an unterminated quote: %s', acceptHeader => {
|
||||
expect(getMediaTypeVersion(acceptHeader, 'v=')).toBe('2');
|
||||
});
|
||||
|
||||
it('should keep an unterminated quoted version as is', () => {
|
||||
expect(getMediaTypeVersion('application/json;v="2', 'v=')).toBe('"2');
|
||||
});
|
||||
|
||||
it('should ignore a long unterminated quoted value with escaped quotes', () => {
|
||||
const acceptHeader =
|
||||
'application/json;note="' + '\\"'.repeat(8000) + ';v=2';
|
||||
expect(getMediaTypeVersion(acceptHeader, 'v=')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user