Files
proxy/nodejs/test/unit/host_features.test.js
T
wmantly 6092468901 Add per-host reverse-proxy controls (rate limit, cache, headers, IP ACL)
Every proxied request flows through one shared OpenResty location whose
behavior is chosen at request time from the host's Redis hash. Add per-host
controls as new Host fields enforced in Lua rather than static nginx config
(which can't key off a per-request variable):

- Rate limiting: per-client-IP token bucket via resty.limit.req
  (ratelimit_enabled/rate/burst), backed by a new `ratelimit` shared dict.
- Response caching: opt-in per host via a global proxy_cache zone gated by
  $skip_cache (respcache_enabled). Off by default; upstream Cache-Control
  still honored.
- Custom/security headers: req_headers (upstream) + resp_headers (client) and
  hsts_enabled, applied in access/header_filter phases.
- IP allow/deny CIDR lists via resty.ipmatcher (deny wins; non-empty allow is
  default-deny).

New ops/nginx_conf/hostfeatures.lua holds the enforcement; proxy.conf's
access_by_lua string becomes a block that calls it, plus a header_filter block.
nodejs/utils/host_features.js is the pure, unit-tested normalize/validate layer
(header/CIDR parsing, range clamping, injection-safe values) applied in
routes/host.js and mirrored by the hosts.ejs edit form. install.sh gains the
ipmatcher rock, the cache dir, and the hostfeatures.lua symlink.

Per-host cache TTL is intentionally deferred (global default only) — see the
plan's limitations.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 22:10:55 -04:00

179 lines
5.5 KiB
JavaScript

'use strict';
const {describe, test} = require('node:test');
const assert = require('node:assert');
const {
parseHeaderLines, stringifyHeaders, sanitizeHeaderObject,
isValidCidr, parseCidrLines, sanitizeCidrArray, stringifyCidrs,
normalizeHostFeatures, MAX_HEADERS, MAX_CIDRS,
} = require('../../utils/host_features');
/**
* Pure normalize/validate helpers for the per-host reverse-proxy controls.
* These are the authoritative server-side validation applied in routes/host.js
* and are mirrored by the browser form code.
*/
describe('parseHeaderLines', () => {
test('parses "Name: value" lines into an object', () => {
assert.deepStrictEqual(
parseHeaderLines('X-Frame-Options: DENY\nX-A: b'),
{'X-Frame-Options': 'DENY', 'X-A': 'b'}
);
});
test('splits on the first colon only', () => {
assert.deepStrictEqual(
parseHeaderLines('X-Url: https://a.b/c'),
{'X-Url': 'https://a.b/c'}
);
});
test('skips blank lines and lines without a colon', () => {
assert.deepStrictEqual(
parseHeaderLines('\nX-A: 1\n\ngarbage\n'),
{'X-A': '1'}
);
});
test('rejects invalid header names', () => {
assert.deepStrictEqual(parseHeaderLines('Bad Name: v\nx y: z'), {});
});
test('strips CR/LF from values (no response splitting)', () => {
let out = parseHeaderLines('X-A: a\rb'); // \r inside a single line
assert.strictEqual(out['X-A'], 'ab');
});
test('handles empty/undefined input', () => {
assert.deepStrictEqual(parseHeaderLines(''), {});
assert.deepStrictEqual(parseHeaderLines(undefined), {});
assert.deepStrictEqual(parseHeaderLines(null), {});
});
test('caps the number of headers', () => {
let lines = [];
for(let i = 0; i < MAX_HEADERS + 10; i++) lines.push(`X-H${i}: ${i}`);
assert.strictEqual(Object.keys(parseHeaderLines(lines.join('\n'))).length, MAX_HEADERS);
});
test('round-trips through stringifyHeaders', () => {
let obj = {'X-A': '1', 'X-B': 'two'};
assert.deepStrictEqual(parseHeaderLines(stringifyHeaders(obj)), obj);
});
});
describe('sanitizeHeaderObject', () => {
test('drops bad names and strips CR/LF', () => {
assert.deepStrictEqual(
sanitizeHeaderObject({'X-Ok': 'v\r\nInjected: y', 'bad name': 'z'}),
{'X-Ok': 'vInjected: y'}
);
});
test('handles non-objects', () => {
assert.deepStrictEqual(sanitizeHeaderObject(null), {});
assert.deepStrictEqual(sanitizeHeaderObject('x'), {});
});
});
describe('isValidCidr', () => {
test('accepts IPv4 with and without a mask', () => {
assert.ok(isValidCidr('192.168.1.1'));
assert.ok(isValidCidr('10.0.0.0/8'));
assert.ok(isValidCidr('0.0.0.0/0'));
});
test('rejects out-of-range octets and masks', () => {
assert.ok(!isValidCidr('256.1.1.1'));
assert.ok(!isValidCidr('10.0.0.0/33'));
});
test('accepts loose IPv6, rejects junk', () => {
assert.ok(isValidCidr('::1'));
assert.ok(isValidCidr('fe80::/10'));
assert.ok(!isValidCidr('not-an-ip'));
assert.ok(!isValidCidr(''));
assert.ok(!isValidCidr(42));
});
});
describe('parseCidrLines / sanitizeCidrArray', () => {
test('splits on whitespace and commas, keeping valid entries', () => {
assert.deepStrictEqual(
parseCidrLines('10.0.0.0/8, 192.168.1.5\nbad\n '),
['10.0.0.0/8', '192.168.1.5']
);
});
test('dedupes', () => {
assert.deepStrictEqual(
sanitizeCidrArray(['1.1.1.1', '1.1.1.1', '2.2.2.2']),
['1.1.1.1', '2.2.2.2']
);
});
test('caps the list length', () => {
let arr = [];
for(let i = 0; i < MAX_CIDRS + 10; i++) arr.push(`10.0.0.${i % 256}`);
// includes dupes past .255, so just assert the cap holds
assert.ok(sanitizeCidrArray(arr).length <= MAX_CIDRS);
});
test('round-trips through stringifyCidrs', () => {
let arr = ['10.0.0.0/8', '192.168.1.5'];
assert.deepStrictEqual(parseCidrLines(stringifyCidrs(arr)), arr);
});
test('handles empty input', () => {
assert.deepStrictEqual(parseCidrLines(''), []);
assert.deepStrictEqual(sanitizeCidrArray(null), []);
});
});
describe('normalizeHostFeatures', () => {
test('coerces booleans and clamps numbers', () => {
let body = normalizeHostFeatures({
ratelimit_enabled: 'true',
respcache_enabled: 'false',
hsts_enabled: true,
ratelimit_rate: '0', // below min -> 1
ratelimit_burst: '5000000', // above max -> cap
});
assert.strictEqual(body.ratelimit_enabled, true);
assert.strictEqual(body.respcache_enabled, false);
assert.strictEqual(body.hsts_enabled, true);
assert.strictEqual(body.ratelimit_rate, 1);
assert.strictEqual(body.ratelimit_burst, 1000000);
});
test('accepts both text and object/array shapes', () => {
let fromText = normalizeHostFeatures({
resp_headers: 'X-A: 1',
ip_deny: '10.0.0.0/8\nbad',
});
assert.deepStrictEqual(fromText.resp_headers, {'X-A': '1'});
assert.deepStrictEqual(fromText.ip_deny, ['10.0.0.0/8']);
let fromObj = normalizeHostFeatures({
resp_headers: {'X-A': '1', 'bad name': 'x'},
ip_deny: ['10.0.0.0/8', 'junk'],
});
assert.deepStrictEqual(fromObj.resp_headers, {'X-A': '1'});
assert.deepStrictEqual(fromObj.ip_deny, ['10.0.0.0/8']);
});
test('only touches present keys (partial update safe)', () => {
let body = normalizeHostFeatures({host: 'a.b.com', ip: '1.2.3.4'});
assert.deepStrictEqual(body, {host: 'a.b.com', ip: '1.2.3.4'});
assert.ok(!('ip_allow' in body));
assert.ok(!('ratelimit_rate' in body));
});
test('junk numbers fall back to defaults', () => {
let body = normalizeHostFeatures({ratelimit_rate: 'abc', ratelimit_burst: 'x'});
assert.strictEqual(body.ratelimit_rate, 10);
assert.strictEqual(body.ratelimit_burst, 20);
});
});