Skip to content

Commit 04f4cf4

Browse files
author
kauanAfonso
committed
feat(rest): make qs arrayLimit configurable for query parsing
Signed-off-by: kauanAfonso <kauan.afonso@ibm.com>
1 parent a26fc0a commit 04f4cf4

2 files changed

Lines changed: 171 additions & 0 deletions

File tree

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
// Copyright IBM Corp. and LoopBack contributors 2024. All Rights Reserved.
2+
// Node module: @loopback/rest
3+
// This file is licensed under the MIT License.
4+
// License text available at https://opensource.org/licenses/MIT
5+
6+
import {
7+
Client,
8+
createRestAppClient,
9+
expect,
10+
givenHttpServerConfig,
11+
} from '@loopback/testlab';
12+
import {get, param, RestApplication} from '../../..';
13+
14+
describe('Query parameter array limit', () => {
15+
let app: RestApplication;
16+
let client: Client;
17+
18+
afterEach(async () => {
19+
if (app) await app.stop();
20+
});
21+
22+
context('with default arrayLimit (20)', () => {
23+
beforeEach(async () => {
24+
app = givenApplication();
25+
await app.start();
26+
client = createRestAppClient(app);
27+
});
28+
29+
it('parses arrays with 20 items correctly', async () => {
30+
const ids = Array.from({length: 20}, (_, i) => (i + 1).toString());
31+
const query = ids.map(id => `ids=${id}`).join('&');
32+
33+
const response = await client.get(`/test?${query}`).expect(200);
34+
expect(response.body.ids).to.eql(ids);
35+
});
36+
37+
it('converts arrays with 21+ items to objects (qs default behavior)', async () => {
38+
const ids = Array.from({length: 21}, (_, i) => (i + 1).toString());
39+
const query = ids.map(id => `ids=${id}`).join('&');
40+
41+
const response = await client.get(`/test?${query}`).expect(200);
42+
43+
expect(response.body.ids).to.be.Object();
44+
expect(Array.isArray(response.body.ids)).to.be.false();
45+
expect(response.body.ids).to.have.property('0', '1');
46+
expect(response.body.ids).to.have.property('20', '21');
47+
});
48+
});
49+
50+
context('with custom arrayLimit (100)', () => {
51+
beforeEach(async () => {
52+
app = givenApplication({
53+
rest: {
54+
queryParser: {
55+
arrayLimit: 100,
56+
},
57+
},
58+
});
59+
await app.start();
60+
client = createRestAppClient(app);
61+
});
62+
63+
it('parses arrays with 21 items correctly', async () => {
64+
const ids = Array.from({length: 21}, (_, i) => (i + 1).toString());
65+
const query = ids.map(id => `ids=${id}`).join('&');
66+
67+
const response = await client.get(`/test?${query}`).expect(200);
68+
expect(response.body.ids).to.eql(ids);
69+
expect(Array.isArray(response.body.ids)).to.be.true();
70+
});
71+
72+
it('parses arrays with 50 items correctly', async () => {
73+
const ids = Array.from({length: 50}, (_, i) => (i + 1).toString());
74+
const query = ids.map(id => `ids=${id}`).join('&');
75+
76+
const response = await client.get(`/test?${query}`).expect(200);
77+
expect(response.body.ids).to.eql(ids);
78+
expect(Array.isArray(response.body.ids)).to.be.true();
79+
});
80+
81+
it('parses arrays with 100 items correctly', async () => {
82+
const ids = Array.from({length: 100}, (_, i) => (i + 1).toString());
83+
const query = ids.map(id => `ids=${id}`).join('&');
84+
85+
const response = await client.get(`/test?${query}`).expect(200);
86+
expect(response.body.ids).to.eql(ids);
87+
expect(Array.isArray(response.body.ids)).to.be.true();
88+
});
89+
90+
it('converts arrays with 101+ items to objects (exceeds limit)', async () => {
91+
const ids = Array.from({length: 101}, (_, i) => (i + 1).toString());
92+
const query = ids.map(id => `ids=${id}`).join('&');
93+
94+
const response = await client.get(`/test?${query}`).expect(200);
95+
96+
expect(response.body.ids).to.be.Object();
97+
expect(Array.isArray(response.body.ids)).to.be.false();
98+
expect(response.body.ids).to.have.property('0', '1');
99+
expect(response.body.ids).to.have.property('100', '101');
100+
});
101+
});
102+
103+
context('with arrayLimit set to 1000', () => {
104+
beforeEach(async () => {
105+
app = givenApplication({
106+
rest: {
107+
queryParser: {
108+
arrayLimit: 1000,
109+
},
110+
},
111+
});
112+
await app.start();
113+
client = createRestAppClient(app);
114+
});
115+
116+
it('parses arrays with 500 items correctly', async () => {
117+
const ids = Array.from({length: 500}, (_, i) => (i + 1).toString());
118+
const query = ids.map(id => `ids=${id}`).join('&');
119+
120+
const response = await client.get(`/test?${query}`).expect(200);
121+
expect(response.body.ids).to.eql(ids);
122+
expect(Array.isArray(response.body.ids)).to.be.true();
123+
});
124+
});
125+
126+
function givenApplication(config?: object) {
127+
const testApp = new RestApplication({
128+
...givenHttpServerConfig(),
129+
...config,
130+
});
131+
132+
class TestController {
133+
@get('/test')
134+
test(
135+
@param.array('ids', 'query', {type: 'string'})
136+
ids?: string[],
137+
): object {
138+
return {ids};
139+
}
140+
}
141+
142+
testApp.controller(TestController);
143+
return testApp;
144+
}
145+
});

