these too
This commit is contained in:
parent
d4dde21ad2
commit
90c47afe17
11 changed files with 1045 additions and 0 deletions
58
screenshot/src/validation.test.ts
Normal file
58
screenshot/src/validation.test.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { buildScreenshotRequest, ValidationError } from './validation.js';
|
||||
|
||||
test('buildScreenshotRequest accepts supported screenshot parameters', () => {
|
||||
const result = buildScreenshotRequest({
|
||||
lat: '51.5074',
|
||||
lon: '-0.1278',
|
||||
zoom: '12.5',
|
||||
tab: 'properties',
|
||||
og: '1',
|
||||
path: '/invite/abc123',
|
||||
filter: ['Last known price:100000:500000', 'Total floor area (sqm):50:150'],
|
||||
poi: 'supermarket',
|
||||
tt: 'transit:kings-cross:Kings Cross:b:0:30',
|
||||
});
|
||||
|
||||
assert.equal(result.pagePath, '/invite/abc123');
|
||||
assert.equal(result.qs.get('lat'), '51.5074');
|
||||
assert.equal(result.qs.get('lon'), '-0.1278');
|
||||
assert.equal(result.qs.get('zoom'), '12.5');
|
||||
assert.equal(result.qs.get('tab'), 'properties');
|
||||
assert.deepEqual(result.qs.getAll('filter'), [
|
||||
'Last known price:100000:500000',
|
||||
'Total floor area (sqm):50:150',
|
||||
]);
|
||||
});
|
||||
|
||||
test('buildScreenshotRequest rejects invalid numeric values', () => {
|
||||
assert.throws(
|
||||
() => buildScreenshotRequest({ lat: '91', lon: '-0.1', zoom: '12' }),
|
||||
ValidationError,
|
||||
);
|
||||
assert.throws(
|
||||
() => buildScreenshotRequest({ lat: '51abc', lon: '-0.1', zoom: '12' }),
|
||||
ValidationError,
|
||||
);
|
||||
});
|
||||
|
||||
test('buildScreenshotRequest rejects unsafe paths', () => {
|
||||
assert.throws(() => buildScreenshotRequest({ path: '//example.com' }), ValidationError);
|
||||
assert.throws(() => buildScreenshotRequest({ path: '/../../etc/passwd' }), ValidationError);
|
||||
});
|
||||
|
||||
test('buildScreenshotRequest limits repeated parameters', () => {
|
||||
assert.throws(
|
||||
() =>
|
||||
buildScreenshotRequest({
|
||||
filter: Array.from({ length: 41 }, (_, index) => `Feature ${index}:0:1`),
|
||||
}),
|
||||
ValidationError,
|
||||
);
|
||||
});
|
||||
|
||||
test('buildScreenshotRequest rejects control characters', () => {
|
||||
assert.throws(() => buildScreenshotRequest({ filter: 'Feature:\u0000:1' }), ValidationError);
|
||||
});
|
||||
114
screenshot/src/validation.ts
Normal file
114
screenshot/src/validation.ts
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
export class ValidationError extends Error {
|
||||
status = 400;
|
||||
}
|
||||
|
||||
export interface ValidatedScreenshotRequest {
|
||||
pagePath: string;
|
||||
qs: URLSearchParams;
|
||||
}
|
||||
|
||||
const MAX_REPEATED_PARAMS = 40;
|
||||
const MAX_VALUE_LENGTH = 500;
|
||||
const NUMERIC_RE = /^-?(?:\d+|\d*\.\d+)$/;
|
||||
const PATH_RE = /^\/(?:invite\/[A-Za-z0-9]{1,20})?$/;
|
||||
const SAFE_VALUE_RE = /^[^\u0000-\u001f\u007f]+$/;
|
||||
const REPEATED_KEYS = ['filter', 'poi', 'tt'] as const;
|
||||
|
||||
type Query = Record<string, unknown>;
|
||||
|
||||
function validationError(message: string): never {
|
||||
throw new ValidationError(message);
|
||||
}
|
||||
|
||||
function firstString(query: Query, key: string): string | undefined {
|
||||
const value = query[key];
|
||||
if (value == null) return undefined;
|
||||
if (Array.isArray(value)) {
|
||||
validationError(`${key} must not be repeated`);
|
||||
}
|
||||
if (typeof value !== 'string') {
|
||||
validationError(`${key} must be a string`);
|
||||
}
|
||||
return value || undefined;
|
||||
}
|
||||
|
||||
function repeatedStrings(query: Query, key: string): string[] {
|
||||
const value = query[key];
|
||||
if (value == null) return [];
|
||||
const values = Array.isArray(value) ? value : [value];
|
||||
if (values.length > MAX_REPEATED_PARAMS) {
|
||||
validationError(`${key} has too many values`);
|
||||
}
|
||||
return values.filter((item): item is string => {
|
||||
if (typeof item !== 'string') {
|
||||
validationError(`${key} values must be strings`);
|
||||
}
|
||||
return item.length > 0;
|
||||
});
|
||||
}
|
||||
|
||||
function assertSafeValue(key: string, value: string): void {
|
||||
if (value.length > MAX_VALUE_LENGTH) {
|
||||
validationError(`${key} is too long`);
|
||||
}
|
||||
if (!SAFE_VALUE_RE.test(value)) {
|
||||
validationError(`${key} contains invalid characters`);
|
||||
}
|
||||
}
|
||||
|
||||
function appendBoundedNumber(
|
||||
qs: URLSearchParams,
|
||||
query: Query,
|
||||
key: string,
|
||||
min: number,
|
||||
max: number,
|
||||
): void {
|
||||
const value = firstString(query, key);
|
||||
if (value == null) return;
|
||||
if (value.length > 40 || !NUMERIC_RE.test(value)) {
|
||||
validationError(`${key} must be a number`);
|
||||
}
|
||||
const numeric = Number(value);
|
||||
if (!Number.isFinite(numeric) || numeric < min || numeric > max) {
|
||||
validationError(`${key} is out of range`);
|
||||
}
|
||||
qs.set(key, value);
|
||||
}
|
||||
|
||||
export function buildScreenshotRequest(query: Query): ValidatedScreenshotRequest {
|
||||
const qs = new URLSearchParams();
|
||||
|
||||
appendBoundedNumber(qs, query, 'lat', -90, 90);
|
||||
appendBoundedNumber(qs, query, 'lon', -180, 180);
|
||||
appendBoundedNumber(qs, query, 'zoom', 0, 22);
|
||||
|
||||
const tab = firstString(query, 'tab');
|
||||
if (tab != null) {
|
||||
if (tab !== 'area' && tab !== 'properties') {
|
||||
validationError('tab is invalid');
|
||||
}
|
||||
qs.set('tab', tab);
|
||||
}
|
||||
|
||||
const og = firstString(query, 'og');
|
||||
if (og != null) {
|
||||
if (og !== '1') {
|
||||
validationError('og is invalid');
|
||||
}
|
||||
qs.set('og', og);
|
||||
}
|
||||
|
||||
for (const key of REPEATED_KEYS) {
|
||||
for (const value of repeatedStrings(query, key)) {
|
||||
assertSafeValue(key, value);
|
||||
qs.append(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
const pagePath = firstString(query, 'path') ?? '/';
|
||||
if (!PATH_RE.test(pagePath)) {
|
||||
validationError('path is invalid');
|
||||
}
|
||||
|
||||
return { pagePath, qs };
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue