Skip to content

Commit

Permalink
[O2B-536] Add buildUrl utility (#2750)
Browse files Browse the repository at this point in the history
* [O2B-536] Add buildUrl utility

* Fix lint warning

* Prevent security issue

* Use URLSearchParams

* Revert package-lock change
  • Loading branch information
martinboulais authored Feb 12, 2025
1 parent 8532349 commit 3ba4600
Show file tree
Hide file tree
Showing 11 changed files with 680 additions and 37 deletions.
84 changes: 84 additions & 0 deletions Framework/Backend/http/buildUrl.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
/**
* @license
* Copyright CERN and copyright holders of ALICE O2. This software is
* distributed under the terms of the GNU General Public License v3 (GPL
* Version 3), copied verbatim in the file "COPYING".
*
* See http://alice-o2.web.cern.ch/license for full licensing information.
*
* In applying this license CERN does not waive the privileges and immunities
* granted to it by virtue of its status as an Intergovernmental Organization
* or submit itself to any jurisdiction.
*/

/**
* @typedef {string|number|null|boolean} QueryParameterValue
*/

const { parseUrlParameters } = require('./parseUrlParameters.js');

/**
* Build a URL from a base URL (that may already have query parameters) and a list of query parameters
*
* @param {string} baseURL the base URL to which parameters should be added
* @param {object} parameters the query parameters
* @return {string} URL the built URL
*/
exports.buildUrl = (baseURL, parameters) => {
if (!parameters) {
parameters = {};
}

const [url, existingParameters] = baseURL.split('?');

parseUrlParameters(new URLSearchParams(existingParameters), parameters);

const serializedQueryParameters = [];

if (Object.keys(parameters).length === 0) {
return url;
}

/**
* Sanitize a value to be used as URL parameter key or value
*
* @param {string} value the value to sanitize
* @return {string} the sanitized value
*/
const sanitize = (value) => encodeURIComponent(decodeURIComponent(value));

/**
* Stringify a query parameter to be used in a URL and push it in the serialized query parameters list
*
* @param {string} key the parameter's key
* @param {QueryParameterValue} value the parameter's value
* @return {void}
*/
const formatAndPushQueryParameter = (key, value) => {
if (value === undefined) {
return;
}

if (Array.isArray(value)) {
for (const subValue of value) {
formatAndPushQueryParameter(`${key}[]`, subValue);
}
return;
}

if (typeof value === 'object' && value !== null) {
for (const [subKey, subValue] of Object.entries(value)) {
formatAndPushQueryParameter(`${key}[${sanitize(subKey)}]`, subValue);
}
return;
}

serializedQueryParameters.push(`${key}=${sanitize(value)}`);
};

for (const [key, parameter] of Object.entries(parameters)) {
formatAndPushQueryParameter(sanitize(key), parameter);
}

return `${url}?${serializedQueryParameters.join('&')}`;
};
148 changes: 148 additions & 0 deletions Framework/Backend/http/parseUrlParameters.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
/**
* @license
* Copyright CERN and copyright holders of ALICE O2. This software is
* distributed under the terms of the GNU General Public License v3 (GPL
* Version 3), copied verbatim in the file "COPYING".
*
* See http://alice-o2.web.cern.ch/license for full licensing information.
*
* In applying this license CERN does not waive the privileges and immunities
* granted to it by virtue of its status as an Intergovernmental Organization
* or submit itself to any jurisdiction.
*/

/**
* Concatenate the given parameters key path to form proper URL query param key
*
* @param {string[]} parametersKeysPath the keys path to concatenate
* @return {string} the concatenated keys path
*/
const concatenateParametersKeyPath = (parametersKeysPath) => {
if (parametersKeysPath.length === 0) {
return 'no parameters keys';
}

const [mainKey, ...otherKeys] = parametersKeysPath;
return `${mainKey}${otherKeys.map((key) => `[${key}]`).join('')}`;
};

/**
* Error to be used when building parameters tree from keys path
*/
class ParameterBuildingError extends Error {
/**
* Constructor
*
* @param {string} message the global error message
* @param {string[]} parametersKeysPath the parameters keys path where the error occurred
*/
constructor(message, parametersKeysPath) {
super(`${message} - ${concatenateParametersKeyPath(parametersKeysPath)}`);

this._originalMessage = message;
this._parametersKeysPath = parametersKeysPath;
}

/**
* Return the orignal message of the error, without concatenated parameters key path
*
* @return {string} the original message
*/
get originalMessage() {
return this._originalMessage;
}

/**
* Return the parameters keys path of the error
*
* @return {string[]} the parameters keys path
*/
get parametersKeyPath() {
return this._parametersKeysPath;
}
}

/**
* Build a parameter object or array from a parameters keys path
*
* For example, a parameter `key1[key2][]=value` translates to keys path ['key1', 'key2', ''] and will lead to {key1: {key2: [value]}}
*
* @param {object|array} parentParameter the parameter's object or array up to the current key
* @param {array} nestedKeys the keys path to build from the current point
* @param {string} value the value of the parameter represented by the key path
* @return {void}
*/
const buildParameterFromNestedKeys = (parentParameter, nestedKeys, value) => {
const currentKey = nestedKeys.shift();

/*
* Protect against prototype polluting assignment
* https://codeql.github.com/codeql-query-help/javascript/js-prototype-polluting-assignment/
*/
if (currentKey === '__proto__' || currentKey === 'constructor' || currentKey === 'prototype') {
throw new Error(`Unauthorized parameters key ${currentKey}`);
}

if (currentKey === '') {
// Parameter must be an array and the value is a new item in that array
if (!Array.isArray(parentParameter)) {
throw new ParameterBuildingError('Expected node in parameters tree to be an array', [currentKey]);
}

parentParameter.push(value);
} else if (currentKey) {
// Parameter must be an object and the value is a property in that array
if (Array.isArray(parentParameter) || typeof parentParameter !== 'object' || parentParameter === null) {
throw new ParameterBuildingError('Expected node in parameters tree to be an object', [currentKey]);
}

if (nestedKeys.length > 0) {
// We still have nested keys to fill
if (!(currentKey in parentParameter)) {
parentParameter[currentKey] = nestedKeys[0] === '' ? [] : {};
}

try {
buildParameterFromNestedKeys(parentParameter[currentKey], nestedKeys, value);
} catch (e) {
if (e instanceof ParameterBuildingError) {
throw new ParameterBuildingError(e.originalMessage, [currentKey, ...e.parametersKeyPath]);
}
throw e;
}
} else {
if (Array.isArray(parentParameter[currentKey])) {
throw new ParameterBuildingError('Node in parameters tree is an array but no more nested keys', [currentKey]);
} else if (typeof parentParameter[currentKey] === 'object' && parentParameter[currentKey] !== null) {
throw new ParameterBuildingError('Node in parameters tree is an object but no more nested keys', [currentKey]);
}
parentParameter[currentKey] = value;
}
}
};

/**
* Extract the parameters tree from the given URL parameters (any value after the "&" in a URL)
*
* @param {URLSearchParams} urlSearchParams the URL search parameters string
* @param {object} [parameters] the existing parameters tree object (will be modified in place)
* @return {object} the parameter tree
*/
exports.parseUrlParameters = (urlSearchParams, parameters) => {
if (urlSearchParams.size === 0) {
return {};
}

if (!parameters) {
parameters = {};
}

for (const [key, value] of urlSearchParams.entries()) {
const [firstKey, ...dirtyKeys] = key.split('[');
const nestedKeys = [firstKey, ...dirtyKeys.map((key) => key.slice(0, -1))];

buildParameterFromNestedKeys(parameters, nestedKeys, value);
}

return parameters;
};
6 changes: 3 additions & 3 deletions Framework/Backend/http/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ const OpenId = require('./openid.js');
const path = require('path');
const url = require('url');
const { LogManager } = require('../log/LogManager');
const { buildUrl } = require('./buildUrl.js');

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

const homeUrlAuthentified = url.format({ pathname: '/', query: query });
return res.redirect(homeUrlAuthentified);
return res.redirect(buildUrl('/', query));
}
return this.ident(req, res, next);
}
Expand Down Expand Up @@ -476,7 +476,7 @@ class HttpServer {
// Concatenates with user query
Object.assign(query, userQuery);

res.redirect(url.format({ pathname: '/', query: query }));
res.redirect(buildUrl('/', query));
}).catch((reason) => {
this.logger.errorMessage(`OpenId failed: ${reason}`);
res.status(401).send('OpenId failed');
Expand Down
7 changes: 7 additions & 0 deletions Framework/Backend/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,9 @@ const { Logger } = require('./log/Logger');
const { getWebUiProtoIncludeDir } = require('./protobuf/getWebUiProtoIncludeDir');
const { AliEcsEventMessagesConsumer } = require('./kafka/AliEcsEventMessagesConsumer.js');

const { parseUrlParameters } = require('./http/parseUrlParameters.js');
const { buildUrl } = require('./http/buildUrl.js');

exports.ConsulService = ConsulService;

exports.HttpServer = HttpServer;
Expand Down Expand Up @@ -92,3 +95,7 @@ exports.updateAndSendExpressResponseFromNativeError = updateAndSendExpressRespon
exports.getWebUiProtoIncludeDir = getWebUiProtoIncludeDir;

exports.AliEcsEventMessagesConsumer = AliEcsEventMessagesConsumer;

exports.buildUrl = buildUrl;

exports.parseUrlParameters = parseUrlParameters;
Loading

0 comments on commit 3ba4600

Please sign in to comment.