stack secrets from external providers (1Password, HashiCorp Vault, Infisical and Doppler)

This commit is contained in:
jarek committed 2026-08-13 10:20:29 +02:00
1 parent d7f435dd5f
commit f43736d94a
28 files changed
+667 -100

No files matched your search

+112 -1
View File
@@ -18,6 +18,8 @@
* persisted here — providers hold it only for the duration of a call.
*/
import { isSafeNotificationUrl } from '../url-safety';
/**
* Identifies which backend a stored provider row talks to. Matches the
* `secret_providers.type` column and the API. Kept as a widenable string union
@@ -28,9 +30,78 @@ export type SecretProviderType =
| 'op-connect'
| 'infisical'
| 'vault'
| 'doppler'
// eslint-disable-next-line @typescript-eslint/no-redundant-type-constituents
| (string & {});
/**
* SSRF guard for a provider's user-supplied host/address. A provider makes
* server-side HTTP requests to whatever host the user configured, so an
* unchecked value lets an authenticated user pivot at Dockhand's network
* (cloud-metadata 169.254.169.254, loopback, internal services) and read the
* response back through testConnection / the bulk pull. Same policy as
* notifications: block loopback + cloud metadata, allow ordinary LAN ranges so a
* self-hosted Vault/Infisical/Connect on the local network still works. Throws on
* an unsafe host; call it before the first request in every REST provider.
*/
export function assertSafeProviderHost(rawUrl: string, label: string): void {
const safe = isSafeNotificationUrl(rawUrl);
if (!safe.ok) {
throw new Error(`${label}: host not allowed (${safe.reason})`);
}
}
/**
* Sanitises a user-supplied selector/path before it is concatenated into a
* provider's API URL (Vault KV path, Infisical secretPath). The selector comes
* from a stack env var an operator controls, so an unencoded value could inject
* `..` traversal or `?`/`#`/scheme characters and forge a request to a different
* endpoint on the same server. Rejects `..` segments and percent-encodes each
* path segment; a leading slash is stripped by the caller as before.
*/
export function sanitizeSelectorPath(selector: string, label: string): string {
const trimmed = selector.trim().replace(/^\/+/, '');
const segments = trimmed.split('/');
for (const seg of segments) {
if (seg === '..' || seg === '.') {
throw new Error(`${label}: selector must not contain path traversal ("${selector}")`);
}
}
return segments.map((s) => encodeURIComponent(s)).join('/');
}
/**
* Extracts a short, human error message from a provider's error RESPONSE BODY, but
* only when the body parses as that provider's own documented error JSON shape.
* Used by testConnection (interactive setup) to show an actionable message like
* "permission denied" or "Invalid Auth token" WITHOUT reflecting arbitrary upstream
* bytes: a non-provider host (SSRF probe of a LAN service) returns HTML/other text
* that does not match the shape, so nothing leaks. Returns null when the body is
* absent, unparseable, or not the expected shape - the caller then shows status only.
*
* Shapes: Vault `{errors:[...]}`, Doppler/Infisical `{messages:[...]}` / `{message}`.
*/
export function parseProviderError(rawBody: string | undefined | null): string | null {
if (!rawBody) return null;
let parsed: unknown;
try {
parsed = JSON.parse(rawBody);
} catch {
return null; // not JSON -> not a known provider error shape
}
if (!parsed || typeof parsed !== 'object') return null;
const obj = parsed as Record<string, unknown>;
const pick = (v: unknown): string | null => {
if (typeof v === 'string' && v.trim()) return v.trim().slice(0, 200);
if (Array.isArray(v)) {
const strs = v.filter((x) => typeof x === 'string' && x.trim()) as string[];
if (strs.length) return strs.join('; ').slice(0, 200);
}
return null;
};
return pick(obj.errors) ?? pick(obj.messages) ?? pick(obj.message) ?? null;
}
/** 1Password service account: a single bearer token. */
export interface ServiceAccountConfig {
token: string;
@@ -66,12 +137,52 @@ export interface VaultConfig {
mount?: string;
}
/**
* Doppler: bulk-only. A SERVICE token (dp.st.) is scoped to one config, so the
* token alone identifies it and `project`/`config` are not needed. A PERSONAL
* token (dp.pt.) is account-wide, so it MUST be told which project + config to
* download (the API returns 400 "You must specify a project" otherwise). `host`
* defaults to Doppler's SaaS API; it is overridable only for testing against a
* stub (Doppler has no self-hosted server).
*/
export interface DopplerConfig {
token: string;
host?: string;
/** Required for a personal token (dp.pt.); ignored for a service token. */
project?: string;
config?: string;
}
/** Persisted (encrypted) config, discriminated by the provider `type`. */
export type SecretProviderConfig =
| ServiceAccountConfig
| ConnectConfig
| InfisicalConfig
| VaultConfig;
| VaultConfig
| DopplerConfig;
/**
* Config keys that hold a SECRET across every provider type. Only these are stripped
* when a provider config is sent to the client (the edit form); everything else
* (host, address, projectId, environment, path, namespace, mount, project, config) is
* a non-secret coordinate the user needs to see and edit. Keep in sync with the
* `type: 'password'` fields in ProviderModal.svelte's PROVIDER_FIELDS.
*/
export const SECRET_CONFIG_KEYS = new Set(['token']);
/**
* 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.
* A stripped secret is simply absent (the form treats a blank secret field as
* "keep the stored value").
*/
export function redactProviderConfig(config: SecretProviderConfig): Record<string, unknown> {
const out: Record<string, unknown> = {};
for (const [key, value] of Object.entries(config)) {
if (!SECRET_CONFIG_KEYS.has(key)) out[key] = value;
}
return out;
}
export interface TestConnectionResult {
ok: boolean;