6092468901
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>
179 lines
5.5 KiB
JavaScript
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);
|
|
});
|
|
});
|