diff --git a/CHANGELOG.md b/CHANGELOG.md index 8916384..b797efc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,12 @@ correspond to git tags (`vX.Y.Z`) and `nodejs/package.json`'s `version`. - Genericized committed defaults in `conf/base.js` and `conf/development.js`: LDAP now defaults to `ldap://localhost` with `dc=example,dc=com`, and OIDC endpoints default to `https://sso.example.com` instead of internal theta42 infrastructure. - The bootstrap `proxyadmin2` account now gets a random, one-time password when `auth.localAdminPass` is unset, instead of the well-known default `proxyadmin2`. The password is printed to the log on first creation and can be made deterministic by setting `auth.localAdminPass` in the secrets file. +### Security +- Sanitized rendered docs HTML via `xss` in `routes/docs.js` so malicious markdown cannot inject scripts or other dangerous markup into the in-app docs viewer. +- The Unix socket JSON-RPC socket is now created with mode `660` instead of world-writable `777`. + ### Fixed +- An existing single-label subdomain host (e.g. `sso.nl.wgnode.com`) could not be attached to a wildcard cert added later (e.g. `*.nl.wgnode.com`): `Host.lookUpWildcardParent()` only checked the wildcard-as-child position (the wildcard's own base domain) and missed the far more common wildcard-as-sibling case, so the edit form's "Parent Wildcard" option stayed permanently greyed out. It now checks both positions. - The global error handler no longer leaks `err.keys`, stack traces, or other internal details in JSON responses; only `name` and `message` are returned to clients. - `DEPLOYMENT.md` and `docs/docker.md` now correctly describe the `CONF_SECRETS` env-var mechanism instead of the old symlink behavior. @@ -123,7 +128,7 @@ First tagged release. Establishes the `vX.Y.Z` tag convention that the in-app up - Standalone backup script (`ops/backup.sh`) for deployments not using theta-env's orchestrator — snapshots Redis and `./config`, with retention. - Admin-only in-app banner that checks GitHub releases every 24h and surfaces available updates. -[Unreleased]: https://github.com/theta42/proxy/compare/v1.1.15...HEAD +[Unreleased]: https://github.com/theta42/proxy/compare/v1.1.16...HEAD [1.1.15]: https://github.com/theta42/proxy/compare/v1.1.14...v1.1.15 [1.1.14]: https://github.com/theta42/proxy/compare/v1.1.13...v1.1.14 [1.1.13]: https://github.com/theta42/proxy/compare/v1.1.12...v1.1.13 diff --git a/nodejs/models/host.js b/nodejs/models/host.js index 12c52fb..5abc762 100755 --- a/nodejs/models/host.js +++ b/nodejs/models/host.js @@ -518,24 +518,38 @@ class Host extends Table{ if(parent && parent['*'] && parent['*']['#record']) return parent['*']['#record']; } - // Find the wildcard covering @host as its own base domain (e.g. - // "*.cool.mysite.com" for host="cool.mysite.com"), regardless of whether - // @host is already registered as its own host. Unlike lookUp(), which - // walks to and returns @host's own exact-match leaf when one exists, this - // walks to that exact position and looks one level deeper at its "*" - // child -- the sibling wildcard slot -- so it still finds the parent - // wildcard even when @host already has its own (non-wildcard) record. - // Used when attaching an already-created host to a wildcard after the - // fact (see update() below); Host.create()'s own wildcardChild handling - // can keep using plain lookUp() since a host being newly created hasn't - // claimed its own leaf yet. + // Find the wildcard that could cover @host, regardless of whether @host is + // already registered as its own host. Unlike lookUp(), which walks to and + // returns @host's own exact-match leaf when one exists, this keeps looking + // for a sibling/child "*" slot, so it still finds the parent wildcard even + // when @host already has its own (non-wildcard) record. Used when attaching + // an already-created host to a wildcard after the fact (see update() below); + // Host.create()'s own wildcardChild handling can keep using plain lookUp() + // since a host being newly created hasn't claimed its own leaf yet. + // + // Two tree positions qualify, and we must check BOTH: + // 1. Child "*" of @host's own node -- @host is the wildcard's base domain + // (e.g. "*.cool.mysite.com" covers host="cool.mysite.com"). + // 2. Sibling "*" one level up -- @host is a single-label subdomain of the + // wildcard (e.g. "*.nl.wgnode.com" covers host="sso.nl.wgnode.com"). + // Case 2 is the common one and was previously missed: the walk consumed the + // leftmost label ("sso") and only inspected that leaf's "*" child, so an + // already-existing sibling subdomain could never be attached to its wildcard. static lookUpWildcardParent(host){ let place = this.lookUpObj; + let parent = undefined; for(let fragment of host.split('.').reverse()){ - if(!place[fragment]) return undefined; + // @host may have no leaf of its own (brand-new subdomain); that case + // is already handled by plain lookUp()'s wildcard fallback in the + // caller, so just stop -- we've still tracked `parent` for case 2. + if(!place[fragment]){ place = undefined; break; } + parent = place; place = place[fragment]; } - if(place['*'] && place['*']['#record']) return place['*']['#record']; + // Case 1: wildcard is a child of @host's own node. + if(place && place['*'] && place['*']['#record']) return place['*']['#record']; + // Case 2: wildcard is a sibling of @host's leftmost label. + if(parent && parent['*'] && parent['*']['#record']) return parent['*']['#record']; } static async lookUpReady(){ diff --git a/nodejs/test/unit/host_lookup.test.js b/nodejs/test/unit/host_lookup.test.js index 71f85f2..58a461c 100644 --- a/nodejs/test/unit/host_lookup.test.js +++ b/nodejs/test/unit/host_lookup.test.js @@ -185,6 +185,35 @@ describe('Host wildcard base-domain lookup', () => { await populateTree(Host, ['*.cool.mysite.com']); assert.strictEqual(Host.lookUpWildcardParent('other.example.com'), undefined); }); + + // Regression: the common case -- an already-existing single-label subdomain + // (its own auto-SSL/HTTP-01 host) sitting beside a wildcard, e.g. + // sso.nl.wgnode.com under *.nl.wgnode.com. The wildcard is a SIBLING of the + // subdomain's leftmost label, not a child of its node, so the old walk (which + // consumed "sso" and only checked that leaf's "*" child) never found it and + // the edit form's "Parent Wildcard" option stayed permanently greyed out. + test('lookUpWildcardParent finds a sibling wildcard for an existing single-label subdomain', async () => { + await populateTree(Host, ['sso.nl.wgnode.com', '*.nl.wgnode.com']); + const result = Host.lookUpWildcardParent('sso.nl.wgnode.com'); + assert.ok(result, 'Should find the sibling wildcard'); + assert.strictEqual(result.host, '*.nl.wgnode.com'); + }); + + // A subdomain with no leaf of its own (never created) is deliberately NOT + // this method's job -- the walk stops before reaching the sibling "*" slot. + // The route resolves that case via plain lookUp()'s wildcard fallback first + // (covered in the route-fallback describe block below). + test('lookUpWildcardParent returns undefined for a subdomain with no leaf of its own', async () => { + await populateTree(Host, ['*.nl.wgnode.com']); + assert.strictEqual(Host.lookUpWildcardParent('api.nl.wgnode.com'), undefined); + }); + + test('lookUpWildcardParent does not treat a deeper wildcard as covering a shallower host', async () => { + // *.deep.nl.wgnode.com must NOT be offered as a parent for sso.nl.wgnode.com + // (a single-level wildcard covers only its own direct children). + await populateTree(Host, ['sso.nl.wgnode.com', '*.deep.nl.wgnode.com']); + assert.strictEqual(Host.lookUpWildcardParent('sso.nl.wgnode.com'), undefined); + }); }); /** @@ -231,6 +260,24 @@ describe('Host wildcard-parent route fallback (lookUp then lookUpWildcardParent) await populateTree(Host, ['cool.mysite.com']); assert.strictEqual(findWildcardParent('cool.mysite.com'), null); }); + + // The user's scenario: sso.nl.wgnode.com already exists as its own host, and + // a *.nl.wgnode.com wildcard is added afterward. lookUp() resolves to sso's + // own (non-wildcard) leaf, so the fallback to lookUpWildcardParent() is what + // surfaces the sibling wildcard and lets the edit form offer conversion. + test('finds the sibling wildcard for an already-existing single-label subdomain', async () => { + await populateTree(Host, ['sso.nl.wgnode.com', '*.nl.wgnode.com']); + const result = findWildcardParent('sso.nl.wgnode.com'); + assert.ok(result); + assert.strictEqual(result.host, '*.nl.wgnode.com'); + }); + + test('finds the sibling wildcard for a never-created single-label subdomain', async () => { + await populateTree(Host, ['*.nl.wgnode.com']); + const result = findWildcardParent('api.nl.wgnode.com'); + assert.ok(result); + assert.strictEqual(result.host, '*.nl.wgnode.com'); + }); }); /** @@ -265,11 +312,17 @@ function createMockHostClassWithWildcardParentFix() { static lookUpWildcardParent(host) { let place = this.lookUpObj; + let parent = undefined; for(let fragment of host.split('.').reverse()){ - if(!place[fragment]) return undefined; + if(!place[fragment]){ place = undefined; break; } + parent = place; place = place[fragment]; } - if(place['*'] && place['*']['#record']) return place['*']['#record']; + // Case 1: wildcard is a child of host's own node (base domain). + if(place && place['*'] && place['*']['#record']) return place['*']['#record']; + // Case 2: wildcard is a sibling of host's leftmost label + // (single-label subdomain, e.g. sso.nl.wgnode.com -> *.nl.wgnode.com). + if(parent && parent['*'] && parent['*']['#record']) return parent['*']['#record']; } }; }