validate secret provider config before save

This commit is contained in:
jarek committed 2026-08-14 14:01:36 +02:00
1 parent 344ad4aadc
commit 5fef45e003
8 files changed
+106 -31

No files matched your search

+5 -8
View File
@@ -6,7 +6,7 @@
*/
import type { SecretProviderConfig, SecretProviderType } from './secretproviders/shared';
import { SECRET_CONFIG_KEYS } from './secretproviders/shared';
import { mergeProviderConfigForWrite } from './secretproviders/shared';
import {
db,
isPostgres,
@@ -386,13 +386,10 @@ export async function updateSecretProvider(
if (typeUnchanged) {
const existing = await getSecretProviderById(id);
if (existing) {
for (const key of SECRET_CONFIG_KEYS) {
const incoming = (data.config as Record<string, unknown>)[key];
if (incoming === undefined || incoming === '') {
const stored = (existing.config as Record<string, unknown>)[key];
if (stored !== undefined) merged[key] = stored;
}
}
merged = mergeProviderConfigForWrite(
data.config as Record<string, unknown>,
existing.config as Record<string, unknown>
);
}
}
const encrypted = encrypt(JSON.stringify(merged));
+4 -1
View File
@@ -15,7 +15,7 @@
import { request } from 'undici';
import type { ConnectConfig, SecretProvider, TestConnectionResult } from './shared';
import { assertSafeProviderHost } from './shared';
import { assertSafeProviderHost, parseProviderError, isJsonResponse } from './shared';
import { UnsupportedOperationError } from './shared';
/** A vault as returned by GET /v1/vaults. */
@@ -251,6 +251,9 @@ export const connectProvider: SecretProvider<ConnectConfig> = {
});
const text = await body.text();
if (statusCode >= 200 && statusCode < 300) {
if (!isJsonResponse(text)) {
return { ok: false, error: '1Password Connect did not return a JSON response - the host may not be a Connect server' };
}
return { ok: true };
}
if (statusCode === 401 || statusCode === 403) {
+4 -1
View File
@@ -20,7 +20,7 @@
import { request } from 'undici';
import type { DopplerConfig, SecretProvider, TestConnectionResult } from './shared';
import { assertSafeProviderHost, parseProviderError } from './shared';
import { assertSafeProviderHost, parseProviderError, isJsonResponse } from './shared';
import { UnsupportedOperationError } from './shared';
const DEFAULT_HOST = 'https://api.doppler.com';
@@ -77,6 +77,9 @@ export const dopplerProvider: SecretProvider<DopplerConfig> = {
// message parsed from Doppler's own {messages:[...]} shape.
const rawBody = await body.text().catch(() => '');
if (statusCode >= 200 && statusCode < 300) {
if (!isJsonResponse(rawBody)) {
return { ok: false, error: 'Doppler did not return a JSON response - the host may not be a Doppler server' };
}
return { ok: true };
}
if (rawBody) console.warn(`[Doppler] testConnection ${statusCode}: ${rawBody}`);
+4 -1
View File
@@ -18,7 +18,7 @@
import { request } from 'undici';
import type { InfisicalConfig, SecretProvider, TestConnectionResult } from './shared';
import { assertSafeProviderHost, parseProviderError } from './shared';
import { assertSafeProviderHost, parseProviderError, isJsonResponse } from './shared';
import { UnsupportedOperationError } from './shared';
/** Shape of a single secret in the /api/v3/secrets/raw response. */
@@ -97,6 +97,9 @@ export const infisicalProvider: SecretProvider<InfisicalConfig> = {
// message parsed from Infisical's own {message}/{messages} shape.
const rawBody = await body.text().catch(() => '');
if (statusCode >= 200 && statusCode < 300) {
if (!isJsonResponse(rawBody)) {
return { ok: false, error: 'Infisical did not return a JSON response - the host may not be an Infisical server' };
}
return { ok: true };
}
if (rawBody) console.warn(`[Infisical] testConnection ${statusCode}: ${rawBody}`);
+38
View File
@@ -102,6 +102,22 @@ export function parseProviderError(rawBody: string | undefined | null): string |
return pick(obj.errors) ?? pick(obj.messages) ?? pick(obj.message) ?? null;
}
/**
* True when `body` parses as a JSON object or array. A 2xx status alone does not prove a
* host is the expected backend - a parked domain / captive portal / reverse proxy answers
* 200 with an HTML page. Every provider backend answers JSON, so testConnection requires
* this before reporting success. Rejects empty bodies and bare scalars (`5`, `true`).
*/
export function isJsonResponse(body: string | undefined | null): boolean {
if (!body || !body.trim()) return false;
try {
const v = JSON.parse(body);
return typeof v === 'object' && v !== null;
} catch {
return false;
}
}
/** 1Password service account: a single bearer token. */
export interface ServiceAccountConfig {
token: string;
@@ -170,6 +186,28 @@ export type SecretProviderConfig =
*/
export const SECRET_CONFIG_KEYS = new Set(['token']);
/**
* Merges an incoming (edit-form) config OVER the stored one for a write/test: the incoming
* non-secret coordinates win, but any SECRET_CONFIG_KEY (the token) that is blank/absent in
* the incoming falls back to the STORED value - because the edit form leaves the token blank
* to mean "keep the stored secret". Used by BOTH the update (persist) and the edit-mode Test
* so a Test validates exactly what a Save would persist. Keep them on this one helper so they
* can never diverge.
*/
export function mergeProviderConfigForWrite(
incoming: Record<string, unknown>,
stored: Record<string, unknown>
): Record<string, unknown> {
const merged: Record<string, unknown> = { ...incoming };
for (const key of SECRET_CONFIG_KEYS) {
const v = incoming[key];
if (v === undefined || v === '') {
if (stored[key] !== undefined) merged[key] = stored[key];
}
}
return merged;
}
/**
* Returns a copy of a provider config with secret values removed, so the edit form
* can pre-fill the non-secret coordinates without the token ever leaving the server.
+15 -6
View File
@@ -18,7 +18,7 @@
*/
import { request } from 'undici';
import { UnsupportedOperationError, assertSafeProviderHost, sanitizeSelectorPath, parseProviderError } from './shared';
import { UnsupportedOperationError, assertSafeProviderHost, sanitizeSelectorPath, parseProviderError, isJsonResponse } from './shared';
import type { SecretProvider, TestConnectionResult, VaultConfig } from './shared';
/** Undici response body handle, derived so we don't import undici's types. */
@@ -93,19 +93,26 @@ export const vaultProvider: SecretProvider<VaultConfig> = {
return { ok: false, error: 'Token is empty' };
}
try {
const { statusCode, body } = await request(`${apiBase(config)}/auth/token/lookup-self`, {
// Read the KV v2 mount config: validates host + token + the configured mount
// in one authenticated call - the same properties deploy (resolveBulk) uses. A
// wrong mount 404s and a bad token 403s, so both fail Test instead of passing.
const mount = encodeURIComponent(config.mount || 'secret');
const { statusCode, body } = await request(`${apiBase(config)}/${mount}/config`, {
method: 'GET',
headers: authHeaders(config)
});
const rawBody = await body.text().catch(() => '');
if (statusCode >= 200 && statusCode < 300) {
await body.text().catch(() => '');
// A 2xx alone isn't proof: a parked domain / proxy can answer 200 with HTML.
if (!isJsonResponse(rawBody)) {
return { ok: false, error: 'Vault did not return a JSON response - the host may not be a Vault server' };
}
return { ok: true };
}
const rawBody = await body.text().catch(() => '');
// Log the full upstream body server-side; show the client only a message
// parsed from Vault's own {errors:[...]} shape (a non-Vault host probed via
// SSRF returns other text that won't parse, so nothing leaks).
if (rawBody) console.warn(`[HashiCorp Vault] token lookup ${statusCode}: ${rawBody}`);
if (rawBody) console.warn(`[HashiCorp Vault] mount config ${mount} ${statusCode}: ${rawBody}`);
const safe = parseProviderError(rawBody);
return {
ok: false,
@@ -143,7 +150,9 @@ export const vaultProvider: SecretProvider<VaultConfig> = {
if (statusCode < 200 || statusCode >= 300) {
const detail = await readErrorDetail(body);
if (detail) console.warn(`[HashiCorp Vault] read ${mount}/${path} ${statusCode}: ${detail}`);
throw new Error(`HashiCorp Vault: read failed for ${mount}/${path} (status ${statusCode})`);
throw new Error(
`HashiCorp Vault: read failed for ${mount}/${path} (status ${statusCode})${detail ? `: ${detail}` : ''}`
);
}
const payload = (await body.json()) as KvV2ReadResponse;
@@ -3,9 +3,17 @@ import type { RequestHandler } from './$types';
import { getSecretProviderById } from '$lib/server/db';
import { authorize } from '$lib/server/authorize';
import { testProviderConnection } from '$lib/server/secretproviders';
import { mergeProviderConfigForWrite } from '$lib/server/secretproviders/shared';
import type { SecretProviderConfig } from '$lib/server/secretproviders/shared';
/** Test a stored provider using its persisted (decrypted) config. */
export const POST: RequestHandler = async ({ params, cookies }) => {
/**
* Test a stored provider. With no body it tests the persisted config. With an optional
* `{ config }` override (the edit form's typed non-secret fields) it tests exactly what a
* Save would persist: the incoming config merged over the stored one, with a blank token
* falling back to the stored secret. The secret is NEVER supplied by the client - it comes
* from storage server-side - so this stays a `secrets:view` operation.
*/
export const POST: RequestHandler = async ({ params, cookies, request }) => {
const auth = await authorize(cookies);
if (auth.authEnabled && !(await auth.can('secrets', 'view'))) {
return json({ error: 'Permission denied' }, { status: 403 });
@@ -21,7 +29,20 @@ export const POST: RequestHandler = async ({ params, cookies }) => {
return json({ error: 'Secret provider not found' }, { status: 404 });
}
const result = await testProviderConnection(provider.type, provider.config);
let configToTest: SecretProviderConfig = provider.config;
try {
const body = await request.json();
if (body && typeof body.config === 'object' && body.config !== null) {
configToTest = mergeProviderConfigForWrite(
body.config as Record<string, unknown>,
provider.config as unknown as Record<string, unknown>
) as unknown as SecretProviderConfig;
}
} catch {
// No/invalid body: test the stored config as-is.
}
const result = await testProviderConnection(provider.type, configToTest);
if (!result.ok) {
return json({ ok: false, error: result.error }, { status: 200 });
}
@@ -212,23 +212,24 @@
formError = '';
try {
const config = collectConfig();
// On edit the token is server-side (blank field = keep stored), so if the
// user hasn't typed a new one, test the STORED config; the pre-filled
// non-secret fields aren't enough to re-test from the client.
const hasNewSecret = fields.some((f) => f.type === 'password' && (config[f.key] ?? '') !== '');
const missing = missingRequired(config, isEditing);
if (missing) {
formError = missing;
return;
}
let response: Response;
if (isEditing && !hasNewSecret) {
// Test the stored (server-side) config.
if (isEditing) {
// Test EXACTLY what a Save would persist: the typed non-secret fields, merged
// server-side over the stored config (a blank token keeps the stored one). This
// makes an edited address/mount/namespace actually get tested - not the old
// stored config.
response = await fetch(`/api/secret-providers/${provider!.id}/test`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ config }),
});
} else {
const missing = missingRequired(config, isEditing);
if (missing) {
formError = missing;
return;
}
response = await fetch('/api/secret-providers/test', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },