From 6465e3d9f502c579611fa5abbb25470b36bfbf6c Mon Sep 17 00:00:00 2001 From: William Mantly Date: Sat, 11 Jul 2026 11:31:03 -0400 Subject: [PATCH 1/9] Scheduler: refresh DNS provider domain lists on interval (#69) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The host scheduler already checked wildcard cert expiry; add the second half of the scheduler controller — DnsProvider.refreshAllDomains() re-syncs every provider's domain list (get -> updateDomains, per-provider errors isolated), scheduled 30s after start and every 24h alongside the cert check. Co-Authored-By: Claude Opus 4.8 --- nodejs/models/dns_provider.js | 22 ++++++++++++++++++++++ nodejs/services/host_scheduler.js | 7 +++++++ 2 files changed, 29 insertions(+) diff --git a/nodejs/models/dns_provider.js b/nodejs/models/dns_provider.js index e5d09dd..f6e5640 100644 --- a/nodejs/models/dns_provider.js +++ b/nodejs/models/dns_provider.js @@ -149,6 +149,28 @@ class DnsProvider extends Table{ return out; } + // Re-sync every configured provider's domain list from its API. Mirrors the + // manual /dns/domain/refresh/:item route (get -> updateDomains) across all + // providers; used by the host scheduler. Never throws — one bad provider + // (e.g. a revoked key) must not abort the rest. + static async refreshAllDomains(){ + let ids; + try{ + ids = await this.list(); + }catch(error){ + console.error('refreshAllDomains: could not list providers', error.message); + return; + } + for(let id of ids){ + try{ + let provider = await this.get(id); + await provider.updateDomains(); + }catch(error){ + console.error('refreshAllDomains: provider', id, error.message); + } + } + } + get api(){ return new this.constructor.Provider(this); } diff --git a/nodejs/services/host_scheduler.js b/nodejs/services/host_scheduler.js index 465f658..399babf 100644 --- a/nodejs/services/host_scheduler.js +++ b/nodejs/services/host_scheduler.js @@ -2,6 +2,7 @@ const conf = require('@simpleworkjs/conf'); const {Host} = require('../models/host'); +const {DnsProvider} = require('../models').models; function hostSchedulerService(){ @@ -29,8 +30,14 @@ function hostSchedulerService(){ // Ensures certificates are renewed well before expiration setInterval(Host.checkWildcardForRenew.bind(Host), conf.service.hostScheduler.interval); + // Refresh each DNS provider's domain list on the same cadence so domains + // added/removed at the provider are picked up without a manual refresh. + setTimeout(DnsProvider.refreshAllDomains.bind(DnsProvider), conf.service.hostScheduler.initial); + setInterval(DnsProvider.refreshAllDomains.bind(DnsProvider), conf.service.hostScheduler.interval); + console.log('Host scheduler service initialized'); console.log('- Wildcard cert check: 30s after start, then every 24h'); + console.log('- DNS provider domain refresh: 30s after start, then every 24h'); } if(conf.service.hostScheduler.enabled !== false) hostSchedulerService(); From d1586b4d5a3800731c4570fbf43ca19906b81683 Mon Sep 17 00:00:00 2001 From: William Mantly Date: Sat, 11 Jul 2026 11:38:09 -0400 Subject: [PATCH 2/9] Fix user creation and password policy (#48) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause of "can't log in with new credentials": routes/user.js POST called User.add, which doesn't exist on the redis User model (it has create) — so every API-created account threw and was never persisted. Switch to User.create and make the Add button a submit. Replace the broken password rule (rejected strong "@123Caplowercase", accepted weak "lowercase1") with a clear policy in utils/password_policy.js: >= 8 chars and either 12+ chars or 3-of-4 character classes. Enforced server-side on create and password changes, mirrored in public/lib/js/val.js, with unit tests. Co-Authored-By: Claude Opus 4.8 --- nodejs/package.json | 6 ++-- nodejs/public/lib/js/val.js | 18 ++++++++-- nodejs/routes/user.js | 16 ++++++++- nodejs/test/unit/password_policy.test.js | 35 ++++++++++++++++++++ nodejs/utils/password_policy.js | 42 ++++++++++++++++++++++++ nodejs/views/users.ejs | 4 +-- 6 files changed, 112 insertions(+), 9 deletions(-) create mode 100644 nodejs/test/unit/password_policy.test.js create mode 100644 nodejs/utils/password_policy.js diff --git a/nodejs/package.json b/nodejs/package.json index 45ef98e..9337708 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/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/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/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: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/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/unix_socket.test.js test/integration/dns_provider.test.js" }, "engines": { "node": ">=18.0.0" diff --git a/nodejs/public/lib/js/val.js b/nodejs/public/lib/js/val.js index 90c8742..0029957 100755 --- a/nodejs/public/lib/js/val.js +++ b/nodejs/public/lib/js/val.js @@ -176,10 +176,22 @@ } }, + // Mirrors utils/password_policy.js: >= 8 chars, and either 12+ chars + // or at least 3 of {lowercase, uppercase, number, symbol}. password: function( value ) { - var reg = /^(?=[^\d_].*?\d)\w(\w|[!@#$%]){1,48}/; - if ( reg.test( value ) === false ) { - return "Weak password, Try again"; + if ( typeof value !== 'string' || value.length < 8 ) { + return "Password must be at least 8 characters"; + } + if ( value.length >= 12 ) return; + + var classes = 0; + if ( /[a-z]/.test( value ) ) classes++; + if ( /[A-Z]/.test( value ) ) classes++; + if ( /[0-9]/.test( value ) ) classes++; + if ( /[^A-Za-z0-9]/.test( value ) ) classes++; + + if ( classes < 3 ) { + return "Use 3 of: lowercase, uppercase, number, symbol (or 12+ chars)"; } } } diff --git a/nodejs/routes/user.js b/nodejs/routes/user.js index 4be45a0..4195cfc 100755 --- a/nodejs/routes/user.js +++ b/nodejs/routes/user.js @@ -3,6 +3,14 @@ const router = require('express').Router(); const {User} = require('../models').models; const authz = require('../middleware/authz'); +const {passwordError} = require('../utils/password_policy'); + +// Reject a weak password before it reaches the model. Throws 422 with a +// per-field key the frontend surfaces inline. +function validatePassword(password){ + let message = passwordError(password); + if(message) throw User.errors.ObjectValidateError([{key: 'password', message}]); +} // User management is global-admin-only, except the self-service routes below // (GET /me, PUT /password, POST /key) which any authenticated user may call for @@ -21,8 +29,12 @@ router.get('/', authz.requireAdmin, async function(req, res, next){ router.post('/', authz.requireAdmin, async function(req, res, next){ try{ req.body.created_by = authz.reqUsername(req) + validatePassword(req.body.password); - return res.json(await User.add(req.body)); + // User.create (not the nonexistent User.add) — the drift here meant every + // API-created account threw, so the new credentials never existed to log + // in with (issue #48). + return res.json(await User.create(req.body)); }catch(error){ next(error); } @@ -62,6 +74,7 @@ router.get('/me', async function(req, res, next){ // Self-service: change your own password. router.put('/password', async function(req, res, next){ try{ + validatePassword(req.body.password); return res.json({results: await req.user.setPassword(req.body)}) }catch(error){ next(error); @@ -71,6 +84,7 @@ router.put('/password', async function(req, res, next){ // Admin: reset another user's password. router.put('/password/:username', authz.requireAdmin, async function(req, res, next){ try{ + validatePassword(req.body.password); let user = await User.get(req.params.username); return res.json({results: await user.setPassword(req.body)}); }catch(error){ diff --git a/nodejs/test/unit/password_policy.test.js b/nodejs/test/unit/password_policy.test.js new file mode 100644 index 0000000..c0d3ca3 --- /dev/null +++ b/nodejs/test/unit/password_policy.test.js @@ -0,0 +1,35 @@ +'use strict'; + +const {describe, test} = require('node:test'); +const assert = require('node:assert'); + +const {passwordError} = require('../../utils/password_policy'); + +/** + * The old rule (issue #48) rejected strong passwords and accepted weak ones. + * These pin the corrected behavior: length-forward, 3-of-4 character classes. + */ +describe('passwordError', () => { + test('accepts a strong mixed password (previously rejected)', () => { + assert.strictEqual(passwordError('@123Caplowercase'), null); + }); + test('accepts a 12+ char passphrase on length alone', () => { + assert.strictEqual(passwordError('correcthorsebattery'), null); + }); + test('rejects a weak two-class password (previously accepted)', () => { + assert.notStrictEqual(passwordError('lowercase1'), null); + }); + test('rejects too-short passwords', () => { + assert.notStrictEqual(passwordError('Ab3$xy'), null); // 6 chars + assert.notStrictEqual(passwordError(''), null); + assert.notStrictEqual(passwordError(undefined), null); + }); + test('accepts 8 chars with 3 classes', () => { + assert.strictEqual(passwordError('Abcd123!'), null); // upper, lower, num, sym + assert.strictEqual(passwordError('Abcdefg1'), null); // upper, lower, num + }); + test('rejects 8-11 chars with only 2 classes', () => { + assert.notStrictEqual(passwordError('abcdefg1'), null); // lower + num only + assert.notStrictEqual(passwordError('ABCDEFG1'), null); // upper + num only + }); +}); diff --git a/nodejs/utils/password_policy.js b/nodejs/utils/password_policy.js new file mode 100644 index 0000000..9a3bf8c --- /dev/null +++ b/nodejs/utils/password_policy.js @@ -0,0 +1,42 @@ +'use strict'; + +/** + * Local-account password policy. + * + * The previous rule was a single opaque regex that rejected strong passwords + * (e.g. "@123Caplowercase") while accepting weak ones (e.g. "lowercase1") — see + * issue #48. This replaces it with a clear, length-forward policy: + * + * - at least MIN characters, and + * - either PASSPHRASE+ characters (a long passphrase passes on length alone), + * or at least 3 of the 4 character classes (lowercase, uppercase, number, + * symbol). + * + * Pure and dependency-free so it can run server-side (routes/user.js) and be + * mirrored client-side (public/lib/js/val.js) and unit tested. + */ + +const MIN = 8; +const PASSPHRASE = 12; + +// Returns a human-readable error message if the password is unacceptable, else +// null when it passes. +function passwordError(value){ + if(typeof value !== 'string' || value.length < MIN){ + return `Password must be at least ${MIN} characters.`; + } + if(value.length >= PASSPHRASE) return null; + + let classes = 0; + if(/[a-z]/.test(value)) classes++; + if(/[A-Z]/.test(value)) classes++; + if(/[0-9]/.test(value)) classes++; + if(/[^A-Za-z0-9]/.test(value)) classes++; + + if(classes < 3){ + return 'Use at least 3 of: lowercase, uppercase, number, symbol — or make it 12+ characters.'; + } + return null; +} + +module.exports = {passwordError, MIN, PASSPHRASE}; diff --git a/nodejs/views/users.ejs b/nodejs/views/users.ejs index a27ba72..4945909 100755 --- a/nodejs/views/users.ejs +++ b/nodejs/views/users.ejs @@ -82,14 +82,14 @@
- +

- From 3e5590288afb85510d853233a509727238cae039 Mon Sep 17 00:00:00 2001 From: William Mantly Date: Sat, 11 Jul 2026 11:47:08 -0400 Subject: [PATCH 3/9] 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. + +
+
- - - - - -
- -
- - - - - New Entry - - - - -
- - - -
-
- -
- -
-
- -
-
- -
-
- -
- -
- - -
-
- -
- -
- -
-
- -
-
- -
-
- -
- -
- -
-
- -
-
- -
- - - -
- -
- - - -
- -
- -
- -
-
- -
- -
- -
-
Proxy controls
- -
- -
- -
-
- -
-
- -
-
- - -
-
- - -
-
- -
- -
- -
-
- -
-
- -
- -
- -
-
- -
-
- -
- - -
- -
- - -
- -
- - -
- -
- - -
- -
- -
- -
-
- -
-
- -
- - -
- -
- - - - Current: none. - Passwords are stored hashed and never shown here. Leave blank to - keep the current users; entering any lines replaces the whole list. - -
- -
- -
-
-
- - -
- +
- -
- - - - - Proxy List - - +
+ + Proxy List + + -
@@ -612,7 +308,6 @@
- - - - - - + + + + + @@ -676,7 +361,6 @@
- SSL Expire - - Host Name - - target - - Actions - SSL ExpireHost NametargetActions
-
- @@ -724,4 +408,273 @@
+ + + <%- include('bottom') %> From c98214cc768ff1995b439a82010bb7c81e612af4 Mon Sep 17 00:00:00 2001 From: William Mantly Date: Sat, 11 Jul 2026 12:24:29 -0400 Subject: [PATCH 7/9] Host modal: Authentication tab, wildcard-child default, allow-list autocomplete - Default the "Parent Wildcard" challenge type when a wildcard parent exists. - Split Authentication (basic auth + SSO) into its own tab; Access keeps IP allow/deny. - Add GET /api/host/auth-suggestions (authenticated host editors, not just admins) and datalist-backed "type to search + Add" pickers for the SSO allowed-users/groups lists. Verified in a browser (tab present, datalist populated, picker appends deduped). Co-Authored-By: Claude Opus 4.8 --- nodejs/routes/host.js | 30 ++++++++++++++++++- nodejs/views/hosts.ejs | 66 +++++++++++++++++++++++++++++++++++------- 2 files changed, 85 insertions(+), 11 deletions(-) diff --git a/nodejs/routes/host.js b/nodejs/routes/host.js index 424b7d4..ccfe75d 100755 --- a/nodejs/routes/host.js +++ b/nodejs/routes/host.js @@ -1,7 +1,10 @@ 'use strict'; const router = require('express').Router(); -const {Host, Domain} = require('../models').models; +const conf = require('@simpleworkjs/conf'); +const {Host, Domain, User} = require('../models').models; +const {LocalGroup} = require('../models/local_group'); +const {Permission} = require('../models/permission'); const authz = require('../middleware/authz'); const {normalizeHostFeatures} = require('../utils/host_features'); const {collectHostFieldErrors} = require('../utils/hostname_validate'); @@ -25,6 +28,31 @@ function hashHostSecrets(body){ } } +// Autocomplete source for the per-host auth allow-lists (SSO users/groups). +// Available to any authenticated host editor (not just global admins). Groups +// are derived from local groups, existing permission group-subjects, and the +// conf.auth admin/role-map groups. +router.get('/auth-suggestions', async function(req, res, next){ + try{ + let users = []; + try{ users = (await User.list()) || []; }catch(error){ /* none */ } + + let groups = new Set(); + try{ for(let g of await LocalGroup.list()) groups.add(g); }catch(error){ /* none */ } + try{ + for(let p of await Permission.listDetail()){ + if(p.subjectType === 'group' && p.subject) groups.add(p.subject); + } + }catch(error){ /* none */ } + for(let g of (conf.auth && conf.auth.adminGroups) || []) groups.add(g); + for(let g of Object.keys((conf.auth && conf.auth.groupRoleMap) || {})) groups.add(g); + + return res.json({users, groups: [...groups].sort()}); + }catch(error){ + return next(error); + } +}); + router.get('/', async function(req, res, next){ try{ let results = await Model[req.query.detail ? "listDetail" : "list"](); diff --git a/nodejs/views/hosts.ejs b/nodejs/views/hosts.ejs index 3953c96..918bbfa 100755 --- a/nodejs/views/hosts.ejs +++ b/nodejs/views/hosts.ejs @@ -98,6 +98,29 @@ bootstrap.Tab.getOrCreateInstance(document.getElementById(id)).show(); } + // Append a picked/typed value to one of the SSO allow-list textareas (deduped). + function allowListAdd(input, name){ + let val = (input.value || '').trim(); + if(!val) return; + let $ta = $('#hostForm textarea[name="' + name + '"]'); + let lines = ($ta.val() || '').split(/\r?\n/).map(s => s.trim()).filter(Boolean); + if(lines.indexOf(val) === -1) lines.push(val); + $ta.val(lines.join('\n')); + input.value = ''; + input.focus(); + } + + // Fill the user/group datalists that back the allow-list autocomplete. + function hostLoadAuthSuggestions(){ + app.api.get('host/auth-suggestions', function(error, data){ + if(error || !data) return; + let $u = $('#hostSsoUsers').empty(); + for(let u of (data.users || [])) $u.append($('