fix(security): vault integration secrets instead of plaintext config (R1)

PayPal client_secret (and any secret-named integration field) was stored
plaintext in integrations.config, unlike Stripe/door which use the
AES-256-GCM vault. A DB read or backup leaked it in cleartext.

- lib/secrets/vault.ts: canonical generic storeSecret/readSecret +
  isSecretConfigField (the proven Stripe vault logic, generalized).
- saveIntegration: secret-named fields go to the vault, only a *_ref is
  kept in config; blank submit preserves the ref (write-only like
  Stripe); legacy plaintext auto-migrates into the vault on next save.
- paypal/sync reads client_secret from the vault, transitional fallback
  to legacy plaintext until the next save.
- Docs updated (API_REFERENCE, ARCHITECTURE 10.3). 502 tests.
This commit is contained in:
Moheeb Zara
2026-05-18 13:08:17 -07:00
parent 7844c262f8
commit 504586fdcd
6 changed files with 176 additions and 8 deletions
+22
View File
@@ -0,0 +1,22 @@
import { describe, it, expect } from 'vitest'
import { isSecretConfigField } from '@/lib/secrets/vault'
describe('isSecretConfigField', () => {
it('treats credential-named fields as secrets to vault', () => {
for (const k of ['client_secret', 'api_key', 'secret_key', 'webhook_secret', 'signing_secret']) {
expect(isSecretConfigField(k)).toBe(true)
}
})
it('leaves non-secret config fields in plaintext config', () => {
for (const k of ['client_id', 'sandbox', 'mode', 'publishable_key', 'username']) {
expect(isSecretConfigField(k)).toBe(false)
}
})
it('never vaults derived flag/ref keys (no recursion on re-save)', () => {
expect(isSecretConfigField('client_secret_set')).toBe(false)
expect(isSecretConfigField('client_secret_ref')).toBe(false)
expect(isSecretConfigField('api_key_ref')).toBe(false)
})
})