Skip to content

Commit 3ba4600

Browse files
[O2B-536] Add buildUrl utility (#2750)
* [O2B-536] Add buildUrl utility * Fix lint warning * Prevent security issue * Use URLSearchParams * Revert package-lock change
1 parent 8532349 commit 3ba4600

File tree

11 files changed

+680
-37
lines changed

11 files changed

+680
-37
lines changed

Framework/Backend/http/buildUrl.js

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
/**
2+
* @license
3+
* Copyright CERN and copyright holders of ALICE O2. This software is
4+
* distributed under the terms of the GNU General Public License v3 (GPL
5+
* Version 3), copied verbatim in the file "COPYING".
6+
*
7+
* See http://alice-o2.web.cern.ch/license for full licensing information.
8+
*
9+
* In applying this license CERN does not waive the privileges and immunities
10+
* granted to it by virtue of its status as an Intergovernmental Organization
11+
* or submit itself to any jurisdiction.
12+
*/
13+
14+
/**
15+
* @typedef {string|number|null|boolean} QueryParameterValue
16+
*/
17+
18+
const { parseUrlParameters } = require('./parseUrlParameters.js');
19+
20+
/**
21+
* Build a URL from a base URL (that may already have query parameters) and a list of query parameters
22+
*
23+
* @param {string} baseURL the base URL to which parameters should be added
24+
* @param {object} parameters the query parameters
25+
* @return {string} URL the built URL
26+
*/
27+
exports.buildUrl = (baseURL, parameters) => {
28+
if (!parameters) {
29+
parameters = {};
30+
}
31+
32+
const [url, existingParameters] = baseURL.split('?');
33+
34+
parseUrlParameters(new URLSearchParams(existingParameters), parameters);
35+
36+
const serializedQueryParameters = [];
37+
38+
if (Object.keys(parameters).length === 0) {
39+
return url;
40+
}
41+
42+
/**
43+
* Sanitize a value to be used as URL parameter key or value
44+
*
45+
* @param {string} value the value to sanitize
46+
* @return {string} the sanitized value
47+
*/
48+
const sanitize = (value) => encodeURIComponent(decodeURIComponent(value));
49+
50+
/**
51+
* Stringify a query parameter to be used in a URL and push it in the serialized query parameters list
52+
*
53+
* @param {string} key the parameter's key
54+
* @param {QueryParameterValue} value the parameter's value
55+
* @return {void}
56+
*/
57+
const formatAndPushQueryParameter = (key, value) => {
58+
if (value === undefined) {
59+
return;
60+
}
61+
62+
if (Array.isArray(value)) {
63+
for (const subValue of value) {
64+
formatAndPushQueryParameter(`${key}[]`, subValue);
65+
}
66+
return;
67+
}
68+
69+
if (typeof value === 'object' && value !== null) {
70+
for (const [subKey, subValue] of Object.entries(value)) {
71+
formatAndPushQueryParameter(`${key}[${sanitize(subKey)}]`, subValue);
72+
}
73+
return;
74+
}
75+
76+
serializedQueryParameters.push(`${key}=${sanitize(value)}`);
77+
};
78+
79+
for (const [key, parameter] of Object.entries(parameters)) {
80+
formatAndPushQueryParameter(sanitize(key), parameter);
81+
}
82+
83+
return `${url}?${serializedQueryParameters.join('&')}`;
84+
};
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
/**
2+
* @license
3+
* Copyright CERN and copyright holders of ALICE O2. This software is
4+
* distributed under the terms of the GNU General Public License v3 (GPL
5+
* Version 3), copied verbatim in the file "COPYING".
6+
*
7+
* See http://alice-o2.web.cern.ch/license for full licensing information.
8+
*
9+
* In applying this license CERN does not waive the privileges and immunities
10+
* granted to it by virtue of its status as an Intergovernmental Organization
11+
* or submit itself to any jurisdiction.
12+
*/
13+
14+
/**
15+
* Concatenate the given parameters key path to form proper URL query param key
16+
*
17+
* @param {string[]} parametersKeysPath the keys path to concatenate
18+
* @return {string} the concatenated keys path
19+
*/
20+
const concatenateParametersKeyPath = (parametersKeysPath) => {
21+
if (parametersKeysPath.length === 0) {
22+
return 'no parameters keys';
23+
}
24+
25+
const [mainKey, ...otherKeys] = parametersKeysPath;
26+
return `${mainKey}${otherKeys.map((key) => `[${key}]`).join('')}`;
27+
};
28+
29+
/**
30+
* Error to be used when building parameters tree from keys path
31+
*/
32+
class ParameterBuildingError extends Error {
33+
/**
34+
* Constructor
35+
*
36+
* @param {string} message the global error message
37+
* @param {string[]} parametersKeysPath the parameters keys path where the error occurred
38+
*/
39+
constructor(message, parametersKeysPath) {
40+
super(`${message} - ${concatenateParametersKeyPath(parametersKeysPath)}`);
41+
42+
this._originalMessage = message;
43+
this._parametersKeysPath = parametersKeysPath;
44+
}
45+
46+
/**
47+
* Return the orignal message of the error, without concatenated parameters key path
48+
*
49+
* @return {string} the original message
50+
*/
51+
get originalMessage() {
52+
return this._originalMessage;
53+
}
54+
55+
/**
56+
* Return the parameters keys path of the error
57+
*
58+
* @return {string[]} the parameters keys path
59+
*/
60+
get parametersKeyPath() {
61+
return this._parametersKeysPath;
62+
}
63+
}
64+
65+
/**
66+
* Build a parameter object or array from a parameters keys path
67+
*
68+
* For example, a parameter `key1[key2][]=value` translates to keys path ['key1', 'key2', ''] and will lead to {key1: {key2: [value]}}
69+
*
70+
* @param {object|array} parentParameter the parameter's object or array up to the current key
71+
* @param {array} nestedKeys the keys path to build from the current point
72+
* @param {string} value the value of the parameter represented by the key path
73+
* @return {void}
74+
*/
75+
const buildParameterFromNestedKeys = (parentParameter, nestedKeys, value) => {
76+
const currentKey = nestedKeys.shift();
77+
78+
/*
79+
* Protect against prototype polluting assignment
80+
* https://codeql.github.com/codeql-query-help/javascript/js-prototype-polluting-assignment/
81+
*/
82+
if (currentKey === '__proto__' || currentKey === 'constructor' || currentKey === 'prototype') {
83+
throw new Error(`Unauthorized parameters key ${currentKey}`);
84+
}
85+
86+
if (currentKey === '') {
87+
// Parameter must be an array and the value is a new item in that array
88+
if (!Array.isArray(parentParameter)) {
89+
throw new ParameterBuildingError('Expected node in parameters tree to be an array', [currentKey]);
90+
}
91+
92+
parentParameter.push(value);
93+
} else if (currentKey) {
94+
// Parameter must be an object and the value is a property in that array
95+
if (Array.isArray(parentParameter) || typeof parentParameter !== 'object' || parentParameter === null) {
96+
throw new ParameterBuildingError('Expected node in parameters tree to be an object', [currentKey]);
97+
}
98+
99+
if (nestedKeys.length > 0) {
100+
// We still have nested keys to fill
101+
if (!(currentKey in parentParameter)) {
102+
parentParameter[currentKey] = nestedKeys[0] === '' ? [] : {};
103+
}
104+
105+
try {
106+
buildParameterFromNestedKeys(parentParameter[currentKey], nestedKeys, value);
107+
} catch (e) {
108+
if (e instanceof ParameterBuildingError) {
109+
throw new ParameterBuildingError(e.originalMessage, [currentKey, ...e.parametersKeyPath]);
110+
}
111+
throw e;
112+
}
113+
} else {
114+
if (Array.isArray(parentParameter[currentKey])) {
115+
throw new ParameterBuildingError('Node in parameters tree is an array but no more nested keys', [currentKey]);
116+
} else if (typeof parentParameter[currentKey] === 'object' && parentParameter[currentKey] !== null) {
117+
throw new ParameterBuildingError('Node in parameters tree is an object but no more nested keys', [currentKey]);
118+
}
119+
parentParameter[currentKey] = value;
120+
}
121+
}
122+
};
123+
124+
/**
125+
* Extract the parameters tree from the given URL parameters (any value after the "&" in a URL)
126+
*
127+
* @param {URLSearchParams} urlSearchParams the URL search parameters string
128+
* @param {object} [parameters] the existing parameters tree object (will be modified in place)
129+
* @return {object} the parameter tree
130+
*/
131+
exports.parseUrlParameters = (urlSearchParams, parameters) => {
132+
if (urlSearchParams.size === 0) {
133+
return {};
134+
}
135+
136+
if (!parameters) {
137+
parameters = {};
138+
}
139+
140+
for (const [key, value] of urlSearchParams.entries()) {
141+
const [firstKey, ...dirtyKeys] = key.split('[');
142+
const nestedKeys = [firstKey, ...dirtyKeys.map((key) => key.slice(0, -1))];
143+
144+
buildParameterFromNestedKeys(parameters, nestedKeys, value);
145+
}
146+
147+
return parameters;
148+
};

Framework/Backend/http/server.js

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ const OpenId = require('./openid.js');
2323
const path = require('path');
2424
const url = require('url');
2525
const { LogManager } = require('../log/LogManager');
26+
const { buildUrl } = require('./buildUrl.js');
2627

2728
/**
2829
* HTTPS server verifies identity using OpenID Connect and provides REST API.
@@ -260,8 +261,7 @@ class HttpServer {
260261
query.access = 'admin';
261262
query.token = this.o2TokenService.generateToken(query.personid, query.username, query.name, query.access);
262263

263-
const homeUrlAuthentified = url.format({ pathname: '/', query: query });
264-
return res.redirect(homeUrlAuthentified);
264+
return res.redirect(buildUrl('/', query));
265265
}
266266
return this.ident(req, res, next);
267267
}
@@ -476,7 +476,7 @@ class HttpServer {
476476
// Concatenates with user query
477477
Object.assign(query, userQuery);
478478

479-
res.redirect(url.format({ pathname: '/', query: query }));
479+
res.redirect(buildUrl('/', query));
480480
}).catch((reason) => {
481481
this.logger.errorMessage(`OpenId failed: ${reason}`);
482482
res.status(401).send('OpenId failed');

Framework/Backend/index.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,9 @@ const { Logger } = require('./log/Logger');
4242
const { getWebUiProtoIncludeDir } = require('./protobuf/getWebUiProtoIncludeDir');
4343
const { AliEcsEventMessagesConsumer } = require('./kafka/AliEcsEventMessagesConsumer.js');
4444

45+
const { parseUrlParameters } = require('./http/parseUrlParameters.js');
46+
const { buildUrl } = require('./http/buildUrl.js');
47+
4548
exports.ConsulService = ConsulService;
4649

4750
exports.HttpServer = HttpServer;
@@ -92,3 +95,7 @@ exports.updateAndSendExpressResponseFromNativeError = updateAndSendExpressRespon
9295
exports.getWebUiProtoIncludeDir = getWebUiProtoIncludeDir;
9396

9497
exports.AliEcsEventMessagesConsumer = AliEcsEventMessagesConsumer;
98+
99+
exports.buildUrl = buildUrl;
100+
101+
exports.parseUrlParameters = parseUrlParameters;

0 commit comments

Comments
 (0)