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:
lijiananandKamil Myśliwiec authored and GitHub committed 2026-10-17 14:34:53 +02:00
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();
});
});