224 lines
9.3 KiB
TypeScript
224 lines
9.3 KiB
TypeScript
import { describe, test, expect } from 'bun:test';
|
|
import { collectProviderFormConfig } from '../src/lib/utils/provider-form-config';
|
|
import { mergeProviderConfigForWrite, destinationOverridesStored } from '../src/lib/server/secretproviders/shared';
|
|
|
|
// Field shape (key + isSecret) per provider, mirroring PROVIDER_FIELDS in
|
|
// routes/settings/secrets/ProviderModal.svelte. Only the shape matters for what the edit
|
|
// form emits, so this is kept as a small local table rather than importing the .svelte
|
|
// module. If a provider's fields change there, add/adjust the row here too.
|
|
const PROVIDER_FIELD_SHAPES: Record<string, Array<{ key: string; isSecret: boolean }>> = {
|
|
'op-service-account': [{ key: 'token', isSecret: true }],
|
|
'op-connect': [
|
|
{ key: 'host', isSecret: false },
|
|
{ key: 'token', isSecret: true }
|
|
],
|
|
infisical: [
|
|
{ key: 'host', isSecret: false },
|
|
{ key: 'token', isSecret: true },
|
|
{ key: 'clientId', isSecret: false },
|
|
{ key: 'clientSecret', isSecret: true },
|
|
{ key: 'projectId', isSecret: false },
|
|
{ key: 'environment', isSecret: false },
|
|
{ key: 'path', isSecret: false }
|
|
],
|
|
vault: [
|
|
{ key: 'address', isSecret: false },
|
|
{ key: 'token', isSecret: true },
|
|
{ key: 'namespace', isSecret: false },
|
|
{ key: 'mount', isSecret: false }
|
|
],
|
|
doppler: [
|
|
{ key: 'token', isSecret: true },
|
|
{ key: 'project', isSecret: false },
|
|
{ key: 'config', isSecret: false }
|
|
],
|
|
bitwarden: [
|
|
{ key: 'token', isSecret: true },
|
|
{ key: 'serverUrl', isSecret: false }
|
|
],
|
|
proton: [{ key: 'token', isSecret: true }],
|
|
'azure-kv': [
|
|
{ key: 'vaultUri', isSecret: false },
|
|
{ key: 'tenantId', isSecret: false },
|
|
{ key: 'clientId', isSecret: false },
|
|
{ key: 'clientSecret', isSecret: true }
|
|
]
|
|
};
|
|
|
|
const setOf = (...keys: string[]) => new Set(keys);
|
|
|
|
describe('collectProviderFormConfig - core rules', () => {
|
|
test('a filled field is sent as-is', () => {
|
|
const cfg = collectProviderFormConfig(
|
|
PROVIDER_FIELD_SHAPES.vault,
|
|
{ address: 'http://vault', token: 'hvs.x', mount: 'kv' },
|
|
setOf('address', 'mount')
|
|
);
|
|
expect(cfg).toEqual({ address: 'http://vault', token: 'hvs.x', mount: 'kv' });
|
|
});
|
|
|
|
test('a blank secret is omitted (blank = keep stored)', () => {
|
|
const cfg = collectProviderFormConfig(
|
|
PROVIDER_FIELD_SHAPES.vault,
|
|
{ address: 'http://vault', token: '', mount: 'kv' },
|
|
setOf('address', 'token', 'mount')
|
|
);
|
|
expect('token' in cfg).toBe(false);
|
|
});
|
|
|
|
test('a cleared loaded non-secret is sent as ""', () => {
|
|
const cfg = collectProviderFormConfig(
|
|
PROVIDER_FIELD_SHAPES.vault,
|
|
{ address: 'http://vault', mount: '' },
|
|
setOf('address', 'mount')
|
|
);
|
|
expect(cfg.mount).toBe('');
|
|
});
|
|
|
|
test('a non-secret that was never loaded is not invented as ""', () => {
|
|
const cfg = collectProviderFormConfig(
|
|
PROVIDER_FIELD_SHAPES.vault,
|
|
{ address: 'http://vault' },
|
|
setOf('address') // mount never existed
|
|
);
|
|
expect('mount' in cfg).toBe(false);
|
|
});
|
|
|
|
test('whitespace-only values are treated as blank', () => {
|
|
const cfg = collectProviderFormConfig(
|
|
PROVIDER_FIELD_SHAPES.vault,
|
|
{ address: ' ', token: ' ', mount: ' ' },
|
|
setOf('address', 'mount')
|
|
);
|
|
expect(cfg.address).toBe(''); // loaded non-secret -> explicit clear
|
|
expect(cfg.mount).toBe('');
|
|
expect('token' in cfg).toBe(false); // blank secret omitted
|
|
});
|
|
});
|
|
|
|
// The bug (#1448) and the fix must behave correctly for EVERY provider's field set - a
|
|
// cleared secret must never be omitted-as-keep when its partner is cleared, and every
|
|
// provider's optional non-secret fields must survive a clear as an explicit "".
|
|
describe('per-provider: no field is silently dropped or wrongly kept', () => {
|
|
for (const [type, fields] of Object.entries(PROVIDER_FIELD_SHAPES)) {
|
|
const secretKeys = fields.filter((f) => f.isSecret).map((f) => f.key);
|
|
const nonSecretKeys = fields.filter((f) => !f.isSecret).map((f) => f.key);
|
|
|
|
test(`${type}: blank secrets omitted, cleared loaded non-secrets sent as ""`, () => {
|
|
// Simulate: everything loaded, user clears every field.
|
|
const allKeys = fields.map((f) => f.key);
|
|
const blankForm: Record<string, string> = {};
|
|
for (const k of allKeys) blankForm[k] = '';
|
|
const cfg = collectProviderFormConfig(fields, blankForm, new Set(allKeys));
|
|
|
|
for (const k of secretKeys) {
|
|
expect(k in cfg).toBe(false); // secrets never sent blank
|
|
}
|
|
for (const k of nonSecretKeys) {
|
|
expect(cfg[k]).toBe(''); // cleared non-secrets sent explicitly
|
|
}
|
|
});
|
|
|
|
test(`${type}: a fresh full config passes through unchanged`, () => {
|
|
const filled: Record<string, string> = {};
|
|
for (const f of fields) filled[f.key] = `val-${f.key}`;
|
|
const cfg = collectProviderFormConfig(fields, filled, new Set());
|
|
expect(cfg).toEqual(filled);
|
|
});
|
|
}
|
|
});
|
|
|
|
// #1448 is Infisical-specific (clientSecret paired with clientId), but the client rule is
|
|
// generic. Prove the full chain (form output -> server merge) for the paired case, and that
|
|
// no OTHER provider's stored secret is dropped just because a non-secret was cleared.
|
|
describe('#1448 full chain: form output -> server merge', () => {
|
|
test('infisical: clearing clientId drops the orphaned clientSecret', () => {
|
|
const stored = { host: 'http://infisical', clientId: 'cid', clientSecret: 'sec' };
|
|
const emitted = collectProviderFormConfig(
|
|
PROVIDER_FIELD_SHAPES.infisical,
|
|
{ host: 'http://infisical', clientId: '', clientSecret: '', token: 'new-tok' },
|
|
setOf('host', 'clientId', 'clientSecret')
|
|
);
|
|
const merged = mergeProviderConfigForWrite(emitted, stored);
|
|
expect(merged.token).toBe('new-tok');
|
|
expect(merged.clientSecret).toBeUndefined();
|
|
});
|
|
|
|
test('infisical: keeping clientId preserves the stored clientSecret', () => {
|
|
const stored = { host: 'http://infisical', clientId: 'cid', clientSecret: 'sec' };
|
|
const emitted = collectProviderFormConfig(
|
|
PROVIDER_FIELD_SHAPES.infisical,
|
|
{ host: 'http://new', clientId: 'cid', clientSecret: '' },
|
|
setOf('host', 'clientId')
|
|
);
|
|
const merged = mergeProviderConfigForWrite(emitted, stored);
|
|
expect(merged.clientSecret).toBe('sec');
|
|
});
|
|
|
|
test('azure-kv: clearing a non-secret does NOT drop the stored clientSecret (not a merge-paired secret)', () => {
|
|
// azure-kv's clientSecret has no PAIRED_SECRET_PARTNERS entry, so a blank clientSecret
|
|
// keeps the stored one regardless of other fields - clearing tenantId must not wipe it.
|
|
const stored = { vaultUri: 'https://v', tenantId: 'tid', clientId: 'cid', clientSecret: 'sec' };
|
|
const emitted = collectProviderFormConfig(
|
|
PROVIDER_FIELD_SHAPES['azure-kv'],
|
|
{ vaultUri: 'https://v', tenantId: '', clientId: 'cid', clientSecret: '' },
|
|
setOf('vaultUri', 'tenantId', 'clientId')
|
|
);
|
|
const merged = mergeProviderConfigForWrite(emitted, stored);
|
|
expect(merged.clientSecret).toBe('sec'); // kept
|
|
expect(merged.tenantId).toBe(''); // cleared coordinate honored
|
|
});
|
|
|
|
test('vault: clearing mount keeps the stored token (blank secret = keep)', () => {
|
|
const stored = { address: 'http://vault', token: 'hvs.stored', mount: 'kv' };
|
|
const emitted = collectProviderFormConfig(
|
|
PROVIDER_FIELD_SHAPES.vault,
|
|
{ address: 'http://vault', mount: '' },
|
|
setOf('address', 'mount')
|
|
);
|
|
const merged = mergeProviderConfigForWrite(emitted, stored);
|
|
expect(merged.token).toBe('hvs.stored');
|
|
expect(merged.mount).toBe(''); // server reads '' as the default mount
|
|
});
|
|
});
|
|
|
|
// The test endpoint must not send a stored credential to a caller-chosen host. It decides via
|
|
// destinationOverridesStored: if the override changes host/address, the stored secret is NOT
|
|
// reattached and the caller needs secrets:edit.
|
|
describe('destinationOverridesStored (credential-exfil guard)', () => {
|
|
test('Vault: changed address is a redirect', () => {
|
|
const stored = { address: 'https://vault.internal:8200', token: 'hvs.REAL' };
|
|
expect(destinationOverridesStored({ address: 'http://attacker.example:8902' }, stored)).toBe(true);
|
|
});
|
|
|
|
test('same address (edit form keeps host, blank token) is NOT a redirect', () => {
|
|
const stored = { address: 'https://vault.internal:8200', token: 'hvs.REAL' };
|
|
expect(destinationOverridesStored({ address: 'https://vault.internal:8200', mount: 'kv2' }, stored)).toBe(false);
|
|
});
|
|
|
|
test('1Password Connect / Infisical: changed host is a redirect', () => {
|
|
const stored = { host: 'https://connect.internal', token: 'ey.REAL' };
|
|
expect(destinationOverridesStored({ host: 'https://evil.example' }, stored)).toBe(true);
|
|
});
|
|
|
|
test('Bitwarden: changed serverUrl is a redirect (full stored-token leak vector)', () => {
|
|
const stored = { serverUrl: 'https://vault.bitwarden.eu', token: 'bws.REAL' };
|
|
expect(destinationOverridesStored({ serverUrl: 'http://attacker.example:8902' }, stored)).toBe(true);
|
|
});
|
|
|
|
test('Azure Key Vault: changed vaultUri is a redirect', () => {
|
|
const stored = { vaultUri: 'https://kv.vault.azure.net', clientSecret: 'REAL' };
|
|
expect(destinationOverridesStored({ vaultUri: 'https://evil.example' }, stored)).toBe(true);
|
|
});
|
|
|
|
test('override that omits the destination entirely is NOT a redirect', () => {
|
|
const stored = { address: 'https://vault.internal:8200', token: 'hvs.REAL' };
|
|
expect(destinationOverridesStored({ mount: 'kv2' }, stored)).toBe(false);
|
|
});
|
|
|
|
test('a cleared (blank) destination is not treated as a redirect', () => {
|
|
const stored = { host: 'https://connect.internal', token: 'ey.REAL' };
|
|
expect(destinationOverridesStored({ host: ' ' }, stored)).toBe(false);
|
|
});
|
|
});
|