stack secrets from external providers (1Password, HashiCorp Vault, Infisical and Doppler)
This commit is contained in:
28 files changed
+667
-100
No files matched your search
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user