Merge branch 'master' into fix/porkbun-domain-sync-zoneid

This commit is contained in:
2026-07-10 23:28:42 -04:00
committed by GitHub
11 changed files with 656 additions and 8 deletions
+14 -1
View File
@@ -30,7 +30,20 @@ class Host extends Table{
'targetssl': {isRequired: false, default: false, type: 'boolean'},
'is_cache': {default: false, isRequired: false, type: 'boolean',},
// Per-host reverse-proxy controls. Enforced in OpenResty by
// ops/nginx_conf/hostfeatures.lua, which reads these straight off the
// Redis hash. Object fields are JSON-encoded by model-redis.
'ratelimit_enabled': {default: false, isRequired: false, type: 'boolean',},
'ratelimit_rate': {default: 10, isRequired: false, type: 'number', min: 1, max: 1000000},
'ratelimit_burst': {default: 20, isRequired: false, type: 'number', min: 0, max: 1000000},
'respcache_enabled': {default: false, isRequired: false, type: 'boolean',},
'hsts_enabled': {default: false, isRequired: false, type: 'boolean',},
'req_headers': {default: function(){return {}}, isRequired: false, type: 'object',},
'resp_headers': {default: function(){return {}}, isRequired: false, type: 'object',},
'ip_allow': {default: function(){return []}, isRequired: false, type: 'object',},
'ip_deny': {default: function(){return []}, isRequired: false, type: 'object',},
'is_wildcard': {default: false, isRequired: false, type: 'boolean',},
'wildcard_status': {isRequired: false, type: 'string', min: 3, max: 500},
'wildcard_matchAny': {default: false, isRequired: false, type: 'boolean',},
+3 -3
View File
@@ -11,10 +11,10 @@
"scripts": {
"start": "node ./bin/www",
"dev": "npx nodemon --ignore public/ ./bin/www",
"test": "node --test test/unit/callback_queue.test.js test/unit/host_lookup.test.js test/unit/wildcard_matchany.test.js test/unit/roles.test.js test/unit/oidc.test.js test/unit/safe_redirect.test.js test/unit/unix_socket.test.js test/integration/dns_provider.test.js",
"test:unit": "node --test test/unit/callback_queue.test.js test/unit/host_lookup.test.js test/unit/wildcard_matchany.test.js test/unit/roles.test.js test/unit/oidc.test.js test/unit/safe_redirect.test.js test/unit/unix_socket.test.js",
"test": "node --test test/unit/callback_queue.test.js test/unit/host_lookup.test.js test/unit/wildcard_matchany.test.js test/unit/roles.test.js test/unit/oidc.test.js test/unit/safe_redirect.test.js test/unit/host_features.test.js test/unit/unix_socket.test.js test/integration/dns_provider.test.js",
"test:unit": "node --test test/unit/callback_queue.test.js test/unit/host_lookup.test.js test/unit/wildcard_matchany.test.js test/unit/roles.test.js test/unit/oidc.test.js test/unit/safe_redirect.test.js test/unit/host_features.test.js test/unit/unix_socket.test.js",
"test:integration": "node --test test/integration/dns_provider.test.js",
"test:watch": "node --test --watch test/unit/callback_queue.test.js test/unit/host_lookup.test.js test/unit/wildcard_matchany.test.js test/unit/roles.test.js test/unit/oidc.test.js test/unit/safe_redirect.test.js test/unit/unix_socket.test.js test/integration/dns_provider.test.js"
"test:watch": "node --test --watch test/unit/callback_queue.test.js test/unit/host_lookup.test.js test/unit/wildcard_matchany.test.js test/unit/roles.test.js test/unit/oidc.test.js test/unit/safe_redirect.test.js test/unit/host_features.test.js test/unit/unix_socket.test.js test/integration/dns_provider.test.js"
},
"engines": {
"node": ">=18.0.0"
+4 -1
View File
@@ -372,8 +372,11 @@ app.util = (function(app){
for (let {name, value} of $(this).serializeArray()) {
console.log(name, value)
if (obj[name] === undefined) {
if (!value
if (!value
&& !$(this).parent().find(`[name="${name}"]`).attr('value')
// Keep empty <textarea>s so a cleared field is submitted (and
// can reset a list, e.g. the per-host IP/header controls).
&& !$(this).filter(`textarea[name="${name}"]`).length
){
continue;
}
+3
View File
@@ -3,6 +3,7 @@
const router = require('express').Router();
const {Host, Domain} = require('../models').models;
const authz = require('../middleware/authz');
const {normalizeHostFeatures} = require('../utils/host_features');
const Model = Host;
@@ -24,6 +25,7 @@ router.get('/', async function(req, res, next){
router.post('/', authz.requireDomainRole('manager', authz.resolve.hostBody), async function(req, res, next){
try{
req.body.created_by = authz.reqUsername(req);
normalizeHostFeatures(req.body);
let item = await Model.create(req.body);
return res.json({
@@ -87,6 +89,7 @@ router.get('/:item', authz.requireDomainRole('viewer', authz.resolve.hostParam),
router.put('/:item', authz.requireDomainRole('manager', authz.resolve.hostParam), async function(req, res, next){
try{
req.body.updated_by = authz.reqUsername(req);
normalizeHostFeatures(req.body);
let item = await Model.get(req.params.item);
item = await item.update(req.body);
+178
View File
@@ -0,0 +1,178 @@
'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);
});
});
+188
View File
@@ -0,0 +1,188 @@
'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,
};
+100
View File
@@ -89,6 +89,16 @@
$.scope.editHost.remove(0);
}
// Mirror of utils/host_features.js stringify* helpers for populating the edit
// form's textareas. The server re-parses the posted text authoritatively.
function hostFeatureHeadersToText(obj){
if(!obj || typeof obj != 'object') return '';
return Object.keys(obj).map(function(name){ return name + ': ' + obj[name]; }).join('\n');
}
function hostFeatureCidrsToText(arr){
return Array.isArray(arr) ? arr.join('\n') : '';
}
function hostEditOpen(btn, host){
hostEditCancle();
console.log('host:', host)
@@ -110,6 +120,13 @@
}
});
// Object/array proxy-control fields render into textareas as text. Server
// (utils/host_features.js) parses the same text/shape back on save.
$(".hostEditPanel textarea[name='req_headers']").val(hostFeatureHeadersToText(host.req_headers));
$(".hostEditPanel textarea[name='resp_headers']").val(hostFeatureHeadersToText(host.resp_headers));
$(".hostEditPanel textarea[name='ip_allow']").val(hostFeatureCidrsToText(host.ip_allow));
$(".hostEditPanel textarea[name='ip_deny']").val(hostFeatureCidrsToText(host.ip_deny));
$('.hostEditPanel').scrollTo();
};
@@ -432,6 +449,89 @@
</div>
<b class="invalid-feedback"></b>
</div>
<hr />
<h6 class="text-muted">Proxy controls</h6>
<div class="form-group">
<label class="form-label">Rate limiting</label>
<div class="radio">
<label>
<input type="radio" name="ratelimit_enabled" id="ratelimit_enabled-false" value="false" checked>
Off <b>Recommended</b>
</label>
</div>
<div class="radio">
<label>
<input type="radio" name="ratelimit_enabled" id="ratelimit_enabled-true" value="true">
Limit requests per client IP
</label>
</div>
</div>
<div class="row">
<div class="col form-group">
<label for="ratelimit_rate" class="form-label">Requests / sec</label>
<input type="number" name="ratelimit_rate" class="form-control" value="10" min="1" max="1000000" />
</div>
<div class="col form-group">
<label for="ratelimit_burst" class="form-label">Burst</label>
<input type="number" name="ratelimit_burst" class="form-control" value="20" min="0" max="1000000" />
</div>
</div>
<div class="form-group">
<label class="form-label">Response caching</label>
<div class="radio">
<label>
<input type="radio" name="respcache_enabled" id="respcache_enabled-false" value="false" checked>
Off <b>Recommended</b>
</label>
</div>
<div class="radio">
<label>
<input type="radio" name="respcache_enabled" id="respcache_enabled-true" value="true">
Cache cacheable responses
</label>
</div>
</div>
<div class="form-group">
<label class="form-label">HSTS</label>
<div class="radio">
<label>
<input type="radio" name="hsts_enabled" id="hsts_enabled-false" value="false" checked>
Off
</label>
</div>
<div class="radio">
<label>
<input type="radio" name="hsts_enabled" id="hsts_enabled-true" value="true">
Send Strict-Transport-Security
</label>
</div>
</div>
<div class="form-group">
<label for="ip_allow" class="form-label">Allow IPs / CIDRs</label>
<textarea name="ip_allow" class="form-control" rows="2" placeholder="one per line; if set, only these are allowed"></textarea>
</div>
<div class="form-group">
<label for="ip_deny" class="form-label">Deny IPs / CIDRs</label>
<textarea name="ip_deny" class="form-control" rows="2" placeholder="one per line; these are blocked"></textarea>
</div>
<div class="form-group">
<label for="req_headers" class="form-label">Upstream request headers</label>
<textarea name="req_headers" class="form-control" rows="2" placeholder="Name: value, one per line"></textarea>
</div>
<div class="form-group">
<label for="resp_headers" class="form-label">Response headers</label>
<textarea name="resp_headers" class="form-control" rows="2" placeholder="Name: value, one per line"></textarea>
</div>
<hr class="buttonBreak" />
<button type="submit" class="btn btn-success">
<i class="fa-solid fa-plus"></i>