/* 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, current: unknown): string[] { const now = (current !== null && typeof current === 'object' ? current : {}) as Record 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) } 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 | undefined { for (const key of IDENTIFIERS) { const value = params[key] if (typeof value === 'string' && value.trim() !== '') return value.trim().slice(0, 120) } return undefined }