Skip to content

Commit 23bc6e1

Browse files
huangyiireneclaude
andauthored
fix(service-settings): redact encrypted setting values at the REST read boundary (#7522) (#7554)
GET /api/settings/:namespace returned the plaintext of every encrypted setting in the namespace — in values.<key>.value and repeated once more inside each cascadeChain entry — for both specifier flavours (type: 'password' and an explicit encrypted: true). Storage was always correct: sys_setting.value is null, value_enc holds a sec_ handle, and sys_secret holds aes-256-gcm ciphertext. The leak was entirely on the way out, where there was no redaction step at all. The endpoint requires setup.access, so this is defense-in-depth rather than privilege escalation — but every operator, integration, proxy, browser cache and HAR capture on that response path received the cleartext of every secret in the namespace, defeating the point of the value_enc + sys_secret split. The fix lives at the REST boundary and nowhere else, reusing the mask convention ADR-0100 pins for encrypted FIELDS on the generic CRUD path rather than inventing a sentinel: - read — a set secret is served as SETTINGS_SECRET_MASK (the same eight bullets as objectql's SECRET_MASK); an unset one stays null, so the response is presence-preserving. cascadeChain is masked entry by entry. source, locked, lockedReason and the 409 SETTINGS_LOCKED env-lock behaviour are untouched. - write — a submitted value equal to the mask means "unchanged" and the key is dropped from the patch, so a form echoing what it read cannot overwrite the stored secret with the mask's literal text. Scoped to secret keys, so a plain setting whose value genuinely is eight bullets still writes verbatim. PUT's own response is redacted the same way — it carries resolved values too, including cascade entries the caller never submitted. SettingsService still decrypts. materialiseRow(), get(), getNamespace(), snapshotOf() and createClient() keep handing real plaintext to in-process consumers, because the mail/sms/storage/auth plugins read their credentials through exactly that path; a test pins that round-trip so this cannot be "fixed" one layer down. New public API: SETTINGS_SECRET_MASK, redactSecretValues, dropEchoedSecretMasks and SettingsService.secretKeysOf(namespace) — the last one so the boundary reads the SAME encrypted-key set setMany consults, which is what stops the two sides drifting into "encrypted on write, cleartext on read". Claude-Session: https://claude.ai/code/session_01CMcwKmYDRFjtfT8jCRRXwB Co-authored-by: Claude <noreply@anthropic.com>
1 parent f7a60d9 commit 23bc6e1

6 files changed

Lines changed: 554 additions & 3 deletions

File tree

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
---
2+
"@objectstack/service-settings": patch
3+
---
4+
5+
fix(service-settings): redact encrypted setting values at the REST read boundary (#7522)
6+
7+
`GET /api/settings/:namespace` returned the **plaintext** of every encrypted
8+
setting in the namespace — in `values.<key>.value` and repeated once more in each
9+
`cascadeChain` entry. Both specifier flavours were affected: `type: 'password'`
10+
and an explicit `encrypted: true`. Storage was never the problem
11+
(`sys_setting.value` is null, `value_enc` holds a `sec_` handle, and `sys_secret`
12+
holds aes-256-gcm ciphertext); the leak was entirely on the way out.
13+
14+
The endpoint requires `setup.access`, so this is defense-in-depth rather than
15+
privilege escalation — but every operator, integration, proxy, browser cache and
16+
HAR capture on that response path received the cleartext of every secret in the
17+
namespace, which is precisely what the `value_enc` + `sys_secret` split exists to
18+
prevent.
19+
20+
**What changed.** The REST handlers now redact before the payload leaves the
21+
process, reusing the mask convention ADR-0100 already pins for encrypted
22+
*fields* on the generic CRUD path rather than inventing a sentinel:
23+
24+
- **Read** — a set secret is served as `SETTINGS_SECRET_MASK` (`••••••••`, the
25+
same eight bullets as objectql's `SECRET_MASK`); an unset one stays `null`. The
26+
redaction is presence-preserving, so "configured vs not configured" is still
27+
readable, and it covers `cascadeChain` entry by entry as well as the effective
28+
value. `source`, `locked`, `lockedReason` and the `409 SETTINGS_LOCKED`
29+
env-lock behaviour are untouched.
30+
- **Write** — a submitted value equal to the mask means "unchanged" and the key
31+
is dropped from the patch, so a form round-trip that echoes what it read does
32+
not overwrite the stored secret with the mask's literal text. The drop is
33+
scoped to secret keys: a plain setting whose value genuinely is eight bullets
34+
still writes verbatim. `PUT`'s own response is redacted the same way — it
35+
carries resolved values too, including cascade entries the caller never
36+
submitted.
37+
38+
**What deliberately did not change.** `SettingsService` still decrypts.
39+
`materialiseRow()`, `get()`, `getNamespace()`, `snapshotOf()` and `createClient()`
40+
keep returning real plaintext, because the mail / sms / storage / auth plugins
41+
read their credentials through exactly that path. Redaction belongs to the REST
42+
boundary and nowhere else; a test pins the in-process round-trip so this cannot
43+
be "fixed" one layer down.
44+
45+
New public API on `@objectstack/service-settings`: `SETTINGS_SECRET_MASK`,
46+
`redactSecretValues`, `dropEchoedSecretMasks`, and
47+
`SettingsService.secretKeysOf(namespace)` — published so a client can recognise a
48+
masked read instead of comparing against a hard-coded string.

packages/services/service-settings/src/index.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,17 @@ export {
5555
registerSettingsRoutes,
5656
type SettingsRoutesOptions,
5757
} from './settings-routes.js';
58+
// #7522 — the REST read mask for encrypted settings, plus the two halves of the
59+
// boundary it defines. Published because a client has to be able to RECOGNISE a
60+
// masked read: the console renders "configured" from it and echoes it back
61+
// unchanged on save, and comparing against a string hard-coded in the console is
62+
// exactly the drift this export prevents. The SERVICE layer is unaffected — it
63+
// still hands real plaintext to in-process consumers; see the module header.
64+
export {
65+
SETTINGS_SECRET_MASK,
66+
redactSecretValues,
67+
dropEchoedSecretMasks,
68+
} from './settings-secret-redaction.js';
5869
export {
5970
settingsObjects,
6071
settingsPluginManifestHeader,

packages/services/service-settings/src/settings-routes.test.ts

Lines changed: 312 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,11 @@ import { SettingsService } from './settings-service.js';
66
import { registerSettingsRoutes } from './settings-routes.js';
77
import { brandingSettingsManifest } from './manifests/branding.manifest.js';
88
import { localizationSettingsManifest } from './manifests/localization.manifest.js';
9+
import { SETTINGS_SECRET_MASK } from './settings-secret-redaction.js';
10+
// `InMemoryCryptoProvider` is a value-only alias (`export const … = LocalCryptoProvider`),
11+
// so the class itself is the one that can also be spelled as a type.
12+
import { LocalCryptoProvider } from './local-crypto-provider.js';
13+
import type { SettingsManifest } from '@objectstack/spec/system';
914

1015
class MockHttp implements IHttpServer {
1116
routes = new Map<string, RouteHandler>();
@@ -304,3 +309,310 @@ describe('settings-routes', () => {
304309
]);
305310
});
306311
});
312+
313+
// ─────────────────────────────────────────────────────────────────────────────
314+
// #7522 — encrypted settings are REDACTED at the REST read boundary.
315+
//
316+
// The service decrypts on purpose and keeps doing so; these cases pin the
317+
// boundary in both directions: nothing ciphertext-backed leaves over HTTP, and
318+
// an in-process consumer still receives the real plaintext. Both specifier
319+
// flavours are covered — `type: 'password'` (implicitly encrypted) and an
320+
// explicit `encrypted: true` on a non-password type — because `registerManifest`
321+
// folds them into one set and a fix that only saw one of them would still leak.
322+
// ─────────────────────────────────────────────────────────────────────────────
323+
324+
const SMTP_PLAINTEXT = 'smtp-pa55word-plaintext';
325+
const TOKEN_PLAINTEXT = 'webhook-token-plaintext';
326+
const GLOBAL_PLAINTEXT = 'global-scope-plaintext';
327+
328+
/** Two secret flavours + one plain control key, resolved down the full cascade. */
329+
const secretsManifest: SettingsManifest = {
330+
namespace: 'secretsns',
331+
version: 1,
332+
label: 'Secrets',
333+
// `user` so the cascade walks global → tenant → user and `cascadeChain`
334+
// carries more than one entry to leak through.
335+
scope: 'user',
336+
readPermission: 'setup.access',
337+
writePermission: 'setup.write',
338+
specifiers: [
339+
// Flavour A — implicitly encrypted because the TYPE is `password`.
340+
{ type: 'password', key: 'smtp_password', label: 'SMTP password', required: false },
341+
// Flavour B — an ordinary type carrying an explicit `encrypted: true`.
342+
{ type: 'text', key: 'webhook_token', label: 'Webhook token', required: false, encrypted: true },
343+
// Control — never encrypted, must survive the redaction untouched.
344+
{ type: 'text', key: 'smtp_host', label: 'Host', required: false, default: 'smtp.example.com' },
345+
],
346+
};
347+
348+
const secretAdmin = () => ({
349+
enforced: true,
350+
permissions: ['setup.access', 'setup.write'],
351+
userId: 'usr_1',
352+
});
353+
354+
function makeSecretStack() {
355+
const secretRows = new Map<string, any>();
356+
const cryptoProvider = new LocalCryptoProvider();
357+
const svc = new SettingsService({
358+
env: {},
359+
cryptoProvider,
360+
secretStore: {
361+
async insert(row) { secretRows.set(row.id, row); return { id: row.id }; },
362+
async get(id) { return secretRows.get(id) ?? null; },
363+
async update(id, patch) { secretRows.set(id, { ...secretRows.get(id), ...patch }); },
364+
},
365+
});
366+
svc.registerManifest(secretsManifest);
367+
const http = new MockHttp();
368+
registerSettingsRoutes(http, svc, { contextFromRequest: secretAdmin });
369+
return { svc, http, secretRows, cryptoProvider };
370+
}
371+
372+
/**
373+
* Seed an upper-scope (`global`) encrypted row directly, exactly as `setMany`
374+
* would write it — ciphertext in the secret store, only the `sec_` handle on the
375+
* setting row. The public write path resolves one scope per key, so this is the
376+
* only way to give `cascadeChain` a second ciphertext-backed entry.
377+
*/
378+
async function seedGlobalSecret(
379+
svc: SettingsService,
380+
secretRows: Map<string, any>,
381+
cryptoProvider: LocalCryptoProvider,
382+
key: string,
383+
plaintext: string,
384+
) {
385+
const handle = await cryptoProvider.encrypt(plaintext, { namespace: 'secretsns', key });
386+
secretRows.set(handle.id, {
387+
id: handle.id,
388+
namespace: 'secretsns',
389+
key,
390+
kms_key_id: handle.kmsKeyId,
391+
alg: handle.alg,
392+
version: handle.version,
393+
ciphertext: handle.ciphertext,
394+
});
395+
await (svc as any).upsertRow({
396+
namespace: 'secretsns',
397+
key,
398+
scope: 'global',
399+
user_id: null,
400+
value: null,
401+
value_enc: handle.id,
402+
encrypted: true,
403+
});
404+
return handle.id;
405+
}
406+
407+
describe('settings-routes — #7522 encrypted values are redacted at the REST boundary', () => {
408+
it('GET /:ns leaks no ciphertext-backed cleartext — both flavours, value AND cascadeChain', async () => {
409+
const { svc, http, secretRows, cryptoProvider } = makeSecretStack();
410+
411+
// Written through the trusted in-process path, the way a seed/bootstrap or
412+
// a plugin would.
413+
await svc.set('secretsns', 'smtp_password', SMTP_PLAINTEXT, { userId: 'usr_1' });
414+
await svc.set('secretsns', 'webhook_token', TOKEN_PLAINTEXT, { userId: 'usr_1' });
415+
await seedGlobalSecret(svc, secretRows, cryptoProvider, 'smtp_password', GLOBAL_PLAINTEXT);
416+
417+
const h = http.routes.get('GET /api/settings/:namespace')!;
418+
const { req, res, state } = makeReqRes({ params: { namespace: 'secretsns' } });
419+
await h(req, res);
420+
421+
expect(state.status).toBe(200);
422+
423+
// The whole-body assertion is the one that cannot be satisfied by masking
424+
// only the places we happened to think of.
425+
const wire = JSON.stringify(state.body);
426+
expect(wire).not.toContain(SMTP_PLAINTEXT);
427+
expect(wire).not.toContain(TOKEN_PLAINTEXT);
428+
expect(wire).not.toContain(GLOBAL_PLAINTEXT);
429+
430+
// …and the specific places the issue names, so a future regression says
431+
// WHICH surface broke rather than just "a string appeared".
432+
const values = state.body.data.values;
433+
expect(values.smtp_password.value).toBe(SETTINGS_SECRET_MASK);
434+
expect(values.webhook_token.value).toBe(SETTINGS_SECRET_MASK);
435+
for (const key of ['smtp_password', 'webhook_token']) {
436+
const chain = values[key].cascadeChain as Array<{ scope: string; value: unknown }>;
437+
expect(chain.length).toBeGreaterThan(0);
438+
for (const entry of chain) {
439+
expect([SETTINGS_SECRET_MASK, null]).toContain(entry.value);
440+
}
441+
}
442+
// The global entry is present and masked — a second ciphertext-backed
443+
// scope, not an artefact of the user row being the only one.
444+
expect(values.smtp_password.cascadeChain).toEqual(
445+
expect.arrayContaining([expect.objectContaining({ scope: 'global', value: SETTINGS_SECRET_MASK })]),
446+
);
447+
448+
// The non-encrypted control key is untouched.
449+
expect(values.smtp_host.value).toBe('smtp.example.com');
450+
});
451+
452+
it('redaction is presence-preserving: an UNSET secret stays null, not a mask', async () => {
453+
const { svc, http } = makeSecretStack();
454+
await svc.set('secretsns', 'smtp_password', SMTP_PLAINTEXT, { userId: 'usr_1' });
455+
456+
const h = http.routes.get('GET /api/settings/:namespace')!;
457+
const { req, res, state } = makeReqRes({ params: { namespace: 'secretsns' } });
458+
await h(req, res);
459+
460+
// Set vs unset stays observable — the console renders "configured" from it.
461+
expect(state.body.data.values.smtp_password.value).toBe(SETTINGS_SECRET_MASK);
462+
expect(state.body.data.values.webhook_token.value).toBeNull();
463+
});
464+
465+
it('`source` and `locked` survive the redaction unchanged', async () => {
466+
const { svc, http } = makeSecretStack();
467+
await svc.set('secretsns', 'webhook_token', TOKEN_PLAINTEXT, { userId: 'usr_1' });
468+
469+
const h = http.routes.get('GET /api/settings/:namespace')!;
470+
const { req, res, state } = makeReqRes({ params: { namespace: 'secretsns' } });
471+
await h(req, res);
472+
473+
expect(state.body.data.values.webhook_token.source).toBe('user');
474+
expect(state.body.data.values.webhook_token.locked).toBe(false);
475+
expect(state.body.data.values.smtp_host.source).toBe('default');
476+
});
477+
478+
it('an env-locked secret is masked while `source: env` / `locked` / 409 SETTINGS_LOCKED are unchanged', async () => {
479+
// The env override is itself a secret value; it must not ride out on the
480+
// read path either, and the lock affordances must keep working.
481+
const svc = new SettingsService({ env: { OS_SECRETSNS_SMTP_PASSWORD: 'env-supplied-secret' } });
482+
svc.registerManifest(secretsManifest);
483+
const http = new MockHttp();
484+
registerSettingsRoutes(http, svc, { contextFromRequest: secretAdmin });
485+
486+
const read = http.routes.get('GET /api/settings/:namespace')!;
487+
const r1 = makeReqRes({ params: { namespace: 'secretsns' } });
488+
await read(r1.req, r1.res);
489+
expect(JSON.stringify(r1.state.body)).not.toContain('env-supplied-secret');
490+
expect(r1.state.body.data.values.smtp_password.value).toBe(SETTINGS_SECRET_MASK);
491+
expect(r1.state.body.data.values.smtp_password.source).toBe('env');
492+
expect(r1.state.body.data.values.smtp_password.locked).toBe(true);
493+
expect(r1.state.body.data.values.smtp_password.lockedReason).toContain('OS_SECRETSNS_SMTP_PASSWORD');
494+
expect(r1.state.body.data.values.smtp_password.cascadeChain).toEqual([
495+
expect.objectContaining({ scope: 'env', value: SETTINGS_SECRET_MASK, locked: true }),
496+
]);
497+
498+
const write = http.routes.get('PUT /api/settings/:namespace')!;
499+
const r2 = makeReqRes({ params: { namespace: 'secretsns' }, body: { smtp_password: 'new' } });
500+
await write(r2.req, r2.res);
501+
expect(r2.state.status).toBe(409);
502+
expect(r2.state.body.error.code).toBe('SETTINGS_LOCKED');
503+
});
504+
505+
// ── the echoed-mask write, i.e. the second bug a redaction fix introduces ──
506+
507+
it('PUTting the echoed mask back is a NO-OP — the stored secret is not overwritten', async () => {
508+
const { svc, http, secretRows } = makeSecretStack();
509+
await svc.set('secretsns', 'smtp_password', SMTP_PLAINTEXT, { userId: 'usr_1' });
510+
const handlesBefore = [...secretRows.keys()];
511+
512+
const h = http.routes.get('PUT /api/settings/:namespace')!;
513+
const { req, res, state } = makeReqRes({
514+
params: { namespace: 'secretsns' },
515+
body: { smtp_password: SETTINGS_SECRET_MASK },
516+
});
517+
await h(req, res);
518+
519+
expect(state.status).toBe(200);
520+
expect(state.body.error).toBeUndefined();
521+
// No new ciphertext row: the mask was never encrypted and stored.
522+
expect([...secretRows.keys()]).toEqual(handlesBefore);
523+
// And the in-process read still yields the ORIGINAL plaintext — not the
524+
// mask's literal text, which is what an unguarded write would have stored
525+
// (and which would decrypt back to itself, so nothing would look wrong
526+
// until the SMTP login failed).
527+
const resolved = await svc.get<string>('secretsns', 'smtp_password', { userId: 'usr_1' });
528+
expect(resolved.value).toBe(SMTP_PLAINTEXT);
529+
expect(resolved.value).not.toBe(SETTINGS_SECRET_MASK);
530+
});
531+
532+
it('the echoed mask inside the read-shape {values:{k:{value}}} envelope is a no-op too', async () => {
533+
const { svc, http } = makeSecretStack();
534+
await svc.set('secretsns', 'webhook_token', TOKEN_PLAINTEXT, { userId: 'usr_1' });
535+
536+
const h = http.routes.get('PUT /api/settings/:namespace')!;
537+
// Exactly what GET now returns, echoed back wholesale by a form save.
538+
const { req, res, state } = makeReqRes({
539+
params: { namespace: 'secretsns' },
540+
body: {
541+
values: {
542+
webhook_token: { value: SETTINGS_SECRET_MASK, source: 'user', locked: false },
543+
},
544+
},
545+
});
546+
await h(req, res);
547+
548+
expect(state.status).toBe(200);
549+
expect((await svc.get<string>('secretsns', 'webhook_token', { userId: 'usr_1' })).value)
550+
.toBe(TOKEN_PLAINTEXT);
551+
});
552+
553+
it('a REAL new secret still writes, and the write RESPONSE is redacted too', async () => {
554+
const { svc, http } = makeSecretStack();
555+
await svc.set('secretsns', 'smtp_password', SMTP_PLAINTEXT, { userId: 'usr_1' });
556+
557+
const h = http.routes.get('PUT /api/settings/:namespace')!;
558+
const { req, res, state } = makeReqRes({
559+
params: { namespace: 'secretsns' },
560+
body: { smtp_password: 'a-genuinely-new-secret' },
561+
});
562+
await h(req, res);
563+
564+
expect(state.status).toBe(200);
565+
// The write took effect in the store…
566+
expect((await svc.get<string>('secretsns', 'smtp_password', { userId: 'usr_1' })).value)
567+
.toBe('a-genuinely-new-secret');
568+
// …but the response body does not echo it back over the wire.
569+
expect(JSON.stringify(state.body)).not.toContain('a-genuinely-new-secret');
570+
expect(state.body.data.values.smtp_password.value).toBe(SETTINGS_SECRET_MASK);
571+
});
572+
573+
it('a non-encrypted key whose value genuinely IS the mask is written verbatim', async () => {
574+
// The drop is scoped to secret keys — it must not swallow a legal write.
575+
const { svc, http } = makeSecretStack();
576+
577+
const h = http.routes.get('PUT /api/settings/:namespace')!;
578+
const { req, res, state } = makeReqRes({
579+
params: { namespace: 'secretsns' },
580+
body: { smtp_host: SETTINGS_SECRET_MASK },
581+
});
582+
await h(req, res);
583+
584+
expect(state.status).toBe(200);
585+
expect((await svc.get<string>('secretsns', 'smtp_host', { userId: 'usr_1' })).value)
586+
.toBe(SETTINGS_SECRET_MASK);
587+
});
588+
589+
// ── the other half of the boundary: the service layer is NOT redacted ──────
590+
591+
it('in-process consumers still receive REAL plaintext (createClient / snapshotOf)', async () => {
592+
// This is the guard against someone later "fixing" #7522 in the service
593+
// layer: the mail/sms/storage/auth plugins read their credentials through
594+
// exactly this path, and a mask here would break every one of them.
595+
const { svc } = makeSecretStack();
596+
await svc.set('secretsns', 'smtp_password', SMTP_PLAINTEXT, { userId: 'usr_1' });
597+
await svc.set('secretsns', 'webhook_token', TOKEN_PLAINTEXT, { userId: 'usr_1' });
598+
599+
const client = await svc.createClient('secretsns', { ctx: { userId: 'usr_1' } });
600+
expect(client.current.smtp_password).toBe(SMTP_PLAINTEXT);
601+
expect(client.get('webhook_token')).toBe(TOKEN_PLAINTEXT);
602+
603+
// …and so does the raw service read the routes wrap.
604+
const payload = await svc.getNamespace('secretsns', { userId: 'usr_1' });
605+
expect(payload.values.smtp_password.value).toBe(SMTP_PLAINTEXT);
606+
expect(payload.values.webhook_token.value).toBe(TOKEN_PLAINTEXT);
607+
client.dispose();
608+
});
609+
610+
it('secretKeysOf reports both flavours and refuses an unknown namespace', async () => {
611+
const { svc } = makeSecretStack();
612+
expect([...svc.secretKeysOf('secretsns')].sort()).toEqual(['smtp_password', 'webhook_token']);
613+
// Fail-closed: "unknown namespace" must never answer "nothing is secret".
614+
expect(() => svc.secretKeysOf('nope')).toThrow(
615+
expect.objectContaining({ code: 'SETTINGS_UNKNOWN_NAMESPACE' }),
616+
);
617+
});
618+
});

0 commit comments

Comments
 (0)