@@ -6,6 +6,11 @@ import { SettingsService } from './settings-service.js';
66import { registerSettingsRoutes } from './settings-routes.js' ;
77import { brandingSettingsManifest } from './manifests/branding.manifest.js' ;
88import { 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
1015class 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