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 <noreply@anthropic.com>
97 lines
4.5 KiB
TypeScript
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
|
|
}
|