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>
189 lines
5.7 KiB
JavaScript
189 lines
5.7 KiB
JavaScript
'use strict';
|
|
|
|
// Pure helpers for the per-host reverse-proxy controls (rate limiting, response
|
|
// caching, custom/security headers, IP allow/deny). Shared by the server route
|
|
// (`routes/host.js`) for authoritative validation and mirrored by the browser
|
|
// form code (`public/js/app.js`) so client and server agree on the wire shape.
|
|
//
|
|
// No dependencies, no I/O — everything here is deterministic and unit-tested by
|
|
// test/unit/host_features.test.js.
|
|
|
|
const MAX_HEADERS = 50; // per direction (req/resp)
|
|
const MAX_HEADER_VALUE = 2048; // chars
|
|
const MAX_CIDRS = 200; // per list (allow/deny)
|
|
|
|
// RFC 7230 header field-name token characters.
|
|
const HEADER_NAME_RE = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
|
|
|
|
/**
|
|
* "Name: value" lines -> { Name: value }. Invalid names are dropped; CR/LF are
|
|
* stripped from values to prevent header/response splitting. First ':' splits.
|
|
*/
|
|
function parseHeaderLines(text){
|
|
let out = {};
|
|
if(text === undefined || text === null) return out;
|
|
let lines = String(text).split(/\r?\n/);
|
|
|
|
for(let line of lines){
|
|
if(!line.trim()) continue;
|
|
let idx = line.indexOf(':');
|
|
if(idx === -1) continue;
|
|
|
|
let name = line.slice(0, idx).trim();
|
|
let value = line.slice(idx + 1).trim();
|
|
|
|
if(!HEADER_NAME_RE.test(name)) continue;
|
|
value = value.replace(/[\r\n]/g, '').slice(0, MAX_HEADER_VALUE);
|
|
|
|
out[name] = value;
|
|
if(Object.keys(out).length >= MAX_HEADERS) break;
|
|
}
|
|
|
|
return out;
|
|
}
|
|
|
|
/** { Name: value } -> "Name: value" lines (for populating the edit form). */
|
|
function stringifyHeaders(obj){
|
|
if(!obj || typeof obj !== 'object') return '';
|
|
return Object.keys(obj)
|
|
.map(name => `${name}: ${obj[name]}`)
|
|
.join('\n');
|
|
}
|
|
|
|
/**
|
|
* Sanitize an already-object header map (e.g. a JSON body) the same way
|
|
* parseHeaderLines sanitizes text: valid token names only, CR/LF-stripped
|
|
* values, capped count.
|
|
*/
|
|
function sanitizeHeaderObject(obj){
|
|
let out = {};
|
|
if(!obj || typeof obj !== 'object') return out;
|
|
|
|
for(let name of Object.keys(obj)){
|
|
if(!HEADER_NAME_RE.test(name)) continue;
|
|
let value = String(obj[name]).replace(/[\r\n]/g, '').slice(0, MAX_HEADER_VALUE);
|
|
out[name] = value;
|
|
if(Object.keys(out).length >= MAX_HEADERS) break;
|
|
}
|
|
|
|
return out;
|
|
}
|
|
|
|
/** True for a plausible IPv4 or IPv6 address with an optional CIDR suffix. */
|
|
function isValidCidr(entry){
|
|
if(typeof entry !== 'string') return false;
|
|
let s = entry.trim();
|
|
if(!s) return false;
|
|
|
|
// IPv4, optional /0-32.
|
|
let m = s.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})(?:\/(\d{1,2}))?$/);
|
|
if(m){
|
|
for(let i = 1; i <= 4; i++){
|
|
if(Number(m[i]) > 255) return false;
|
|
}
|
|
if(m[5] !== undefined && Number(m[5]) > 32) return false;
|
|
return true;
|
|
}
|
|
|
|
// IPv6 (loose — resty.ipmatcher does the authoritative parse), optional /0-128.
|
|
if(/^[0-9A-Fa-f:]+(?:\/\d{1,3})?$/.test(s) && s.indexOf(':') !== -1){
|
|
let slash = s.indexOf('/');
|
|
if(slash !== -1 && Number(s.slice(slash + 1)) > 128) return false;
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/** Newline/whitespace-separated text -> array of valid CIDR strings. */
|
|
function parseCidrLines(text){
|
|
if(text === undefined || text === null) return [];
|
|
return sanitizeCidrArray(String(text).split(/[\s,]+/));
|
|
}
|
|
|
|
/** array -> deduped array of valid CIDR strings, capped. */
|
|
function sanitizeCidrArray(arr){
|
|
if(!Array.isArray(arr)) return [];
|
|
let seen = new Set();
|
|
let out = [];
|
|
|
|
for(let raw of arr){
|
|
let s = String(raw).trim();
|
|
if(!s || seen.has(s)) continue;
|
|
if(!isValidCidr(s)) continue;
|
|
seen.add(s);
|
|
out.push(s);
|
|
if(out.length >= MAX_CIDRS) break;
|
|
}
|
|
|
|
return out;
|
|
}
|
|
|
|
/** array -> newline-joined text (for populating the edit form). */
|
|
function stringifyCidrs(arr){
|
|
if(!Array.isArray(arr)) return '';
|
|
return arr.join('\n');
|
|
}
|
|
|
|
function toBool(v){
|
|
return v === true || v === 'true';
|
|
}
|
|
|
|
/** Coerce a number into [min, max], falling back to `def` for junk. */
|
|
function clampNumber(v, min, max, def){
|
|
let n = Number(v);
|
|
if(!Number.isFinite(n)) return def;
|
|
n = Math.floor(n);
|
|
if(n < min) return min;
|
|
if(n > max) return max;
|
|
return n;
|
|
}
|
|
|
|
/**
|
|
* Coerce/validate only the per-host feature fields that are PRESENT in `body`,
|
|
* in place, returning it. Absent fields are left untouched so partial updates
|
|
* (PUT) don't reset unspecified controls. Accepts both the browser wire shape
|
|
* (objects/arrays) and raw text (curl users), normalizing to the stored shape.
|
|
*/
|
|
function normalizeHostFeatures(body){
|
|
if(!body || typeof body !== 'object') return body;
|
|
|
|
if('ratelimit_enabled' in body) body.ratelimit_enabled = toBool(body.ratelimit_enabled);
|
|
if('respcache_enabled' in body) body.respcache_enabled = toBool(body.respcache_enabled);
|
|
if('hsts_enabled' in body) body.hsts_enabled = toBool(body.hsts_enabled);
|
|
|
|
if('ratelimit_rate' in body) body.ratelimit_rate = clampNumber(body.ratelimit_rate, 1, 1000000, 10);
|
|
if('ratelimit_burst' in body) body.ratelimit_burst = clampNumber(body.ratelimit_burst, 0, 1000000, 20);
|
|
|
|
if('req_headers' in body){
|
|
body.req_headers = typeof body.req_headers === 'string'
|
|
? parseHeaderLines(body.req_headers)
|
|
: sanitizeHeaderObject(body.req_headers);
|
|
}
|
|
if('resp_headers' in body){
|
|
body.resp_headers = typeof body.resp_headers === 'string'
|
|
? parseHeaderLines(body.resp_headers)
|
|
: sanitizeHeaderObject(body.resp_headers);
|
|
}
|
|
|
|
if('ip_allow' in body){
|
|
body.ip_allow = typeof body.ip_allow === 'string'
|
|
? parseCidrLines(body.ip_allow)
|
|
: sanitizeCidrArray(body.ip_allow);
|
|
}
|
|
if('ip_deny' in body){
|
|
body.ip_deny = typeof body.ip_deny === 'string'
|
|
? parseCidrLines(body.ip_deny)
|
|
: sanitizeCidrArray(body.ip_deny);
|
|
}
|
|
|
|
return body;
|
|
}
|
|
|
|
module.exports = {
|
|
MAX_HEADERS, MAX_HEADER_VALUE, MAX_CIDRS,
|
|
parseHeaderLines, stringifyHeaders, sanitizeHeaderObject,
|
|
isValidCidr, parseCidrLines, sanitizeCidrArray, stringifyCidrs,
|
|
normalizeHostFeatures,
|
|
};
|