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)
})
})
+10 -1
View File
@@ -1,5 +1,7 @@
import { NextRequest, NextResponse } from 'next/server'
import { createClient } from '@/lib/supabase/server'
import { createAdminClient } from '@/lib/supabase/admin'
import { readSecret } from '@/lib/secrets/vault'
// PayPal Transaction Search API (v1)
// Docs: https://developer.paypal.com/docs/api/transaction-search/v1/
@@ -72,7 +74,14 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ error: 'PayPal not connected' }, { status: 400 })
}
const { client_id, client_secret, sandbox } = integration.config as Record<string, string>
const cfg = integration.config as Record<string, string>
const { client_id, sandbox } = cfg
// client_secret lives in the AES-256-GCM vault (referenced by client_secret_ref).
// Legacy fallback: integrations saved before vaulting still have a plaintext
// client_secret; it is auto-migrated on the next save in Settings.
const client_secret = cfg.client_secret_ref
? await readSecret(createAdminClient(), member.space_id, cfg.client_secret_ref)
: (cfg.client_secret ?? null)
if (!client_id || !client_secret) {
return NextResponse.json({ error: 'PayPal credentials incomplete' }, { status: 400 })
}
+1
View File
@@ -682,6 +682,7 @@ async function saveIntegration(
**Behavior**:
- Upserts on `(space_id, platform)`
- Stores config with `_set` indicators for each field
- Secret-named fields (`client_secret`, `*_secret`, `api_key`, `secret_key`) are NEVER stored in `integrations.config` in plaintext: they go to the AES-256-GCM secrets vault and only a `<field>_ref` id is kept (same vault Stripe/door use). A blank submit preserves the existing ref (write-only, like Stripe); any legacy plaintext secret already on file is auto-migrated into the vault on the next save. The PayPal sync route reads `client_secret` from the vault (with a transitional fallback to legacy plaintext until the next save).
---
+4 -4
View File
@@ -396,10 +396,10 @@ The admin client (`SUPABASE_SERVICE_ROLE_KEY`) is used ONLY for:
### 10.3 Secrets Storage
Integration credentials stored in `integrations.config` (JSONB):
- Values stored with `_set` indicators
- UI shows masked values
- Consider encryption at rest for production
Integration credentials:
- Secret-named fields (`client_secret`, `*_secret`, `api_key`, `secret_key`) are encrypted at rest in the AES-256-GCM secrets vault (`lib/secrets/vault.ts`, same vault Stripe + door use); only a `<field>_ref` id is kept in `integrations.config`. Server-side decrypt only, never returned to the client.
- Non-secret fields stored in `integrations.config` (JSONB) with `_set` indicators; UI shows connected state via the flags, never the value.
- Legacy plaintext secrets (pre-vault) are auto-migrated into the vault on the next `saveIntegration`.
---
+58 -3
View File
@@ -2,6 +2,8 @@
import { revalidatePath } from 'next/cache'
import { createClient } from '@/lib/supabase/server'
import { createAdminClient } from '@/lib/supabase/admin'
import { storeSecret, isSecretConfigField } from '@/lib/secrets/vault'
import { requireMemberWithRole, parseInput } from '@/lib/auth-helpers'
import {
updateSpaceSettingsSchema,
@@ -68,13 +70,66 @@ export async function saveIntegration(platform: string, config: Record<string, s
const auth = await requireMemberWithRole(supabase, ADMIN_ONLY, 'Admin access required')
if (!auth.ok) return { error: auth.error }
const { member } = auth
const {
data: { user },
} = await supabase.auth.getUser()
// Store provided values; also store boolean "_set" flags so the UI can show
// a "connected" state without leaking the secret back to the client.
// Existing config: lets us carry a stored secret ref forward when the admin
// leaves a credential field blank (write-only, like Stripe), and lets us
// auto-migrate any legacy plaintext secret into the vault on the next save.
const { data: existingRow } = await supabase
.from('integrations')
.select('config')
.eq('space_id', member.space_id)
.eq('platform', v.data.platform)
.maybeSingle()
const existing = (existingRow?.config as Record<string, string> | null) ?? {}
const admin = createAdminClient()
// Credentials NEVER land in integrations.config in plaintext: secret-named
// fields go to the AES-256-GCM vault and only a *_ref id is kept. Non-secret
// fields are stored verbatim. *_set flags let the UI show "connected"
// without the value being readable.
const safeConfig: Record<string, string> = {}
let connected = false
for (const [key, value] of Object.entries(v.data.config)) {
if (isSecretConfigField(key)) continue // handled below
safeConfig[key] = value ?? ''
safeConfig[`${key}_set`] = value && value.length > 0 ? 'true' : 'false'
if (value && value.length > 0) connected = true
}
// Determine the secret fields this platform may carry: those provided now
// plus any already on file (so a blank submit preserves / migrates them).
const secretKeys = new Set<string>()
for (const k of Object.keys(v.data.config)) if (isSecretConfigField(k)) secretKeys.add(k)
for (const k of Object.keys(existing)) {
const base = k.replace(/_ref$/, '')
if (isSecretConfigField(base)) secretKeys.add(base)
}
for (const key of secretKeys) {
const provided = v.data.config[key]
let ref: string | null = existing[`${key}_ref`] ?? null
if (provided && provided.length > 0) {
ref = await storeSecret(admin, member.space_id, `${v.data.platform} ${key}`, provided, user?.id ?? null)
if (!ref) return { error: `Could not securely store ${key}.` }
} else if (!ref && existing[key] && existing[key].length > 0) {
// Legacy plaintext on file (pre-vault). Migrate it now, then drop it.
ref = await storeSecret(admin, member.space_id, `${v.data.platform} ${key}`, existing[key], user?.id ?? null)
if (!ref) return { error: `Could not migrate ${key} into the vault.` }
}
if (ref) {
safeConfig[`${key}_ref`] = ref
safeConfig[`${key}_set`] = 'true'
connected = true
} else {
safeConfig[`${key}_set`] = 'false'
}
}
const { error } = await supabase.from('integrations').upsert(
@@ -82,7 +137,7 @@ export async function saveIntegration(platform: string, config: Record<string, s
space_id: member.space_id,
platform: v.data.platform,
name: v.data.platform,
is_connected: Object.values(v.data.config).some(value => value && value.length > 0),
is_connected: connected,
config: safeConfig,
updated_at: new Date().toISOString(),
},
+81
View File
@@ -0,0 +1,81 @@
// Generic per-space secret vault helpers. This is the canonical store/read
// pair for any third-party credential (the AES-256-GCM `secrets` table, the
// same layout the door subsystem and Stripe config use). NOT a 'use server'
// module: it takes a Supabase admin client and is imported by server actions
// and route handlers. Secrets are server-only and never returned to a client.
import type { createAdminClient } from '@/lib/supabase/admin'
import { encryptSecret, decryptSecret, encryptionAvailable } from '@/lib/secrets/crypto'
type Admin = ReturnType<typeof createAdminClient>
// Store a credential in the encrypted vault. Returns the secret row id to
// keep (as a *_ref) in integrations.config. null on failure.
export async function storeSecret(
admin: Admin,
spaceId: string,
title: string,
value: string,
createdBy: string | null,
): Promise<string | null> {
const row: Record<string, unknown> = {
space_id: spaceId,
title,
label: title,
category: 'integration',
created_by: createdBy,
}
if (encryptionAvailable()) {
const { ciphertext, version } = encryptSecret(value)
row.encrypted_value = ciphertext
row.encryption_version = version
row.value = ''
} else {
row.value = value
row.encryption_version = 0
}
const { data, error } = await admin.from('secrets').insert(row).select('id').single()
if (error || !data) return null
return data.id as string
}
// Read + decrypt a stored credential by ref, space-scoped. Server-only.
export async function readSecret(
admin: Admin,
spaceId: string,
secretRef: string | null | undefined,
): Promise<string | null> {
if (!secretRef) return null
const { data } = await admin
.from('secrets')
.select('encryption_version, encrypted_value, value')
.eq('id', secretRef)
.eq('space_id', spaceId)
.maybeSingle()
if (!data) return null
if (data.encryption_version === 1 && data.encrypted_value) {
const raw = data.encrypted_value as unknown
const buf =
typeof raw === 'string'
? Buffer.from((raw as string).replace(/^\\x/, ''), 'hex')
: Buffer.from(raw as Uint8Array)
try {
return decryptSecret(buf, 1)
} catch {
return null
}
}
return (data.value as string | null) ?? null
}
// Config key naming convention for credentials that must be vaulted rather
// than stored plaintext in integrations.config. Derived keys (*_set, *_ref)
// are never themselves secrets.
export function isSecretConfigField(key: string): boolean {
if (key.endsWith('_set') || key.endsWith('_ref')) return false
return (
key === 'client_secret' ||
key === 'api_key' ||
key === 'secret_key' ||
key.endsWith('_secret')
)
}