Files
technitium-console/hub/guards.ts
T
DenozordecandClaude Opus 5.5 8bb1e01936 Hub stage 5: manage each server from one console
Zones, Cache, Blocking, DNS Client, Settings, DHCP and Logs can now be
changed on one chosen server through the hub, with that server's admin
token. Operators manage cache and blocking; admins also zones, settings
and DHCP. Under "All servers", or on a server without an admin token,
the controls are disabled with a hint.

The hub refuses settings/set changes to a server's web service fields
and anything about users, SSO or LDAP; backup, restore, log and stats
deletion and admin/* stay refused. Non-admins get settings with secrets
blanked. Write params travel in the POST body, downloads pass through
with a single-use token, and the audit log names no values.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 22:06:37 +07:00

97 lines
4.5 KiB
TypeScript

/*
Checks the hub makes on what passes through it to a server (stage 5): which `settings/set`
fields it will not forward, which values of `settings/get` a role may see, and which single
identifier of a write may go into the audit log.
Pure functions, so the tests feed them any input.
*/
/*
`settings/set` and the hub's own way in.
The hub reaches a server through that server's web service, with an API token. Changing the
service's bind addresses, ports, TLS or reverse-proxy settings from here can cut the hub off with
no way back except the server's own console, so the hub refuses a change to any `webService*`
field. The console sends the whole Web Service block with every save, unchanged values included,
so a field is refused only when its value DIFFERS from what the server holds now (see
`webServiceChanges`). Users, SSO and LDAP are not part of `settings/set`; a field that looks like
one (`user*`, `sso*`, `ldap*`, `auth*`) is refused outright, in case a server version adds it.
*/
export const WEB_SERVICE_MESSAGE = "Change the web service settings in that server's own console."
const FORBIDDEN_FIELD = /^(user|sso|ldap|auth)/i
const WEB_FIELD = /^webService/
/** True for a field the hub never forwards, whatever its value. */
export const isForbiddenSettingsField = (name: string): boolean => FORBIDDEN_FIELD.test(name)
/* A list as the console sends it (`a,b`, one per line, or `false` for none) or as the server holds it. */
function listOf(value: unknown): string[] {
const items = Array.isArray(value) ? value.map(String) : String(value ?? '').split(/[,\n]/)
return items.map((v) => v.trim()).filter((v) => v !== '' && v !== 'false')
}
/* Whether a submitted text means what the server holds. Booleans and numbers by value, lists as sets, text trimmed. */
function sameSetting(submitted: string, current: unknown): boolean {
if (typeof current === 'boolean') return submitted.trim().toLowerCase() === String(current)
if (typeof current === 'number') return submitted.trim() !== '' && Number(submitted) === current
if (Array.isArray(current)) {
const a = listOf(submitted).sort()
const b = listOf(current).sort()
return a.length === b.length && a.every((v, i) => v === b[i])
}
return submitted.trim() === String(current ?? '').trim()
}
/*
The `webService*` fields of a `settings/set` whose value differs from the server's current
settings (`settings/get`). A field the server does not report is not compared: it would be ignored
there too.
*/
export function webServiceChanges(submitted: Record<string, string>, current: unknown): string[] {
const now = (current !== null && typeof current === 'object' ? current : {}) as Record<string, unknown>
const changed: string[] = []
for (const [name, value] of Object.entries(submitted)) {
if (!WEB_FIELD.test(name) || !Object.hasOwn(now, name)) continue
if (!sameSetting(value, now[name])) changed.push(name)
}
return changed
}
/*
`settings/get` carries secrets: the TSIG shared secrets, the forward proxy password and the two
certificate passwords. A role without `admin` gets a copy with those values blanked (a viewer
needs the screen, not the keys); an admin gets them, since saving the form sends them back. The
blanked copy is for display only and `settings/set` is `admin`-only, so a blank cannot be saved
over a real secret by someone who may not read it.
*/
export function redactSettings(response: unknown): unknown {
if (response === null || typeof response !== 'object') return response
const copy = { ...(response as Record<string, unknown>) }
for (const key of ['webServiceTlsCertificatePassword', 'dnsTlsCertificatePassword']) {
if (typeof copy[key] === 'string' && copy[key] !== '') copy[key] = ''
}
if (Array.isArray(copy.tsigKeys)) {
copy.tsigKeys = copy.tsigKeys.map((k: unknown) => (k !== null && typeof k === 'object' ? { ...k, sharedSecret: '' } : k))
}
const proxy = copy.proxy
if (proxy !== null && typeof proxy === 'object' && typeof (proxy as { password?: unknown }).password === 'string') {
copy.proxy = { ...proxy, password: '' }
}
return copy
}
/*
The one identifier an audit line may carry for a write: a zone, a domain, a name or a scope. Never
the other parameters (records carry data, settings carry passwords and TSIG secrets). Cut short.
*/
const IDENTIFIERS = ['zone', 'domain', 'name', 'scope', 'zones']
export function safeTarget(params: Record<string, string>): string | undefined {
for (const key of IDENTIFIERS) {
const value = params[key]
if (typeof value === 'string' && value.trim() !== '') return value.trim().slice(0, 120)
}
return undefined
}