packages/rest/src/rest.server.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ import {IncomingMessage, ServerResponse} from 'http';
4040
import {ServerOptions} from 'https';
4141
import {dump} from 'js-yaml';
4242
import {cloneDeep} from 'lodash';
43+
import qs from 'qs';
4344
import {ServeStaticOptions} from 'serve-static';
4445
import {writeErrorToResponse} from 'strong-error-handler';
4546
import {BodyParser, REQUEST_BODY_PARSER_TAG} from './body-parsers';
@@ -327,6 +328,17 @@ export class RestServer
327328
if (this.config.router && typeof this.config.router.strict === 'boolean') {
328329
this._expressApp.set('strict routing', this.config.router.strict);
329330
}
331+
332+
// Configure query parser with custom arrayLimit if provided
333+
const arrayLimit = this.config.queryParser?.arrayLimit ?? 20;
334+
this._expressApp.set('query parser', (str: string) => {
335+
return qs.parse(str, {
336+
arrayLimit,
337+
// Use extended mode (same as body-parser urlencoded)
338+
allowPrototypes: false,
339+
depth: 20,
340+
});
341+
});
330342
}
331343

332344
/**
@@ -1180,6 +1192,20 @@ export interface RestServerResolvedOptions {
11801192
openApiSpec: OpenApiSpecOptions;
11811193
apiExplorer: ApiExplorerOptions;
11821194
requestBodyParser?: RequestBodyParserOptions;
1195+
/**
1196+
* Query string parser options
1197+
*/
1198+
queryParser?: {
1199+
/**
1200+
* Maximum number of array elements to parse in query parameters.
1201+
* The qs library defaults to 20 to prevent DoS attacks with large array indices.
1202+
* Set this to a higher value if your API needs to handle more than 20 array items.
1203+
*
1204+
* @default 20
1205+
* @see https://github.com/ljharb/qs#parsing-arrays
1206+
*/
1207+
arrayLimit?: number;
1208+
};
11831209
sequence?: Constructor<SequenceHandler>;
11841210
// eslint-disable-next-line @typescript-eslint/no-explicit-any
11851211
expressSettings: {[name: string]: any};

0 commit comments

Comments
 (0)