From 3e5590288afb85510d853233a509727238cae039 Mon Sep 17 00:00:00 2001 From: William Mantly Date: Sat, 11 Jul 2026 11:47:08 -0400 Subject: [PATCH] Per-host HTTP basic auth (#57) Adds opt-in basic auth per Host, following the existing per-host controls pattern: - Host fields basicauth_enabled / basicauth_realm / basicauth_users ({user: base64(sha1(pw))}). Credentials are parsed to plaintext by the pure host_features normalizer and hashed at the route layer (utils/basicauth.js), so plaintext never reaches Redis. - ops/nginx_conf/hostfeatures.lua enforces it in access phase: verifies the Authorization header against base64(sha1(password)), fails closed with a 401 WWW-Authenticate challenge. - hosts.ejs gains an enable toggle, realm, and a username:password textarea (passwords never echoed back; blank keeps the current set). Unit tests cover hashing (matches the htpasswd {SHA} vector), credential parsing, and normalization. Note: the Lua path needs verification on a live OpenResty box. Co-Authored-By: Claude Opus 4.8 --- nodejs/models/host.js | 6 +++ nodejs/package.json | 6 +-- nodejs/routes/host.js | 12 +++++ nodejs/test/unit/basicauth.test.js | 77 ++++++++++++++++++++++++++++++ nodejs/utils/basicauth.js | 33 +++++++++++++ nodejs/utils/host_features.js | 77 +++++++++++++++++++++++++++++- nodejs/views/hosts.ejs | 37 ++++++++++++++ ops/nginx_conf/hostfeatures.lua | 44 +++++++++++++++++ 8 files changed, 288 insertions(+), 4 deletions(-) create mode 100644 nodejs/test/unit/basicauth.test.js create mode 100644 nodejs/utils/basicauth.js diff --git a/nodejs/models/host.js b/nodejs/models/host.js index 4c33072..ae80c54 100755 --- a/nodejs/models/host.js +++ b/nodejs/models/host.js @@ -41,6 +41,12 @@ class Host extends Table{ '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',}, + // Per-host HTTP basic auth. basicauth_users is {username: base64(sha1(pw))} + // (hashed at the route layer, see utils/basicauth.js); enforced in + // ops/nginx_conf/hostfeatures.lua. + 'basicauth_enabled': {default: false, isRequired: false, type: 'boolean',}, + 'basicauth_realm': {default: 'Restricted', isRequired: false, type: 'string', min: 1, max: 128}, + 'basicauth_users': {default: function(){return {}}, isRequired: false, type: 'object',}, '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',}, diff --git a/nodejs/package.json b/nodejs/package.json index 9337708..48539be 100644 --- a/nodejs/package.json +++ b/nodejs/package.json @@ -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/host_features.test.js test/unit/dynamic_record.test.js test/unit/hostname_validate.test.js test/unit/password_policy.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/dynamic_record.test.js test/unit/hostname_validate.test.js test/unit/password_policy.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/dynamic_record.test.js test/unit/hostname_validate.test.js test/unit/password_policy.test.js test/unit/basicauth.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/dynamic_record.test.js test/unit/hostname_validate.test.js test/unit/password_policy.test.js test/unit/basicauth.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/host_features.test.js test/unit/dynamic_record.test.js test/unit/hostname_validate.test.js test/unit/password_policy.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/dynamic_record.test.js test/unit/hostname_validate.test.js test/unit/password_policy.test.js test/unit/basicauth.test.js test/unit/unix_socket.test.js test/integration/dns_provider.test.js" }, "engines": { "node": ">=18.0.0" diff --git a/nodejs/routes/host.js b/nodejs/routes/host.js index 75fcb6c..424b7d4 100755 --- a/nodejs/routes/host.js +++ b/nodejs/routes/host.js @@ -5,6 +5,7 @@ const {Host, Domain} = require('../models').models; const authz = require('../middleware/authz'); const {normalizeHostFeatures} = require('../utils/host_features'); const {collectHostFieldErrors} = require('../utils/hostname_validate'); +const {hashBasicAuthUsers} = require('../utils/basicauth'); const Model = Host; @@ -15,6 +16,15 @@ function validateHostFields(body){ if(errors.length) throw Model.errors.ObjectValidateError(errors); } +// After normalizeHostFeatures has parsed basic-auth creds to {user: plaintext}, +// hash them so plaintext never reaches Redis. Runs at the route layer only, so +// internally-copied records (cache/wildcard children) keep their existing hashes. +function hashHostSecrets(body){ + if(body.basicauth_users && typeof body.basicauth_users === 'object'){ + body.basicauth_users = hashBasicAuthUsers(body.basicauth_users); + } +} + router.get('/', async function(req, res, next){ try{ let results = await Model[req.query.detail ? "listDetail" : "list"](); @@ -35,6 +45,7 @@ router.post('/', authz.requireDomainRole('manager', authz.resolve.hostBody), asy req.body.created_by = authz.reqUsername(req); validateHostFields(req.body); normalizeHostFeatures(req.body); + hashHostSecrets(req.body); let item = await Model.create(req.body); return res.json({ @@ -100,6 +111,7 @@ router.put('/:item', authz.requireDomainRole('manager', authz.resolve.hostParam) req.body.updated_by = authz.reqUsername(req); validateHostFields(req.body); normalizeHostFeatures(req.body); + hashHostSecrets(req.body); let item = await Model.get(req.params.item); item = await item.update(req.body); diff --git a/nodejs/test/unit/basicauth.test.js b/nodejs/test/unit/basicauth.test.js new file mode 100644 index 0000000..c700d86 --- /dev/null +++ b/nodejs/test/unit/basicauth.test.js @@ -0,0 +1,77 @@ +'use strict'; + +const {describe, test} = require('node:test'); +const assert = require('node:assert'); + +const {hashPassword, hashBasicAuthUsers} = require('../../utils/basicauth'); +const { + parseBasicAuthLines, + sanitizeBasicAuthObject, + sanitizeRealm, + normalizeHostFeatures, +} = require('../../utils/host_features'); + +/** + * Per-host basic auth (#57). The hash must match what OpenResty computes in + * ops/nginx_conf/hostfeatures.lua: base64(sha1(password)) (htpasswd "{SHA}"). + */ +describe('basicauth hashing', () => { + test('base64(sha1(password)) matches the known htpasswd {SHA} vector', () => { + assert.strictEqual(hashPassword('secret'), '5en6G6MezRroT3XKqkdPOmY/BfQ='); + }); + test('hashBasicAuthUsers hashes each password, skips empties', () => { + assert.deepStrictEqual( + hashBasicAuthUsers({alice: 'secret', bob: '', carol: null}), + {alice: '5en6G6MezRroT3XKqkdPOmY/BfQ='} + ); + }); +}); + +describe('parseBasicAuthLines', () => { + test('parses user:password lines; passwords may contain colons', () => { + assert.deepStrictEqual( + parseBasicAuthLines('alice:secret\nbob:pw:with:colons'), + {alice: 'secret', bob: 'pw:with:colons'} + ); + }); + test('drops blank lines, lines without a colon, and empty passwords', () => { + assert.deepStrictEqual( + parseBasicAuthLines('\nalice:secret\nnopassword\nbob:\n \n'), + {alice: 'secret'} + ); + }); + test('rejects usernames with spaces/control chars', () => { + assert.deepStrictEqual(parseBasicAuthLines('a b:secret'), {}); + }); +}); + +describe('sanitizeRealm', () => { + test('strips CR/LF and quotes and trims', () => { + assert.strictEqual(sanitizeRealm('My "Realm"\r\n'), 'My Realm'); + assert.strictEqual(sanitizeRealm(undefined), ''); + }); +}); + +describe('normalizeHostFeatures (basic auth)', () => { + test('coerces enabled, parses users to plaintext object, sanitizes realm', () => { + let body = { + basicauth_enabled: 'true', + basicauth_realm: 'Admins\n', + basicauth_users: 'alice:secret\nbob:pw', + }; + normalizeHostFeatures(body); + assert.strictEqual(body.basicauth_enabled, true); + assert.strictEqual(body.basicauth_realm, 'Admins'); + assert.deepStrictEqual(body.basicauth_users, {alice: 'secret', bob: 'pw'}); + }); + test('empty users input is dropped so a blank edit keeps existing users', () => { + let body = {basicauth_enabled: 'true', basicauth_users: ' \n'}; + normalizeHostFeatures(body); + assert.ok(!('basicauth_users' in body)); + }); + test('object input is sanitized like text input', () => { + let body = {basicauth_users: {alice: 'secret', 'bad user': 'x', bob: ''}}; + normalizeHostFeatures(body); + assert.deepStrictEqual(body.basicauth_users, {alice: 'secret'}); + }); +}); diff --git a/nodejs/utils/basicauth.js b/nodejs/utils/basicauth.js new file mode 100644 index 0000000..5454b73 --- /dev/null +++ b/nodejs/utils/basicauth.js @@ -0,0 +1,33 @@ +'use strict'; + +const crypto = require('crypto'); + +/** + * Server-only hashing for per-host basic-auth credentials. Kept out of the pure, + * browser-mirrored utils/host_features.js because it needs Node crypto. + * + * Passwords are stored as base64(SHA-1(password)) — the Apache htpasswd "{SHA}" + * scheme — so plaintext never lands in Redis. OpenResty verifies with the same + * hash (ops/nginx_conf/hostfeatures.lua): base64(sha1(password)). + * + * SHA-1 is weak for password storage in general, but this is a lightweight proxy + * gate (not the app's own accounts) and matches htpasswd; upgrading the scheme is + * a follow-up. Enforce strong passwords operationally. + */ +function hashPassword(password){ + return crypto.createHash('sha1').update(String(password)).digest('base64'); +} + +// { username: plaintext } -> { username: base64sha1 }. Skips empty passwords. +function hashBasicAuthUsers(users){ + let out = {}; + if(!users || typeof users !== 'object') return out; + for(let user of Object.keys(users)){ + let pass = users[user]; + if(pass === undefined || pass === null || pass === '') continue; + out[user] = hashPassword(pass); + } + return out; +} + +module.exports = {hashPassword, hashBasicAuthUsers}; diff --git a/nodejs/utils/host_features.js b/nodejs/utils/host_features.js index 3e107aa..7bf391a 100644 --- a/nodejs/utils/host_features.js +++ b/nodejs/utils/host_features.js @@ -11,6 +11,13 @@ const MAX_HEADERS = 50; // per direction (req/resp) const MAX_HEADER_VALUE = 2048; // chars const MAX_CIDRS = 200; // per list (allow/deny) +const MAX_BASICAUTH_USERS = 100; +const MAX_PASSWORD = 256; +const MAX_REALM = 128; + +// Basic-auth username: printable ASCII, no space or control chars. ':' can't +// appear (we split on the first ':'), but the class excludes it anyway. +const BASICAUTH_USER_RE = /^[\x21-\x39\x3B-\x7e]+$/; // RFC 7230 header field-name token characters. const HEADER_NAME_RE = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/; @@ -125,6 +132,56 @@ function stringifyCidrs(arr){ return arr.join('\n'); } +/** + * "username:password" lines -> { username: password } (plaintext). The first + * ':' splits; usernames are validated and CR/LF is stripped from passwords. + * Lines without a password are dropped. Hashing happens server-side + * (utils/basicauth.js) — this stays pure so the browser can share it. + */ +function parseBasicAuthLines(text){ + let out = {}; + if(text === undefined || text === null) return out; + + for(let line of String(text).split(/\r?\n/)){ + line = line.replace(/[\r\n]/g, ''); + if(!line.trim()) continue; + let idx = line.indexOf(':'); + if(idx === -1) continue; + + let user = line.slice(0, idx).trim(); + let pass = line.slice(idx + 1).slice(0, MAX_PASSWORD); + if(!user || !pass) continue; + if(!BASICAUTH_USER_RE.test(user)) continue; + + out[user] = pass; + if(Object.keys(out).length >= MAX_BASICAUTH_USERS) break; + } + return out; +} + +/** Sanitize an already-object credential map ({user: password}) the same way. */ +function sanitizeBasicAuthObject(obj){ + let out = {}; + if(!obj || typeof obj !== 'object') return out; + + for(let user of Object.keys(obj)){ + if(!BASICAUTH_USER_RE.test(user)) continue; + let pass = String(obj[user]).replace(/[\r\n]/g, '').slice(0, MAX_PASSWORD); + if(!pass) continue; + out[user] = pass; + if(Object.keys(out).length >= MAX_BASICAUTH_USERS) break; + } + return out; +} + +/** Realm goes into a WWW-Authenticate header; strip CR/LF and quotes, cap len. */ +function sanitizeRealm(value){ + return String(value === undefined || value === null ? '' : value) + .replace(/[\r\n"]/g, '') + .trim() + .slice(0, MAX_REALM); +} + function toBool(v){ return v === true || v === 'true'; } @@ -151,6 +208,23 @@ function normalizeHostFeatures(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('basicauth_enabled' in body) body.basicauth_enabled = toBool(body.basicauth_enabled); + + if('basicauth_realm' in body) body.basicauth_realm = sanitizeRealm(body.basicauth_realm); + + if('basicauth_users' in body){ + let users = typeof body.basicauth_users === 'string' + ? parseBasicAuthLines(body.basicauth_users) + : sanitizeBasicAuthObject(body.basicauth_users); + // Empty input means "leave the existing users untouched" (passwords are + // never echoed to the form, so a blank textarea must not wipe them). Drop + // the key so the partial update skips it. Disable basic auth to clear. + if(Object.keys(users).length === 0){ + delete body.basicauth_users; + }else{ + body.basicauth_users = users; + } + } 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); @@ -181,8 +255,9 @@ function normalizeHostFeatures(body){ } module.exports = { - MAX_HEADERS, MAX_HEADER_VALUE, MAX_CIDRS, + MAX_HEADERS, MAX_HEADER_VALUE, MAX_CIDRS, MAX_BASICAUTH_USERS, parseHeaderLines, stringifyHeaders, sanitizeHeaderObject, isValidCidr, parseCidrLines, sanitizeCidrArray, stringifyCidrs, + parseBasicAuthLines, sanitizeBasicAuthObject, sanitizeRealm, normalizeHostFeatures, }; diff --git a/nodejs/views/hosts.ejs b/nodejs/views/hosts.ejs index 8ef3c2e..40199bc 100755 --- a/nodejs/views/hosts.ejs +++ b/nodejs/views/hosts.ejs @@ -127,6 +127,12 @@ $(".hostEditPanel textarea[name='ip_allow']").val(hostFeatureCidrsToText(host.ip_allow)); $(".hostEditPanel textarea[name='ip_deny']").val(hostFeatureCidrsToText(host.ip_deny)); + // Never echo basic-auth passwords back to the form; show the current + // usernames as a hint and leave the textarea blank (blank = keep). + $(".hostEditPanel textarea[name='basicauth_users']").val(''); + $(".hostEditPanel .basicauth-current").text( + Object.keys(host.basicauth_users || {}).join(', ') || 'none'); + $('.hostEditPanel').scrollTo(); }; @@ -532,6 +538,37 @@ +
+ +
+ +
+
+ +
+
+ +
+ + +
+ +
+ + + + Current: none. + Passwords are stored hashed and never shown here. Leave blank to + keep the current users; entering any lines replaces the whole list. + +
+