Files
MyIP/tests/guards.test.js
jason5ng32andClaude Opus 5.5 e766bd84ce Fix(api): make guard query rewrites survive Express 5
Express 5 exposes req.query through a getter that re-parses the URL on
every read, so the guards' in-place normalization was silently dropped:
?type=caa reached dns-resolver lowercase, ?asn=AS13335 reached the ASN
handlers unparsed, ?q=as13335 missed whois's AS<n> branch. Guards now pin
the parsed query as an own property before rewriting it, and a regression
block exercises them on a real Express request.

Fixes #521

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 20:24:56 +08:00

455 lines
18 KiB
JavaScript

import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import express from 'express';
import { requireReferer, requirePublicIP, requireValidPrefix, requireValidDomain, requireValidProviderId, requireValidRecordType, requireValidReportId, requireValidCountry, requireValidASN, normalizeAsnQuery } from '../server/guards.js';
// Minimal (req, res, next) stubs — just enough to observe what the
// middleware does.
function makeReq({ referer, query = {} } = {}) {
return {
headers: referer ? { referer } : {},
query,
};
}
function makeRes() {
const res = {
statusCode: null,
body: null,
status(code) { this.statusCode = code; return this; },
json(payload) { this.body = payload; return this; },
};
return res;
}
describe('requireReferer', () => {
it('calls next() when the referer is on the allow-list (localhost)', () => {
let nextCalled = false;
const req = makeReq({ referer: 'http://localhost/foo' });
requireReferer(req, makeRes(), () => { nextCalled = true; });
assert.equal(nextCalled, true);
});
it('returns 403 "What are you doing?" when no referer header', () => {
const res = makeRes();
let nextCalled = false;
requireReferer(makeReq(), res, () => { nextCalled = true; });
assert.equal(res.statusCode, 403);
assert.equal(res.body.error, 'What are you doing?');
assert.equal(nextCalled, false);
});
it('returns 403 "Access denied" when referer is not on the allow-list', () => {
const res = makeRes();
let nextCalled = false;
requireReferer(makeReq({ referer: 'http://evil.example/x' }), res, () => { nextCalled = true; });
assert.equal(res.statusCode, 403);
assert.equal(res.body.error, 'Access denied');
assert.equal(nextCalled, false);
});
});
describe('requirePublicIP', () => {
const guard = requirePublicIP();
it('calls next() when ip query param is a valid IPv4', () => {
let nextCalled = false;
guard(makeReq({ query: { ip: '1.1.1.1' } }), makeRes(), () => { nextCalled = true; });
assert.equal(nextCalled, true);
});
it('calls next() when ip query param is a valid IPv6', () => {
let nextCalled = false;
guard(makeReq({ query: { ip: '2001:4860:4860::8888' } }), makeRes(), () => { nextCalled = true; });
assert.equal(nextCalled, true);
});
it('returns 400 "No IP address provided" when ip is missing', () => {
const res = makeRes();
let nextCalled = false;
guard(makeReq({ query: {} }), res, () => { nextCalled = true; });
assert.equal(res.statusCode, 400);
assert.equal(res.body.error, 'No IP address provided');
assert.equal(nextCalled, false);
});
it('returns 400 "Invalid IP address" when ip is malformed', () => {
const res = makeRes();
let nextCalled = false;
guard(makeReq({ query: { ip: 'nope' } }), res, () => { nextCalled = true; });
assert.equal(res.statusCode, 400);
assert.equal(res.body.error, 'Invalid IP address');
assert.equal(nextCalled, false);
});
it('rejects leading-zero IPv4 input before reaching an upstream handler', () => {
const res = makeRes();
let nextCalled = false;
guard(makeReq({ query: { ip: '8.8.8.08' } }), res, () => { nextCalled = true; });
assert.equal(res.statusCode, 400);
assert.equal(res.body.error, 'Invalid IP address');
assert.equal(nextCalled, false);
});
// Reserved space is well-formed but unanswerable by any upstream, so it
// gets its own message — rejected before the handler, never fetched.
for (const ip of ['10.0.0.1', '192.168.1.1', '127.0.0.1', '198.18.0.2', 'fd00::1', '::1']) {
it(`returns 400 "Not a public IP address" for ${ip}`, () => {
const res = makeRes();
let nextCalled = false;
guard(makeReq({ query: { ip } }), res, () => { nextCalled = true; });
assert.equal(res.statusCode, 400);
assert.equal(res.body.error, 'Not a public IP address');
assert.equal(nextCalled, false);
});
}
it('honors a custom param name', () => {
const custom = requirePublicIP('target');
let nextCalled = false;
custom(makeReq({ query: { target: '8.8.8.8' } }), makeRes(), () => { nextCalled = true; });
assert.equal(nextCalled, true);
});
});
describe('requireValidPrefix', () => {
const guard = requireValidPrefix();
it('calls next() when prefix is a valid IPv4 CIDR', () => {
let nextCalled = false;
guard(makeReq({ query: { prefix: '8.8.8.0/24' } }), makeRes(), () => { nextCalled = true; });
assert.equal(nextCalled, true);
});
it('calls next() when prefix is a valid IPv6 CIDR', () => {
let nextCalled = false;
guard(makeReq({ query: { prefix: '2001:4860:4860::/48' } }), makeRes(), () => { nextCalled = true; });
assert.equal(nextCalled, true);
});
it('returns 400 "No prefix provided" when prefix is missing', () => {
const res = makeRes();
let nextCalled = false;
guard(makeReq({ query: {} }), res, () => { nextCalled = true; });
assert.equal(res.statusCode, 400);
assert.equal(res.body.error, 'No prefix provided');
assert.equal(nextCalled, false);
});
it('returns 400 "Invalid prefix" when prefix is malformed', () => {
const res = makeRes();
let nextCalled = false;
guard(makeReq({ query: { prefix: '8.8.8.0' } }), res, () => { nextCalled = true; });
assert.equal(res.statusCode, 400);
assert.equal(res.body.error, 'Invalid prefix');
assert.equal(nextCalled, false);
});
it('returns 400 when prefix length is out of v4 range', () => {
const res = makeRes();
guard(makeReq({ query: { prefix: '8.8.8.0/33' } }), res, () => {});
assert.equal(res.statusCode, 400);
});
});
describe('requireValidCountry', () => {
const guard = requireValidCountry();
it('calls next() and uppercases the code in place', () => {
let nextCalled = false;
const req = makeReq({ query: { country: 'iq' } });
guard(req, makeRes(), () => { nextCalled = true; });
assert.equal(nextCalled, true);
assert.equal(req.query.country, 'IQ');
});
it('returns 400 "No country provided" when country is missing', () => {
const res = makeRes();
let nextCalled = false;
guard(makeReq({ query: {} }), res, () => { nextCalled = true; });
assert.equal(res.statusCode, 400);
assert.equal(res.body.error, 'No country provided');
assert.equal(nextCalled, false);
});
it('returns 400 "Invalid country" for anything but two letters', () => {
for (const bad of ['USA', 'U', 'U1', 'us,de', 'ÜS']) {
const res = makeRes();
guard(makeReq({ query: { country: bad } }), res, () => {});
assert.equal(res.statusCode, 400, bad);
assert.equal(res.body.error, 'Invalid country', bad);
}
});
});
describe('requireValidProviderId', () => {
const guard = requireValidProviderId();
it('calls next() for a whitelisted provider id', () => {
let nextCalled = false;
guard(makeReq({ query: { id: 'claude' } }), makeRes(), () => { nextCalled = true; });
assert.equal(nextCalled, true);
});
it('returns 400 "No provider id provided" when id is missing', () => {
const res = makeRes();
let nextCalled = false;
guard(makeReq({ query: {} }), res, () => { nextCalled = true; });
assert.equal(res.statusCode, 400);
assert.equal(res.body.error, 'No provider id provided');
assert.equal(nextCalled, false);
});
it('returns 400 "Invalid provider id" for an unknown id', () => {
const res = makeRes();
let nextCalled = false;
guard(makeReq({ query: { id: 'not-a-provider' } }), res, () => { nextCalled = true; });
assert.equal(res.statusCode, 400);
assert.equal(res.body.error, 'Invalid provider id');
assert.equal(nextCalled, false);
});
});
describe('requireValidReportId', () => {
const guard = requireValidReportId();
// A report id is 16 random bytes as base64url — always exactly 22 chars.
const validId = 'AbC123-_xyz987654321ab';
// This guard reads req.params (route param), not req.query.
const makeParamsReq = (params) => ({ headers: {}, params });
it('calls next() for a well-formed 22-char base64url id', () => {
let nextCalled = false;
guard(makeParamsReq({ id: validId }), makeRes(), () => { nextCalled = true; });
assert.equal(nextCalled, true);
});
it('returns 400 when the id is missing', () => {
const res = makeRes();
let nextCalled = false;
guard(makeParamsReq({}), res, () => { nextCalled = true; });
assert.equal(res.statusCode, 400);
assert.equal(res.body.error, 'Invalid report id');
assert.equal(nextCalled, false);
});
it('returns 400 for wrong length or non-base64url characters', () => {
for (const bad of ['short', 'a'.repeat(23), 'a'.repeat(21), `${'a'.repeat(21)}+`, `${'a'.repeat(21)}/`]) {
const res = makeRes();
let nextCalled = false;
guard(makeParamsReq({ id: bad }), res, () => { nextCalled = true; });
assert.equal(res.statusCode, 400, `should reject "${bad}"`);
assert.equal(nextCalled, false);
}
});
});
describe('requireValidRecordType', () => {
const guard = requireValidRecordType();
it('calls next() for a supported type', () => {
let nextCalled = false;
guard(makeReq({ query: { type: 'CAA' } }), makeRes(), () => { nextCalled = true; });
assert.equal(nextCalled, true);
});
it('uppercases the type in place so the handler switch matches', () => {
const req = makeReq({ query: { type: 'caa' } });
guard(req, makeRes(), () => {});
assert.equal(req.query.type, 'CAA');
});
it('rejects a missing type', () => {
const res = makeRes();
let nextCalled = false;
guard(makeReq({ query: {} }), res, () => { nextCalled = true; });
assert.equal(nextCalled, false);
assert.equal(res.statusCode, 400);
assert.deepEqual(res.body, { error: 'No record type provided' });
});
it('rejects a type the resolver does not handle, so DoH never forwards it', () => {
for (const type of ['ANY', 'DNSKEY', 'HTTPS', 'PTR', '../etc']) {
const res = makeRes();
let nextCalled = false;
guard(makeReq({ query: { type } }), res, () => { nextCalled = true; });
assert.equal(nextCalled, false, type);
assert.equal(res.statusCode, 400);
assert.deepEqual(res.body, { error: 'Invalid record type' });
}
});
});
describe('requireValidDomain', () => {
const guard = requireValidDomain();
it('calls next() for a valid domain', () => {
let nextCalled = false;
guard(makeReq({ query: { domain: 'example.com' } }), makeRes(), () => { nextCalled = true; });
assert.equal(nextCalled, true);
});
it('lowercases the domain in place (canonical cache key)', () => {
const req = makeReq({ query: { domain: 'WWW.Example.COM' } });
let nextCalled = false;
guard(req, makeRes(), () => { nextCalled = true; });
assert.equal(nextCalled, true);
assert.equal(req.query.domain, 'www.example.com');
});
it('supports a custom query parameter name', () => {
const hostnameGuard = requireValidDomain('hostname');
const req = makeReq({ query: { hostname: 'WWW.Example.COM' } });
let nextCalled = false;
hostnameGuard(req, makeRes(), () => { nextCalled = true; });
assert.equal(nextCalled, true);
assert.equal(req.query.hostname, 'www.example.com');
});
it('returns 400 when the domain is missing', () => {
const res = makeRes();
let nextCalled = false;
guard(makeReq({ query: {} }), res, () => { nextCalled = true; });
assert.equal(res.statusCode, 400);
assert.equal(res.body.error, 'No domain provided');
assert.equal(nextCalled, false);
});
it('returns 400 for non-domain inputs', () => {
for (const bad of ['not a domain', 'nodot', '1.2.3.4.5.', 'http://example.com', 'exa_mple.com']) {
const res = makeRes();
let nextCalled = false;
guard(makeReq({ query: { domain: bad } }), res, () => { nextCalled = true; });
assert.equal(res.statusCode, 400, `should reject "${bad}"`);
assert.equal(nextCalled, false);
}
});
});
describe('requireValidASN', () => {
const guard = requireValidASN();
it('canonicalizes to the bare number string in place', () => {
for (const [input, expected] of [['13335', '13335'], ['AS13335', '13335'], ['as13335', '13335'],
['AS013335', '13335'], ['0013335', '13335'], [' 13335 ', '13335'], ['1', '1'],
['4294967295', '4294967295'], ['AS4294967295', '4294967295']]) {
const req = makeReq({ query: { asn: input } });
let nextCalled = false;
guard(req, makeRes(), () => { nextCalled = true; });
assert.equal(nextCalled, true, input);
assert.equal(req.query.asn, expected, input);
}
});
it('returns 400 "No ASN provided" when the param is missing', () => {
const res = makeRes();
let nextCalled = false;
guard(makeReq(), res, () => { nextCalled = true; });
assert.equal(res.statusCode, 400);
assert.equal(res.body.error, 'No ASN provided');
assert.equal(nextCalled, false);
});
it('returns 400 for AS0, values past 32 bits, overlong digit strings and non-ASNs', () => {
for (const bad of ['0', 'AS0', '000', '4294967296', 'AS99999999999', '00000000013335', '9'.repeat(400),
'ASN13335', '13335a', '-1', '1.5', 'AS 13335', 'not-an-asn', ['1', '2']]) {
const res = makeRes();
let nextCalled = false;
guard(makeReq({ query: { asn: bad } }), res, () => { nextCalled = true; });
assert.equal(res.statusCode, 400, `should reject "${bad}"`);
assert.equal(res.body.error, 'Invalid ASN');
assert.equal(nextCalled, false);
}
});
it('honours a custom param name', () => {
const req = makeReq({ query: { origin: 'AS64500' } });
let nextCalled = false;
requireValidASN('origin')(req, makeRes(), () => { nextCalled = true; });
assert.equal(nextCalled, true);
assert.equal(req.query.origin, '64500');
});
});
describe('normalizeAsnQuery', () => {
const guard = normalizeAsnQuery();
it('canonicalizes ASN-shaped values to AS<n> in place', () => {
for (const [input, expected] of [['AS13335', 'AS13335'], ['as13335', 'AS13335'], ['As13335', 'AS13335'],
['AS013335', 'AS13335'], [' AS13335 ', 'AS13335'], ['AS4294967295', 'AS4294967295']]) {
const req = makeReq({ query: { q: input } });
let nextCalled = false;
guard(req, makeRes(), () => { nextCalled = true; });
assert.equal(nextCalled, true, input);
assert.equal(req.query.q, expected, input);
}
});
it('returns 400 for AS0 and values past 32 bits', () => {
for (const bad of ['AS0', 'as0', 'AS4294967296', 'AS99999999999']) {
const res = makeRes();
let nextCalled = false;
guard(makeReq({ query: { q: bad } }), res, () => { nextCalled = true; });
assert.equal(res.statusCode, 400, `should reject "${bad}"`);
assert.equal(res.body.error, 'Invalid ASN');
assert.equal(nextCalled, false);
}
});
it('passes IPs, domains, bare numbers, missing and array values through untouched', () => {
for (const query of [{ q: '1.1.1.1' }, { q: 'example.com' }, { q: 'ASN13335' }, {}, { q: ['AS1', 'AS2'] },
{ q: '13335' }, { q: '0' }, { q: '99999999999' }]) {
const req = makeReq({ query: { ...query } });
let nextCalled = false;
guard(req, makeRes(), () => { nextCalled = true; });
assert.equal(nextCalled, true);
assert.deepEqual(req.query, query);
}
});
});
// Express 5 defines req.query as a prototype getter that re-parses the URL
// on every read; a real request (never listening) catches rewrites that a
// plain-object stub would keep.
describe('guard rewrites on a real Express request', () => {
const app = express();
const makeExpressReq = (url) => {
const req = Object.create(app.request);
req.url = url;
req.headers = {};
return req;
};
it('reproduces the re-parsing getter', () => {
const req = makeExpressReq('/x?type=caa');
assert.notEqual(req.query, req.query);
});
for (const [name, guard, url, param, expected] of [
['requireValidRecordType', requireValidRecordType(), '/x?type=caa', 'type', 'CAA'],
['requireValidDomain', requireValidDomain(), '/x?domain=Example.COM', 'domain', 'example.com'],
['requireValidASN', requireValidASN(), '/x?asn=AS013335', 'asn', '13335'],
['normalizeAsnQuery', normalizeAsnQuery(), '/x?q=as13335', 'q', 'AS13335'],
['requireValidCountry', requireValidCountry(), '/x?country=us', 'country', 'US'],
]) {
it(`${name} rewrite survives a later req.query read`, () => {
const req = makeExpressReq(url);
let nextCalled = false;
guard(req, makeRes(), () => { nextCalled = true; });
assert.equal(nextCalled, true);
assert.equal(req.query[param], expected);
});
}
it('keeps both rewrites when chained like /api/dnsresolver', () => {
const req = makeExpressReq('/x?type=caa&hostname=Example.COM');
let calls = 0;
requireValidDomain('hostname')(req, makeRes(), () => { calls++; });
requireValidRecordType()(req, makeRes(), () => { calls++; });
assert.equal(calls, 2);
assert.deepEqual({ ...req.query }, { type: 'CAA', hostname: 'example.com' });
assert.equal(req.query, req.query);
});
});