Files
puter/src/backend/util/validation.js
T

221 lines
6.8 KiB
JavaScript

/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { HttpError } from '../core/http/HttpError.js';
/**
* Small input validation utilities for driver methods. Throws HttpError(400,
* ...) on failure. Returns the value on success.
*/
/**
* Real web origins. Anything outside this list is an XSS/SSRF primitive once
* the value is consumed as `iframe.src`, `window.location`, a server-side
* fetch, etc. — `new URL()` alone happily parses `javascript:alert(1)`,
* `data:text/html,…`, `file:///etc/passwd`, and `vbscript:`.
*/
export const WEB_PROTOCOLS = Object.freeze(['http:', 'https:']);
/**
* Web origins plus the browser-extension schemes, for app origins and
* `index_url` values. Shared by AuthService.#originFromUrl,
* AppStore.createFromOrigin and AppDriver.#validateInput.
*
* Only Chrome derives the extension host from the signing key. Firefox and
* Safari generate it per install, so one extension is a distinct origin — and
* therefore a distinct app row — for each user who installs it.
*/
export const WEB_AND_EXTENSION_PROTOCOLS = Object.freeze([
...WEB_PROTOCOLS,
'chrome-extension:',
'moz-extension:',
'safari-extension:',
'safari-web-extension:',
]);
export function validateString(
value,
{ key, maxLen, regex, required = true, allowEmpty = false } = {},
) {
if (value === undefined || value === null) {
if (required)
throw new HttpError(400, `Missing \`${key}\``, {
legacyCode: 'bad_request',
});
return value;
}
if (typeof value !== 'string') {
throw new HttpError(400, `\`${key}\` must be a string`, {
legacyCode: 'bad_request',
});
}
if (!allowEmpty && value.length === 0) {
throw new HttpError(400, `\`${key}\` must not be empty`, {
legacyCode: 'bad_request',
});
}
if (maxLen && value.length > maxLen) {
throw new HttpError(
400,
`\`${key}\` must be at most ${maxLen} characters`,
{ legacyCode: 'bad_request' },
);
}
if (regex && !regex.test(value)) {
throw new HttpError(400, `\`${key}\` has an invalid format`, {
legacyCode: 'bad_request',
});
}
return value;
}
export function validateUrl(
value,
{
key,
maxLen = 3000,
required = true,
// http(s) by default (see WEB_PROTOCOLS); callers that need something
// exotic must opt in explicitly.
protocols = WEB_PROTOCOLS,
} = {},
) {
if (value === undefined || value === null) {
if (required)
throw new HttpError(400, `Missing \`${key}\``, {
legacyCode: 'bad_request',
});
return value;
}
validateString(value, { key, maxLen, required });
let parsed;
try {
parsed = new URL(value);
} catch {
throw new HttpError(400, `\`${key}\` must be a valid URL`, {
legacyCode: 'bad_request',
});
}
if (!protocols.includes(parsed.protocol)) {
throw new HttpError(
400,
`\`${key}\` must use one of the following protocols: ${protocols.join(', ')}`,
{ legacyCode: 'bad_request' },
);
}
// Only "special" schemes (http:, https:, ws:, …) require an authority.
// `new URL()` accepts `chrome-extension:` and `extension:javascript:alert(1)`
// with an empty host, which slips past every host-based guard downstream.
if (!parsed.hostname) {
throw new HttpError(400, `\`${key}\` must include a host`, {
legacyCode: 'bad_request',
});
}
return value;
}
export function validateBool(value, { key, required = false } = {}) {
if (value === undefined || value === null) {
if (required)
throw new HttpError(400, `Missing \`${key}\``, {
legacyCode: 'bad_request',
});
return value;
}
return Boolean(value);
}
export function validateJsonObject(
value,
{ key, required = false, maxBytes } = {},
) {
if (value === undefined || value === null) {
if (required)
throw new HttpError(400, `Missing \`${key}\``, {
legacyCode: 'bad_request',
});
return value;
}
if (typeof value === 'string') {
try {
value = JSON.parse(value);
} catch {
throw new HttpError(400, `\`${key}\` must be valid JSON`, {
legacyCode: 'bad_request',
});
}
}
if (typeof value !== 'object' || Array.isArray(value)) {
throw new HttpError(400, `\`${key}\` must be an object`, {
legacyCode: 'bad_request',
});
}
if (
maxBytes &&
Buffer.byteLength(JSON.stringify(value), 'utf8') > maxBytes
) {
throw new HttpError(
400,
`\`${key}\` must be at most ${maxBytes} bytes as JSON`,
{ legacyCode: 'bad_request' },
);
}
return value;
}
export function validateArrayOfStrings(
value,
{ key, required = false, maxItems, maxItemLen } = {},
) {
if (value === undefined || value === null) {
if (required)
throw new HttpError(400, `Missing \`${key}\``, {
legacyCode: 'bad_request',
});
return value;
}
if (!Array.isArray(value)) {
throw new HttpError(400, `\`${key}\` must be an array`, {
legacyCode: 'bad_request',
});
}
if (maxItems && value.length > maxItems) {
throw new HttpError(
400,
`\`${key}\` must have at most ${maxItems} entries`,
{ legacyCode: 'bad_request' },
);
}
for (let i = 0; i < value.length; i++) {
if (typeof value[i] !== 'string') {
throw new HttpError(400, `\`${key}[${i}]\` must be a string`, {
legacyCode: 'bad_request',
});
}
if (maxItemLen && value[i].length > maxItemLen) {
throw new HttpError(
400,
`\`${key}[${i}]\` must be at most ${maxItemLen} characters`,
{ legacyCode: 'bad_request' },
);
}
}
return value;
}