diff --git a/.bumpy/proxy-core-extraction.md b/.bumpy/proxy-core-extraction.md new file mode 100644 index 000000000..22fef24e2 --- /dev/null +++ b/.bumpy/proxy-core-extraction.md @@ -0,0 +1,5 @@ +--- +varlock: minor +--- + +new `varlock/proxy-core` subpath export: the credential proxy's transport-agnostic core (policy evaluation, substitution guards, response scrubbing, request pipeline), usable from non-node runtimes like Cloudflare Workers diff --git a/packages/varlock/package.json b/packages/varlock/package.json index a74fb2623..5eaa675a5 100644 --- a/packages/varlock/package.json +++ b/packages/varlock/package.json @@ -14,7 +14,7 @@ "directory": "packages/varlock" }, "scripts": { - "build": "tsup", + "build": "tsup && tsup --config tsup.proxy-core.config.ts", "build:binary": "bun run scripts/build-binaries.ts --dev", "build:binaries": "bun run scripts/build-binaries.ts", "test:local-encrypt:reset": "bun run scripts/reset-local-encrypt-state.ts", @@ -131,6 +131,11 @@ "types": "./dist/plugin-lib.d.ts", "default": "./dist/plugin-lib.js" }, + "./proxy-core": { + "ts-src": "./src/proxy/core/index.ts", + "types": "./dist/proxy-core.d.ts", + "default": "./dist/proxy-core.js" + }, "./test-helpers": { "ts-src": "./src/test-helpers/plugin-test.ts", "default": "./src/test-helpers/plugin-test.ts" diff --git a/packages/varlock/src/cli/commands/proxy.command.ts b/packages/varlock/src/cli/commands/proxy.command.ts index 53b11ea20..e0da685d5 100644 --- a/packages/varlock/src/cli/commands/proxy.command.ts +++ b/packages/varlock/src/cli/commands/proxy.command.ts @@ -84,7 +84,7 @@ import { fetchTunnelBootstrap, startTunnelClientListener } from '../../proxy/tun import { parseSandboxSpec, isContainerKind, checkSandboxAvailable, type SandboxSpec, } from '../../proxy/sandbox'; -import type { ProxyManagedItem, ProxyRule } from '../../proxy/types'; +import type { ProxyManagedItem, ProxyRule } from '../../proxy/core/types'; import { generateProxyPlaceholderForItem } from '../../proxy/placeholder'; import { isVarlockReservedKey } from '../../env-graph/lib/reserved-vars'; import { resetRedactionMap } from '../../runtime/env'; diff --git a/packages/varlock/src/env-graph/lib/decorators.ts b/packages/varlock/src/env-graph/lib/decorators.ts index 674ea5a10..2f680e81c 100644 --- a/packages/varlock/src/env-graph/lib/decorators.ts +++ b/packages/varlock/src/env-graph/lib/decorators.ts @@ -14,7 +14,7 @@ import { ResolutionError, SchemaError, type VarlockError } from './errors'; import type { EnvGraph } from './env-graph'; import { parseKeyFilterArgs, applyKeyFilter, type KeyFilter } from './key-filter'; import { parseDuration } from '../../lib/duration'; -import { PROXY_APPROVAL_EACH_VALUES, parseProxySubstitutionTarget } from '../../proxy/types'; +import { PROXY_APPROVAL_EACH_VALUES, parseProxySubstitutionTarget } from '../../proxy/core/types'; export abstract class DecoratorInstance { diff --git a/packages/varlock/src/env-graph/lib/env-graph.ts b/packages/varlock/src/env-graph/lib/env-graph.ts index 592db7d67..c45464b30 100644 --- a/packages/varlock/src/env-graph/lib/env-graph.ts +++ b/packages/varlock/src/env-graph/lib/env-graph.ts @@ -36,7 +36,7 @@ import { PROXY_APPROVAL_EACH_VALUES, parseProxySubstitutionTarget, type ProxyApprovalEach, type ProxyEgressMode, type ProxyManagedItem, type ProxyRule, -} from '../../proxy/types'; +} from '../../proxy/core/types'; import { parseDuration } from '../../lib/duration'; const processExists = !!globalThis.process; diff --git a/packages/varlock/src/proxy/approval.ts b/packages/varlock/src/proxy/approval.ts index e2d523b89..6a2ee691e 100644 --- a/packages/varlock/src/proxy/approval.ts +++ b/packages/varlock/src/proxy/approval.ts @@ -2,7 +2,7 @@ import { createHash, randomBytes } from 'node:crypto'; import readline from 'node:readline'; import type { Readable, Writable } from 'node:stream'; -import type { ProxyApprovalEach } from './types'; +import type { ProxyApprovalEach } from './core/types'; /** * A request-bound approval request (Invariant #8). It commits to the EXACT diff --git a/packages/varlock/src/proxy/audit.ts b/packages/varlock/src/proxy/audit.ts index d048c5904..c054c2301 100644 --- a/packages/varlock/src/proxy/audit.ts +++ b/packages/varlock/src/proxy/audit.ts @@ -3,42 +3,12 @@ import { existsSync } from 'node:fs'; import { appendFile, mkdir, readFile } from 'node:fs/promises'; import { dirname, join } from 'node:path'; +import type { ProxyActivity, ProxyAuditDecision } from './core/activity'; import { getProxySessionDir } from './session-registry'; -/** The security decision the proxy reached for a single request. */ -export type ProxyAuditDecision = | 'allow' // forwarded upstream (a secret may or may not have been injected) - | 'deny' // matched a `block` rule — never reached upstream - | 'blocked-egress' // strict egress mode rejected a non-allowlisted host - | 'blocked-uninjected' // request carried a placeholder no rule injects on this route (misconfig) - | 'blocked-cleartext' // refused to inject a secret into a non-TLS connection - | 'blocked-location' // placeholder appeared in a request location the rule doesn't allow substituting in - | 'blocked-occurrences' // placeholder appeared more times than the rule's occurrence cap allows - | 'approval-granted' // require-approval rule matched and the approver allowed it - | 'approval-denied'; // require-approval rule matched and approval was denied/timed-out - -/** - * Structured per-request activity emitted by the proxy runtime. It carries - * everything the audit log needs but **never** a secret value: `path`/`url` are - * the child's *placeholder-form* request (injection happens after this is - * emitted), and `injectedKeys` are item keys (names), not values. - */ -export type ProxyActivity = { - /** Whether the host matched a configured `@proxy` rule. */ - matched: boolean; - /** Whether the request was blocked (egress, policy, or cleartext guard). */ - blocked: boolean; - host: string; - method: string; - /** Path only, no query string, in placeholder form. */ - path: string; - /** Full path + query in placeholder form — used only to compute the fingerprint hash. */ - url?: string; - decision: ProxyAuditDecision; - /** Stable descriptor of the matched rule (see `describeRule`), if any. */ - ruleId?: string; - /** Keys (names, never values) of the managed items actually injected into this request. */ - injectedKeys?: Array; -}; +// The activity types live in @varlock/proxy-core (the transport-agnostic +// pipeline emits them); re-exported here so audit consumers keep one import. +export type { ProxyActivity, ProxyAuditDecision } from './core/activity'; /** First line of every audit file — makes the file self-describing after the session record is gone. */ export type ProxyAuditHeader = { diff --git a/packages/varlock/src/proxy/core/activity.ts b/packages/varlock/src/proxy/core/activity.ts new file mode 100644 index 000000000..be011d1f9 --- /dev/null +++ b/packages/varlock/src/proxy/core/activity.ts @@ -0,0 +1,34 @@ +/** The security decision the proxy reached for a single request. */ +export type ProxyAuditDecision = | 'allow' // forwarded upstream (a secret may or may not have been injected) + | 'deny' // matched a `block` rule — never reached upstream + | 'blocked-egress' // strict egress mode rejected a non-allowlisted host + | 'blocked-uninjected' // request carried a placeholder no rule injects on this route (misconfig) + | 'blocked-cleartext' // refused to inject a secret into a non-TLS connection + | 'blocked-location' // placeholder appeared in a request location the rule doesn't allow substituting in + | 'blocked-occurrences' // placeholder appeared more times than the rule's occurrence cap allows + | 'approval-granted' // require-approval rule matched and the approver allowed it + | 'approval-denied'; // require-approval rule matched and approval was denied/timed-out + +/** + * Structured per-request activity emitted by the proxy runtime. It carries + * everything the audit log needs but **never** a secret value: `path`/`url` are + * the child's *placeholder-form* request (injection happens after this is + * emitted), and `injectedKeys` are item keys (names), not values. + */ +export type ProxyActivity = { + /** Whether the host matched a configured `@proxy` rule. */ + matched: boolean; + /** Whether the request was blocked (egress, policy, or cleartext guard). */ + blocked: boolean; + host: string; + method: string; + /** Path only, no query string, in placeholder form. */ + path: string; + /** Full path + query in placeholder form — used only to compute the fingerprint hash. */ + url?: string; + decision: ProxyAuditDecision; + /** Stable descriptor of the matched rule (see `describeRule`), if any. */ + ruleId?: string; + /** Keys (names, never values) of the managed items actually injected into this request. */ + injectedKeys?: Array; +}; diff --git a/packages/varlock/src/proxy/core/auth.ts b/packages/varlock/src/proxy/core/auth.ts new file mode 100644 index 000000000..aa7c760d1 --- /dev/null +++ b/packages/varlock/src/proxy/core/auth.ts @@ -0,0 +1,62 @@ +/** + * Constant-time token comparison (length leak is fine; the token is a uuid, not + * a password). Char-code XOR accumulation rather than node's `timingSafeEqual` + * so it runs on any runtime. + */ +export function tokenMatches(provided: unknown, expected: string): boolean { + if (typeof provided !== 'string') return false; + if (provided.length !== expected.length) return false; + let diff = 0; + for (let i = 0; i < provided.length; i += 1) { + // eslint-disable-next-line no-bitwise + diff |= provided.charCodeAt(i) ^ expected.charCodeAt(i); + } + return diff === 0; +} + +export function isLoopbackAddress(addr: string | undefined): boolean { + if (!addr) return false; + return addr === '::1' || addr === '::ffff:127.0.0.1' || addr.startsWith('127.'); +} + +/** True if a listen host binds only loopback (so remote peers can't reach it). */ +export function isLoopbackBind(host: string): boolean { + return host === 'localhost' || isLoopbackAddress(host); +} + +/** Extract the token from a `Proxy-Authorization: Basic base64(user:token)` header. */ +export function parseProxyAuthToken(header: string | Array | undefined): string | undefined { + const value = Array.isArray(header) ? header[0] : header; + if (typeof value !== 'string') return undefined; + const spaceIdx = value.indexOf(' '); + if (spaceIdx === -1) return undefined; + const scheme = value.slice(0, spaceIdx); + const encoded = value.slice(spaceIdx + 1).trim(); + if (scheme.toLowerCase() !== 'basic' || !encoded) return undefined; + let decoded: string; + try { + // atob + TextDecoder rather than Buffer so this runs on any runtime. + const bytes = Uint8Array.from(atob(encoded), (c) => c.charCodeAt(0)); + decoded = new TextDecoder().decode(bytes); + } catch { + return undefined; + } + const colon = decoded.indexOf(':'); + // Basic is `user:pass`; the token is the password half (username is cosmetic). + return colon === -1 ? decoded : decoded.slice(colon + 1); +} + +/** + * Data-plane gate. Loopback peers are same-uid-trusted and always pass (the + * historical model). A non-loopback peer — only reachable when the listener is + * bound off-loopback — must present the session's `Proxy-Authorization` token. + */ +export function dataPlaneAuthOk( + peerAddr: string | undefined, + header: string | Array | undefined, + token: string | undefined, +): boolean { + if (isLoopbackAddress(peerAddr)) return true; + if (!token) return false; // non-loopback bind without a token: fail closed + return tokenMatches(parseProxyAuthToken(header), token); +} diff --git a/packages/varlock/src/proxy/core/headers.ts b/packages/varlock/src/proxy/core/headers.ts new file mode 100644 index 000000000..1b197071a --- /dev/null +++ b/packages/varlock/src/proxy/core/headers.ts @@ -0,0 +1,94 @@ +import { replaceRealWithPlaceholders } from './substitution'; +import type { ProxyManagedItem } from './types'; + +/** + * A header map in node's incoming shape (lower-cased names; repeated headers as + * arrays). Structurally compatible with `http.IncomingHttpHeaders`, but defined + * here so nothing in the core depends on node types. Adapters for other + * transports (e.g. a fetch-based gateway) convert their native headers into + * this shape. + */ +export type HeadersRecord = Record | undefined>; + +export type HeaderTransformFn = (value: string) => string; + +export function transformHeaders( + headers: HeadersRecord, + transformValue: HeaderTransformFn, +): Record> { + const out: Record> = {}; + for (const [key, val] of Object.entries(headers)) { + if (val === undefined) continue; + if (Array.isArray(val)) { + out[key] = val.map((v) => transformValue(v)); + } else { + out[key] = transformValue(String(val)); + } + } + return out; +} + +export function getHeaderValue( + headers: HeadersRecord, + key: string, +): string | undefined { + const raw = headers[key.toLowerCase()]; + if (raw === undefined) return undefined; + if (Array.isArray(raw)) return raw[0]; + return String(raw); +} + +export function isUncompressedResponse(headers: HeadersRecord): boolean { + const contentEncoding = getHeaderValue(headers, 'content-encoding'); + if (!contentEncoding) return true; + const tokens = contentEncoding.split(',').map((token) => token.trim().toLowerCase()).filter(Boolean); + if (!tokens.length) return true; + return tokens.every((token) => token === 'identity'); +} + +export function isTextLikeResponse(headers: HeadersRecord): boolean { + const contentType = getHeaderValue(headers, 'content-type')?.toLowerCase(); + if (!contentType) return false; + return contentType.startsWith('text/') + || contentType.includes('json') + || contentType.includes('xml') + || contentType.includes('javascript') + || contentType.includes('x-www-form-urlencoded') + || contentType.includes('graphql'); +} + +// Only buffer-and-redact bounded, reasonably small text bodies. Anything we +// can't size up front (SSE, chunked streams) or that's too large is streamed +// straight through — buffering it would break streaming (e.g. LLM token-by-token +// responses hang until complete) for a low-value protection: the injected secret +// is in the request, not the response. Header redaction still applies regardless. +export const MAX_REDACT_BODY_BYTES = 2 * 1024 * 1024; + +export function isStreamingResponse(headers: HeadersRecord): boolean { + const contentType = getHeaderValue(headers, 'content-type')?.toLowerCase() ?? ''; + return contentType.includes('text/event-stream'); +} + +export function isBoundedRedactableBody(headers: HeadersRecord): boolean { + const lenRaw = getHeaderValue(headers, 'content-length'); + if (lenRaw === undefined) return false; // unknown size — treat as a stream, never buffer + const len = Number(lenRaw); + return Number.isFinite(len) && len >= 0 && len <= MAX_REDACT_BODY_BYTES; +} + +export function shouldRedactResponseBody(headers: HeadersRecord): boolean { + return isUncompressedResponse(headers) + && isTextLikeResponse(headers) + && !isStreamingResponse(headers) + && isBoundedRedactableBody(headers); +} + +export function redactOutgoingHeaders( + headers: HeadersRecord, + managedItems: Array, +): Record> { + return transformHeaders( + headers, + (value) => replaceRealWithPlaceholders(value, managedItems), + ); +} diff --git a/packages/varlock/src/proxy/core/index.ts b/packages/varlock/src/proxy/core/index.ts new file mode 100644 index 000000000..a40322ea4 --- /dev/null +++ b/packages/varlock/src/proxy/core/index.ts @@ -0,0 +1,8 @@ +export * from './activity'; +export * from './auth'; +export * from './headers'; +export * from './pipeline'; +export * from './policy'; +export * from './scrub'; +export * from './substitution'; +export * from './types'; diff --git a/packages/varlock/src/proxy/core/pipeline.ts b/packages/varlock/src/proxy/core/pipeline.ts new file mode 100644 index 000000000..811fc6f8f --- /dev/null +++ b/packages/varlock/src/proxy/core/pipeline.ts @@ -0,0 +1,328 @@ +import type { ProxyActivity } from './activity'; +import { getHeaderValue, type HeadersRecord } from './headers'; +import { + describeRule, domainMatches, evaluateProxyPolicy, getRequestScopedManagedItems, + type PolicyDecision, type RequestFacts, type RequestScopedManagedItem, +} from './policy'; +import { + checkSubstitutionGuards, detectInjectedKeys, findUninjectedPlaceholder, replacePlaceholdersWithReal, + type SubstitutionGuardRequest, +} from './substitution'; +import type { + ProxyApprovalEach, ProxyEgressMode, ProxyManagedItem, ProxyRule, +} from './types'; + +/** The policy a proxy enforces, as one swappable snapshot (rules + items + egress mode). */ +export type ProxyPolicyState = { + rules: Array; + managedItems: Array; + egressMode: ProxyEgressMode; +}; + +export function hostMatchesProxyRules(host: string, rules: Array): boolean { + return rules.some((rule) => rule.domain.some((d) => domainMatches(d, host))); +} + +/** Transport-neutral facts about one proxied request (no body — see the two-phase flow below). */ +export type ProxiedRequestFacts = { + host: string; + /** True when the client's connection to the upstream would be TLS. */ + isHttps: boolean; + method: string; + /** Path component for policy facts/activity (no query). */ + pathOnly: string; + /** Origin-form path+query sent upstream (and scrubbed) — also used as the activity URL. */ + requestTarget: string; +}; + +/** + * A request the pipeline refuses to forward. `activity` is the audit event to + * record; `status`/`message` are the client-facing response. `teardownOnTunnel` + * mirrors the transport hint historically applied per decision kind: when true, + * a MITM-tunnel transport should tear the socket down rather than end the + * response normally (short status-only responses don't reliably flush through a + * CONNECT tunnel). + */ +export type BlockedOutcome = { + kind: 'blocked'; + status: number; + message: string; + activity: ProxyActivity; + teardownOnTunnel: boolean; +}; + +/** Phase-1 result when the request may proceed to body-dependent checks. */ +export type PreBodyContinue = { + kind: 'continue'; + shouldRewrite: boolean; + /** Managed items in scope for this request (approval-gated keys included only when the approval gate will run). */ + hostItems: Array; + policyDecision?: PolicyDecision; + ruleId?: string; +}; + +export type ForwardOutcome = { + kind: 'forward'; + activity: ProxyActivity; + shouldRewrite: boolean; + hostItems: Array; + /** Keys whose placeholders actually appear in this request (audit detail). */ + injectedKeys: Array; + /** `requestTarget` with placeholders substituted (identity when not rewriting). */ + rewrittenTarget: string; + /** Body text with placeholders substituted (identity when not rewriting). */ + rewrittenBodyText: string; + /** Transform for individual header values (placeholder → real when rewriting, identity otherwise). */ + transformHeaderValue: (value: string) => string; +}; + +/** + * Asks the transport's approval provider for an out-of-band, request-bound + * decision (Invariant #8). Implementations must fail closed (deny on + * timeout/error); the pipeline additionally treats a thrown error or a missing + * gate as a denial. + */ +export type ApprovalGateFn = (input: { + method: string; + host: string; + path: string; + ruleId?: string; + each?: ProxyApprovalEach; + maxDurationMs?: number; + injectedKeys: Array; +}) => Promise; + +/** + * Phase 1 of the shared request pipeline — every check that can run before the + * body is buffered, so a transport can reject a doomed request without reading + * its body: egress gate → per-call policy (block) → request-scoped item + * selection → cleartext guard. Returns either a fail-closed `blocked` outcome + * (the adapter records `activity` and responds with `status`/`message`) or a + * `continue` carrying the resolved policy context for phase 2. + */ +export function evaluateProxiedRequestPreBody( + t: ProxiedRequestFacts, + policy: ProxyPolicyState, +): BlockedOutcome | PreBodyContinue { + const baseActivity = { + host: t.host, method: t.method, path: t.pathOnly, url: t.requestTarget, + }; + + const shouldRewrite = hostMatchesProxyRules(t.host, policy.rules); + const shouldAllowEgress = policy.egressMode === 'permissive' || shouldRewrite; + if (!shouldAllowEgress) { + return { + kind: 'blocked', + status: 403, + message: `Blocked by the varlock credential proxy: ${t.host} is not allowed by your egress policy (strict mode only permits hosts with a matching @proxy rule). Add a @proxy rule for this host, or use permissive egress, to allow it.`, + activity: { + ...baseActivity, matched: shouldRewrite, blocked: true, decision: 'blocked-egress', + }, + teardownOnTunnel: false, + }; + } + + // Per-call policy (static authorization): evaluate host + method + path; a + // matching `block` rule denies the request and it never reaches upstream. + const facts: RequestFacts = { host: t.host, method: t.method, path: t.pathOnly }; + const policyDecision = shouldRewrite ? evaluateProxyPolicy(facts, policy.rules, policy.egressMode) : undefined; + const ruleId = policyDecision?.matchedRule ? describeRule(policyDecision.matchedRule) : undefined; + if (policyDecision?.verdict === 'deny') { + // Two deny kinds: an explicit `block` rule (denylist), or strict egress with + // no allow rule matching this method/path on an otherwise-ruled host. + const egressStrictDeny = policyDecision.denyKind === 'egress-strict'; + return { + kind: 'blocked', + status: 403, + message: egressStrictDeny + ? `Blocked by the varlock credential proxy: no @proxy rule matches ${t.method} ${t.host}${t.pathOnly}. ` + + 'The host has a @proxy rule, but none matches this method and path, and egress is strict. ' + + 'Add a matching (or broader) @proxy rule, or use permissive egress.' + : `Blocked by the varlock credential proxy: a @proxy block rule denies ${t.method} ${t.host}${t.pathOnly}.`, + activity: { + ...baseActivity, ...(ruleId ? { ruleId } : {}), matched: true, blocked: true, decision: egressStrictDeny ? 'blocked-egress' : 'deny', + }, + teardownOnTunnel: true, + }; + } + + // Approval-gated keys (contributed only by `@proxy(approval)` rules) are + // withheld unless the verdict actually routes through the approval gate below. + // A plain-`allow` verdict from a more-specific rule must NOT smuggle a broader + // approval rule's secret in without a prompt (see getRequestScopedManagedItems). + const hostItems = shouldRewrite + ? getRequestScopedManagedItems(facts, policy.rules, policy.managedItems, { + includeApprovalGatedKeys: policyDecision?.verdict === 'require-approval', + }) + : []; + + // Invariant #2/#5: never inject a secret into a cleartext (non-TLS) connection — + // no cert means no verifiable identity. Fail closed. (MITM is always https, so + // this only fires on the absolute-form http path.) + if (hostItems.length > 0 && !t.isHttps) { + return { + kind: 'blocked', + status: 403, + message: `Blocked by the varlock credential proxy: refusing to inject a secret into a cleartext (non-TLS) connection to ${t.host}.`, + activity: { + ...baseActivity, ...(ruleId ? { ruleId } : {}), matched: true, blocked: true, decision: 'blocked-cleartext', + }, + teardownOnTunnel: false, + }; + } + + return { + kind: 'continue', shouldRewrite, hostItems, policyDecision, ruleId, + }; +} + +/** + * Phase 2 of the shared request pipeline — the body-dependent checks and the + * substitution itself: uninjected-placeholder guard → substitution guards + * (placement + occurrence cap) → approval gate → placeholder → real + * substitution. Returns a fail-closed `blocked` outcome or a `forward` carrying + * the rewritten request parts for the transport to send upstream (over a + * connection whose upstream identity the transport must verify before any + * secret is written). + */ +export async function evaluateProxiedRequestWithBody( + pre: PreBodyContinue, + t: ProxiedRequestFacts, + policy: ProxyPolicyState, + input: { headers: HeadersRecord; bodyText: string }, + opts?: { approvalGate?: ApprovalGateFn }, +): Promise { + const { + shouldRewrite, hostItems, policyDecision, ruleId, + } = pre; + const baseActivity = { + host: t.host, method: t.method, path: t.pathOnly, url: t.requestTarget, + }; + const ruleIdPart = ruleId ? { ruleId } : {}; + + const scanParts = [t.requestTarget, JSON.stringify(input.headers), input.bodyText]; + const injectedKeys = shouldRewrite ? detectInjectedKeys(scanParts, hostItems) : []; + + // Helpful-failure guard: when NO rule injects anything on this route yet the + // request carries a managed placeholder, the real value won't be substituted + // and the upstream would reject it with a cryptic auth error — and the cause + // is the proxy rules (wrong path/method, or wrong host). Explain it instead of + // forwarding a doomed request. Scoped to `hostItems.length === 0` so a request + // that DOES inject on this route can still carry an unrelated placeholder + // (e.g. another item's, bound for a different host) through untouched. + const leaked = hostItems.length === 0 + ? findUninjectedPlaceholder(scanParts, policy.managedItems, hostItems) + : undefined; + if (leaked) { + return { + kind: 'blocked', + status: 403, + message: `Blocked by the varlock credential proxy: this request to ${t.host}${t.pathOnly} carries the placeholder for ${leaked.key}, ` + + 'but no @proxy rule injects it here — the real value was not substituted and the request would fail upstream. ' + + 'Add or broaden a @proxy rule so it matches this request (host + path + method).', + activity: { + ...baseActivity, ...ruleIdPart, matched: shouldRewrite, blocked: true, decision: 'blocked-uninjected', + }, + teardownOnTunnel: true, + }; + } + + // Substitution guards: before any placeholder is swapped for its real value, + // enforce *where* (target: header / header:name / query:param / body:path) and + // *how often* (occurrence cap) each injected secret may appear. Default is any + // header, once. This is what keeps a clever request from moving the real secret + // into an exfiltration-friendly spot (an email body, a duplicated field) on an + // otherwise-allowed host — the secret is only ever substituted where the rule + // explicitly allows. + if (shouldRewrite && hostItems.length > 0) { + const guardReq: SubstitutionGuardRequest = { + headers: Object.entries(input.headers).map(([name, value]) => ({ + name: name.toLowerCase(), + value: Array.isArray(value) ? value.join('\n') : String(value ?? ''), + })), + requestTarget: t.requestTarget, + body: input.bodyText, + contentType: getHeaderValue(input.headers, 'content-type'), + }; + const violation = checkSubstitutionGuards(guardReq, hostItems); + if (violation) { + const decision = violation.kind === 'location' ? 'blocked-location' : 'blocked-occurrences'; + return { + kind: 'blocked', + status: 403, + message: violation.kind === 'location' + ? `Blocked by the varlock credential proxy: ${violation.item.key}'s placeholder appears in the ${violation.location} of this request, which its @proxy rule doesn't allow. ` + + `${violation.suggestion}. ` + + 'If that placement was not intentional, it may be an attempt to place the secret somewhere it could leak.' + : `Blocked by the varlock credential proxy: ${violation.item.key}'s placeholder appears ${violation.count} times in this request, but at most ${violation.item.maxOccurrences} is allowed. ` + + 'A valid request uses the secret once; extra copies can exfiltrate it. If this API legitimately repeats it, raise maxOccurrences on the @proxy rule.', + activity: { + ...baseActivity, ...ruleIdPart, matched: true, blocked: true, decision, + }, + teardownOnTunnel: true, + }; + } + } + + // Invariant #8: a require-approval rule holds the request for an out-of-band, + // request-bound decision. Fail closed (deny) unless explicitly approved: a + // missing gate or a throwing gate is a denial. + if (policyDecision?.verdict === 'require-approval') { + let approved = false; + if (opts?.approvalGate) { + try { + approved = await opts.approvalGate({ + method: t.method, + host: t.host, + path: t.pathOnly, + ruleId, + each: policyDecision.matchedRule?.approval?.each, + maxDurationMs: policyDecision.matchedRule?.approval?.maxDurationMs, + injectedKeys, + }); + } catch { + approved = false; + } + } + if (!approved) { + return { + kind: 'blocked', + status: 403, + message: `Blocked by the varlock credential proxy: this request to ${t.host} required approval and it was not granted.`, + activity: { + ...baseActivity, ...ruleIdPart, matched: true, blocked: true, decision: 'approval-denied', + }, + teardownOnTunnel: true, + }; + } + } + + // Substitute placeholder → real value. The guards above already proved every + // occurrence sits at an allowed target for its item, and placeholders are unique + // per item, so a blind string-replace across all three parts only ever hits the + // approved spot — no need to re-scope per location (which would also risk + // re-serializing/altering the body). + return { + kind: 'forward', + activity: { + ...baseActivity, + ...ruleIdPart, + matched: shouldRewrite, + blocked: false, + decision: policyDecision?.verdict === 'require-approval' ? 'approval-granted' : 'allow', + ...(injectedKeys.length ? { injectedKeys } : {}), + }, + shouldRewrite, + hostItems, + injectedKeys, + rewrittenTarget: shouldRewrite + ? replacePlaceholdersWithReal(t.requestTarget, hostItems) + : t.requestTarget, + rewrittenBodyText: shouldRewrite + ? replacePlaceholdersWithReal(input.bodyText, hostItems) + : input.bodyText, + transformHeaderValue: shouldRewrite + ? (value) => replacePlaceholdersWithReal(value, hostItems) + : (value) => value, + }; +} diff --git a/packages/varlock/src/proxy/policy.test.ts b/packages/varlock/src/proxy/core/policy.test.ts similarity index 100% rename from packages/varlock/src/proxy/policy.test.ts rename to packages/varlock/src/proxy/core/policy.test.ts diff --git a/packages/varlock/src/proxy/policy.ts b/packages/varlock/src/proxy/core/policy.ts similarity index 100% rename from packages/varlock/src/proxy/policy.ts rename to packages/varlock/src/proxy/core/policy.ts diff --git a/packages/varlock/src/proxy/core/scrub.ts b/packages/varlock/src/proxy/core/scrub.ts new file mode 100644 index 000000000..6e59c6dc0 --- /dev/null +++ b/packages/varlock/src/proxy/core/scrub.ts @@ -0,0 +1,81 @@ +import { replaceRealWithPlaceholders } from './substitution'; +import type { ProxyManagedItem } from './types'; + +/** Returns the first managed item whose real value still appears in `text` (a leak), if any. */ +export function findRealLeak(text: string, managedItems: Array): ProxyManagedItem | undefined { + return managedItems.find((item) => item.realValue.length > 0 && text.includes(item.realValue)); +} + +/** Item keys whose real value appears in `text` — i.e. the keys that get scrubbed back to placeholders. */ +export function detectScrubbedKeys(text: string, managedItems: Array): Array { + const keys: Array = []; + for (const item of managedItems) { + if (item.realValue.length > 0 && text.includes(item.realValue)) keys.push(item.key); + } + return keys; +} + +/** + * Length of the longest suffix of `text` that is a strict prefix of some real + * value — i.e. a partial real value that might complete in the next chunk and + * so must be held back. Returns 0 (emit everything) when the text doesn't end + * mid-secret, which keeps streaming responsive instead of buffering a fixed + * window every chunk. + */ +export function pendingRealPrefixLen(text: string, managedItems: Array): number { + let best = 0; + for (const item of managedItems) { + const real = item.realValue; + if (!real) continue; + const maxK = Math.min(real.length - 1, text.length); + for (let k = maxK; k > best; k -= 1) { + if (text.endsWith(real.slice(0, k))) { + best = k; + break; + } + } + } + return best; +} + +/** + * Scrub real values back to placeholders on an *unbounded text stream* (e.g. + * SSE), chunk by chunk, so a reflected secret in a streamed response is still + * replaced without buffering the whole stream. Operates on already-decoded text + * (the transport wraps it with its own byte decoder — node's StringDecoder or a + * streaming TextDecoder — so multi-byte UTF-8 chars stay intact across chunks); + * only a trailing *partial* real value is held back, so complete chunks flow + * through immediately. + */ +export class StreamingScrubber { + private carry = ''; + + constructor( + private managedItems: Array, + /** Called with each managed key whose real value is seen in the stream (pre-scrub). */ + private onScrubbedKey?: (key: string) => void, + ) {} + + private note(text: string) { + if (this.onScrubbedKey) for (const key of detectScrubbedKeys(text, this.managedItems)) this.onScrubbedKey(key); + } + + /** Scrub one decoded chunk; returns the text safe to emit now. */ + push(text: string): string { + const decoded = this.carry + text; + this.note(decoded); + const scrubbed = replaceRealWithPlaceholders(decoded, this.managedItems); + const hold = pendingRealPrefixLen(scrubbed, this.managedItems); + const emitLen = scrubbed.length - hold; + this.carry = scrubbed.slice(emitLen); + return scrubbed.slice(0, emitLen); + } + + /** Flush any held-back tail (plus a final decoded fragment, if any), fully scrubbed. */ + flush(text = ''): string { + const decoded = this.carry + text; + this.carry = ''; + this.note(decoded); + return replaceRealWithPlaceholders(decoded, this.managedItems); + } +} diff --git a/packages/varlock/src/proxy/core/substitution.ts b/packages/varlock/src/proxy/core/substitution.ts new file mode 100644 index 000000000..79c3f3b1c --- /dev/null +++ b/packages/varlock/src/proxy/core/substitution.ts @@ -0,0 +1,278 @@ +import type { RequestScopedManagedItem } from './policy'; +import { + isNeverAutoSubstituteHeader, proxySubstitutionTargetKey, + type ProxyManagedItem, type ProxySubstitutionLocation, type ProxySubstitutionTarget, +} from './types'; + +/** + * Number of non-overlapping occurrences of `needle` in `haystack`. Uses an + * indexOf scan rather than `split` so it stays O(n) time / O(1) extra space: an + * untrusted agent controls the request and could repeat a placeholder many times, + * and `split` would allocate an array proportional to the match count. + */ +export function countOccurrences(haystack: string, needle: string): number { + if (!needle) return 0; + let count = 0; + let idx = haystack.indexOf(needle); + while (idx !== -1) { + count += 1; + idx = haystack.indexOf(needle, idx + needle.length); + } + return count; +} + +/** A request decomposed into the parts the substitution guards inspect. */ +export type SubstitutionGuardRequest = { + /** Header name (lower-cased) + value, one entry per header. */ + headers: Array<{ name: string; value: string }>; + /** Request target: path + query string. */ + requestTarget: string; + /** Raw request body text. */ + body: string; + /** Content-type header value, if any (selects the body parser). */ + contentType?: string; +}; + +export type SubstitutionGuardViolation = | { kind: 'location'; item: RequestScopedManagedItem; location: ProxySubstitutionLocation; suggestion: string } + | { kind: 'occurrences'; item: RequestScopedManagedItem; count: number }; + +/** A string value in a request body, with the dotted path that locates it. */ +type BodyLeaf = { path: string; value: string }; + +/** + * String leaves of a request body, each with its dotted path, so a body-path + * target can be checked. JSON objects/arrays produce paths like `client_secret`, + * `data.token`, `items[0].key`; form bodies produce one leaf per field (path = + * field name). Returns null when the body can't be parsed for the content type — + * the guard treats that as "no allowed body occurrences" and fails closed. + */ +function bodyStringLeaves(body: string, contentType: string | undefined): Array | null { + const ct = (contentType ?? '').toLowerCase(); + if (ct.includes('application/x-www-form-urlencoded')) { + return [...new URLSearchParams(body)].map(([name, value]) => ({ path: name, value })); + } + let parsed: unknown; + try { + parsed = JSON.parse(body); + } catch { + return null; + } + const out: Array = []; + const walk = (node: unknown, prefix: string) => { + if (typeof node === 'string') { + out.push({ path: prefix, value: node }); + } else if (Array.isArray(node)) { + node.forEach((el, i) => walk(el, `${prefix}[${i}]`)); + } else if (node && typeof node === 'object') { + for (const [k, v] of Object.entries(node)) walk(v, prefix ? `${prefix}.${k}` : k); + } + // numbers/booleans/null can't contain a placeholder string — skip. + }; + walk(parsed, ''); + return out; +} + +/** A copy-pasteable `substituteIn=[...]` that keeps the current targets and adds `entry`. */ +function substituteInExample(targets: Array, entry: string): string { + return `substituteIn=[${[...targets.map(proxySubstitutionTargetKey), entry].join(', ')}]`; +} + +/** Human hint naming the current targets and the exact substituteIn edit to allow the offending location. */ +function locationSuggestion(location: ProxySubstitutionLocation, targets: Array): string { + const current = targets.map(proxySubstitutionTargetKey); + const entry = location === 'body' ? 'body:' : location; + const extraByLocation: Partial> = { + body: ' (name the field, e.g. body:client_secret, or body:* to allow anywhere in the body)', + query: ' (or query: to pin one parameter)', + }; + const extra = extraByLocation[location] ?? ''; + return `currently allowed: [${current.join(', ')}]. To allow it in the ${location}, set ${substituteInExample(targets, entry)} on the @proxy rule${extra}`; +} + +/** Header-specific hint: names the offending header, the exact substituteIn edit, and any denylist note. */ +function headerSuggestion(name: string | undefined, denied: boolean, targets: Array): string { + const current = targets.map(proxySubstitutionTargetKey); + const where = name ? `the "${name}" header` : 'that header'; + const entry = name ? `header:${name}` : 'header:'; + const deniedNote = denied + ? ` (${name} is excluded from the any-header default because it's commonly forwarded or logged)` + : ''; + return `currently allowed: [${current.join(', ')}]${deniedNote}. To allow it in ${where}, set ${substituteInExample(targets, entry)} on the @proxy rule`; +} + +/** + * Enforce the substitution guards on the injected items for a request, *before* + * any placeholder is swapped for its real value. Returns the first violation, or + * undefined if every injected placeholder sits only where its rule allows and + * within its occurrence cap. + * + * - placement guard: a placeholder occurrence anywhere the item's `targets` don't + * allow is an anomaly (default: any header). Each occurrence is checked against + * the exact target (specific header name, query param, or body path), which is + * what stops an injected secret from being swapped into a request body/query — a + * placeholder the agent was tricked into placing in, say, an email body on an + * otherwise-allowed host, even one whose body IS a substitution target at a + * different path. + * - cardinality guard: a valid request uses the secret a fixed number of times + * (default 1). An extra occurrence suggests an exfiltration copy (duplicate the + * token into an attacker-visible field while still making a valid call). + * + * Because placeholders are unique high-entropy tokens, the guard alone decides + * placement; the actual substitution can stay a blind string-replace, since a + * passing request has every occurrence at an allowed spot. + * + * Both fail closed: the caller blocks the request rather than substituting. + */ +export function checkSubstitutionGuards( + req: SubstitutionGuardRequest, + hostItems: Array, +): SubstitutionGuardViolation | undefined { + for (const item of hostItems) { + const ph = item.placeholder; + if (!ph) continue; + const { targets } = item; + const anyHeader = targets.some((t) => t.location === 'header' && !t.name); + const headerNames = new Set(targets.flatMap((t) => (t.location === 'header' && t.name ? [t.name] : []))); + const anyPath = targets.some((t) => t.location === 'path'); + const anyQuery = targets.some((t) => t.location === 'query' && !t.name); + const queryNames = targets.flatMap((t) => (t.location === 'query' && t.name ? [t.name] : [])); + const bodyPaths = targets.flatMap((t) => (t.location === 'body' ? [t.path] : [])); + // `body:*` is the explicit escape hatch for bodies we can't parse into a path. + const bodyAnywhere = bodyPaths.includes('*'); + + // Split the request target into the URL path and the query string: they are + // separate substitution locations (`path` vs `query`/`query:`). + const queryStart = req.requestTarget.indexOf('?'); + const pathPart = queryStart === -1 ? req.requestTarget : req.requestTarget.slice(0, queryStart); + const queryPart = queryStart === -1 ? '' : req.requestTarget.slice(queryStart + 1); + + // Headers: total occurrences vs. those in an allowed header. The any-header + // default excludes a denylist of never-secret forward/log headers; an explicit + // header: target still wins (so a named denied header is allowed). + let headerTotal = 0; + let headerAllowed = 0; + let offendingHeader: string | undefined; + for (const h of req.headers) { + const c = countOccurrences(h.value, ph); + if (!c) continue; + headerTotal += c; + const allowed = headerNames.has(h.name) || (anyHeader && !isNeverAutoSubstituteHeader(h.name)); + if (allowed) headerAllowed += c; + else offendingHeader ||= h.name; + } + if (headerAllowed < headerTotal) { + const denied = anyHeader && !!offendingHeader && isNeverAutoSubstituteHeader(offendingHeader); + return { + kind: 'location', item, location: 'header', suggestion: headerSuggestion(offendingHeader, denied, targets), + }; + } + + // URL path: all-or-nothing (`path` allows a token anywhere in the path). + const pathTotal = countOccurrences(pathPart, ph); + if (pathTotal > 0 && !anyPath) { + return { + kind: 'location', item, location: 'path', suggestion: locationSuggestion('path', targets), + }; + } + + // Query string: total occurrences vs. those in an allowed param. + const queryTotal = countOccurrences(queryPart, ph); + let queryAllowed = 0; + if (queryTotal) { + if (anyQuery) { + queryAllowed = queryTotal; + } else if (queryNames.length) { + const params = new URLSearchParams(queryPart); + for (const name of queryNames) for (const v of params.getAll(name)) queryAllowed += countOccurrences(v, ph); + } + } + if (queryAllowed < queryTotal) { + return { + kind: 'location', item, location: 'query', suggestion: locationSuggestion('query', targets), + }; + } + + // Body: total occurrences vs. those at an allowed path. `body:*` allows anywhere + // (no parse needed); otherwise an unparseable body (leaves === null) allows + // nothing, so a `body:` target fails closed on a body we can't parse. + const bodyTotal = countOccurrences(req.body, ph); + let bodyAllowed = 0; + if (bodyTotal && bodyAnywhere) { + bodyAllowed = bodyTotal; + } else if (bodyTotal && bodyPaths.length) { + const leaves = bodyStringLeaves(req.body, req.contentType); + if (leaves) { + for (const leaf of leaves) if (bodyPaths.includes(leaf.path)) bodyAllowed += countOccurrences(leaf.value, ph); + } + } + if (bodyAllowed < bodyTotal) { + return { + kind: 'location', item, location: 'body', suggestion: locationSuggestion('body', targets), + }; + } + + const total = headerTotal + pathTotal + queryTotal + bodyTotal; + if (total > item.maxOccurrences) return { kind: 'occurrences', item, count: total }; + } + return undefined; +} + +export function replacePlaceholdersWithReal(value: string, managedItems: Array): string { + let next = value; + // Longest placeholder first, mirroring the scrub direction: if one placeholder + // is a substring of another (e.g. `vlk_x` and `vlk_x_1`), replacing the shorter + // one first would corrupt the longer one and splice in the wrong real value. + const sortedByPlaceholderLength = [...managedItems] + .filter((item) => !!item.placeholder) + .sort((a, b) => b.placeholder.length - a.placeholder.length); + for (const item of sortedByPlaceholderLength) { + next = next.split(item.placeholder).join(item.realValue); + } + return next; +} + +/** + * Which managed items' placeholders actually appear in this request — i.e. the + * secrets that will really be injected. Used for the audit log so it records + * what was injected (keys only), not merely what was in scope. + */ +export function detectInjectedKeys(parts: Array, hostItems: Array): Array { + const keys: Array = []; + for (const item of hostItems) { + if (!item.placeholder) continue; + if (parts.some((part) => part.includes(item.placeholder))) keys.push(item.key); + } + return keys; +} + +/** + * Find a managed placeholder present in the outbound request that is NOT being + * injected on this route (`injectHere`). Such a placeholder would reach the + * upstream un-substituted and fail with a cryptic auth error, and the cause is + * the proxy rules (wrong path/method, or wrong host) — so we catch it and + * explain, rather than forwarding a doomed request. Placeholders are unique + * per item, so a match is unambiguous (no false positives). + */ +export function findUninjectedPlaceholder( + parts: Array, + managedItems: Array, + injectHere: Array, +): ProxyManagedItem | undefined { + const injectedKeys = new Set(injectHere.map((item) => item.key)); + return managedItems.find( + (item) => item.placeholder.length > 0 + && !injectedKeys.has(item.key) + && parts.some((part) => part.includes(item.placeholder)), + ); +} + +export function replaceRealWithPlaceholders(value: string, managedItems: Array): string { + let next = value; + const sortedByRealLength = [...managedItems] + .filter((item) => !!item.realValue && !!item.placeholder) + .sort((a, b) => b.realValue.length - a.realValue.length); + for (const item of sortedByRealLength) { + next = next.split(item.realValue).join(item.placeholder); + } + return next; +} diff --git a/packages/varlock/src/proxy/types.ts b/packages/varlock/src/proxy/core/types.ts similarity index 100% rename from packages/varlock/src/proxy/types.ts rename to packages/varlock/src/proxy/core/types.ts diff --git a/packages/varlock/src/proxy/runtime-proxy.test.ts b/packages/varlock/src/proxy/runtime-proxy.test.ts index 29df54247..658e58fbd 100644 --- a/packages/varlock/src/proxy/runtime-proxy.test.ts +++ b/packages/varlock/src/proxy/runtime-proxy.test.ts @@ -8,13 +8,14 @@ import { } from 'node:fs'; import { URL } from 'node:url'; -import type { ProxyActivity } from './audit'; +import { dataPlaneAuthOk, parseProxyAuthToken } from './core/auth'; +import type { RequestScopedManagedItem } from './core/policy'; import { - checkSubstitutionGuards, dataPlaneAuthOk, findUninjectedPlaceholder, parseProxyAuthToken, - replacePlaceholdersWithReal, startLocalProxyRuntime, + checkSubstitutionGuards, findUninjectedPlaceholder, replacePlaceholdersWithReal, type SubstitutionGuardRequest, -} from './runtime-proxy'; -import type { RequestScopedManagedItem } from './policy'; +} from './core/substitution'; +import type { ProxyActivity } from './audit'; +import { startLocalProxyRuntime } from './runtime-proxy'; /** Bind an ephemeral port, capture it, release it — a free port for a fixed-port test. */ function getFreePort(): Promise { diff --git a/packages/varlock/src/proxy/runtime-proxy.ts b/packages/varlock/src/proxy/runtime-proxy.ts index de4c2be48..969a916fc 100644 --- a/packages/varlock/src/proxy/runtime-proxy.ts +++ b/packages/varlock/src/proxy/runtime-proxy.ts @@ -1,4 +1,3 @@ -import { timingSafeEqual } from 'node:crypto'; import { mkdir, mkdtemp, readFile, rm, writeFile, } from 'node:fs/promises'; @@ -12,6 +11,26 @@ import { StringDecoder } from 'node:string_decoder'; import tls from 'node:tls'; import { URL } from 'node:url'; +import { + dataPlaneAuthOk, isLoopbackAddress, isLoopbackBind, tokenMatches, +} from './core/auth'; +import { + getHeaderValue, isTextLikeResponse, isUncompressedResponse, + redactOutgoingHeaders, shouldRedactResponseBody, transformHeaders, +} from './core/headers'; +import { + evaluateProxiedRequestPreBody, evaluateProxiedRequestWithBody, hostMatchesProxyRules, + type ApprovalGateFn, type ProxiedRequestFacts, type ProxyPolicyState, +} from './core/pipeline'; +import { normalizeHost } from './core/policy'; +import { + detectScrubbedKeys, findRealLeak, StreamingScrubber, +} from './core/scrub'; +import { replaceRealWithPlaceholders } from './core/substitution'; +import type { + ProxyEgressMode, ProxyManagedItem, ProxyRule, +} from './core/types'; + import { createApprovalRequest, isApprovalValid, type ApprovalProvider, } from './approval'; @@ -19,19 +38,10 @@ import type { ProxyActivity } from './audit'; import { createEphemeralCa, createHostCert, exportCaPrivateKeyPem, loadCa, } from './cert-authority'; -import { - describeRule, domainMatches, evaluateProxyPolicy, getRequestScopedManagedItems, normalizeHost, - type RequestFacts, type RequestScopedManagedItem, -} from './policy'; import { PROXY_TOKEN_HEADER, SESSION_ENV_ENDPOINT_PATH, VARLOCK_INTERNAL_HOST, } from './session-env-payload'; import { attachTunnelServer, type TunnelBootstrap } from './tunnel'; -import { - isNeverAutoSubstituteHeader, proxySubstitutionTargetKey, - type ProxyApprovalEach, type ProxyEgressMode, type ProxyManagedItem, type ProxyRule, - type ProxySubstitutionLocation, type ProxySubstitutionTarget, -} from './types'; const LOCALHOST = '127.0.0.1'; @@ -146,8 +156,6 @@ export type SessionEnvPayloadMeta = { type HostInfo = { host: string, port: number }; -type HeaderTransformFn = (value: string) => string; - function parseHostPort(value: string): HostInfo | null { // Parse via URL so bracketed IPv6 literals (`[::1]:443`) are handled — a plain // `split(':')` mangles them. The hostname comes back bracketed for IPv6; strip @@ -165,10 +173,6 @@ function parseHostPort(value: string): HostInfo | null { } } -function hostMatchesProxyRules(host: string, rules: Array): boolean { - return rules.some((rule) => rule.domain.some((d) => domainMatches(d, host))); -} - /** * Invariant #1: bind secret injection to the *verified upstream TLS identity*, * not the requested name. Opens a TLS connection to the rule-matched host, proves @@ -234,315 +238,6 @@ function verifyUpstreamIdentity(host: string, port: number): Promise<{ address: }); } -/** - * Run the request-bound approval gate (Invariant #8). Builds an ApprovalRequest - * committed to this exact request, asks the provider, and returns whether the - * decision actually authorizes it. Fails closed: no provider, a throwing - * provider, a nonce mismatch, or an expired/denied decision all return false. - */ -async function runApprovalGate(input: { - approvalProvider: ApprovalProvider | undefined; - method: string; - host: string; - path: string; - body: Buffer; - ruleId?: string; - each?: ProxyApprovalEach; - maxDurationMs?: number; - injectedKeys: Array; -}): Promise { - if (!input.approvalProvider) return false; - const request = createApprovalRequest({ - method: input.method, - host: input.host, - path: input.path, - body: input.body, - ruleId: input.ruleId, - each: input.each, - maxDurationMs: input.maxDurationMs, - injectedKeys: input.injectedKeys, - }); - try { - const decision = await input.approvalProvider.requestApproval(request); - return isApprovalValid(request, decision); - } catch { - return false; - } -} - -/** - * Number of non-overlapping occurrences of `needle` in `haystack`. Uses an - * indexOf scan rather than `split` so it stays O(n) time / O(1) extra space: an - * untrusted agent controls the request and could repeat a placeholder many times, - * and `split` would allocate an array proportional to the match count. - */ -function countOccurrences(haystack: string, needle: string): number { - if (!needle) return 0; - let count = 0; - let idx = haystack.indexOf(needle); - while (idx !== -1) { - count += 1; - idx = haystack.indexOf(needle, idx + needle.length); - } - return count; -} - -/** A request decomposed into the parts the substitution guards inspect. */ -export type SubstitutionGuardRequest = { - /** Header name (lower-cased) + value, one entry per header. */ - headers: Array<{ name: string; value: string }>; - /** Request target: path + query string. */ - requestTarget: string; - /** Raw request body text. */ - body: string; - /** Content-type header value, if any (selects the body parser). */ - contentType?: string; -}; - -export type SubstitutionGuardViolation = | { kind: 'location'; item: RequestScopedManagedItem; location: ProxySubstitutionLocation; suggestion: string } - | { kind: 'occurrences'; item: RequestScopedManagedItem; count: number }; - -/** A string value in a request body, with the dotted path that locates it. */ -type BodyLeaf = { path: string; value: string }; - -/** - * String leaves of a request body, each with its dotted path, so a body-path - * target can be checked. JSON objects/arrays produce paths like `client_secret`, - * `data.token`, `items[0].key`; form bodies produce one leaf per field (path = - * field name). Returns null when the body can't be parsed for the content type — - * the guard treats that as "no allowed body occurrences" and fails closed. - */ -function bodyStringLeaves(body: string, contentType: string | undefined): Array | null { - const ct = (contentType ?? '').toLowerCase(); - if (ct.includes('application/x-www-form-urlencoded')) { - return [...new URLSearchParams(body)].map(([name, value]) => ({ path: name, value })); - } - let parsed: unknown; - try { - parsed = JSON.parse(body); - } catch { - return null; - } - const out: Array = []; - const walk = (node: unknown, prefix: string) => { - if (typeof node === 'string') { - out.push({ path: prefix, value: node }); - } else if (Array.isArray(node)) { - node.forEach((el, i) => walk(el, `${prefix}[${i}]`)); - } else if (node && typeof node === 'object') { - for (const [k, v] of Object.entries(node)) walk(v, prefix ? `${prefix}.${k}` : k); - } - // numbers/booleans/null can't contain a placeholder string — skip. - }; - walk(parsed, ''); - return out; -} - -/** A copy-pasteable `substituteIn=[...]` that keeps the current targets and adds `entry`. */ -function substituteInExample(targets: Array, entry: string): string { - return `substituteIn=[${[...targets.map(proxySubstitutionTargetKey), entry].join(', ')}]`; -} - -/** Human hint naming the current targets and the exact substituteIn edit to allow the offending location. */ -function locationSuggestion(location: ProxySubstitutionLocation, targets: Array): string { - const current = targets.map(proxySubstitutionTargetKey); - const entry = location === 'body' ? 'body:' : location; - const extraByLocation: Partial> = { - body: ' (name the field, e.g. body:client_secret, or body:* to allow anywhere in the body)', - query: ' (or query: to pin one parameter)', - }; - const extra = extraByLocation[location] ?? ''; - return `currently allowed: [${current.join(', ')}]. To allow it in the ${location}, set ${substituteInExample(targets, entry)} on the @proxy rule${extra}`; -} - -/** Header-specific hint: names the offending header, the exact substituteIn edit, and any denylist note. */ -function headerSuggestion(name: string | undefined, denied: boolean, targets: Array): string { - const current = targets.map(proxySubstitutionTargetKey); - const where = name ? `the "${name}" header` : 'that header'; - const entry = name ? `header:${name}` : 'header:'; - const deniedNote = denied - ? ` (${name} is excluded from the any-header default because it's commonly forwarded or logged)` - : ''; - return `currently allowed: [${current.join(', ')}]${deniedNote}. To allow it in ${where}, set ${substituteInExample(targets, entry)} on the @proxy rule`; -} - -/** - * Enforce the substitution guards on the injected items for a request, *before* - * any placeholder is swapped for its real value. Returns the first violation, or - * undefined if every injected placeholder sits only where its rule allows and - * within its occurrence cap. - * - * - placement guard: a placeholder occurrence anywhere the item's `targets` don't - * allow is an anomaly (default: any header). Each occurrence is checked against - * the exact target (specific header name, query param, or body path), which is - * what stops an injected secret from being swapped into a request body/query — a - * placeholder the agent was tricked into placing in, say, an email body on an - * otherwise-allowed host, even one whose body IS a substitution target at a - * different path. - * - cardinality guard: a valid request uses the secret a fixed number of times - * (default 1). An extra occurrence suggests an exfiltration copy (duplicate the - * token into an attacker-visible field while still making a valid call). - * - * Because placeholders are unique high-entropy tokens, the guard alone decides - * placement; the actual substitution can stay a blind string-replace, since a - * passing request has every occurrence at an allowed spot. - * - * Both fail closed: the caller blocks the request rather than substituting. - */ -export function checkSubstitutionGuards( - req: SubstitutionGuardRequest, - hostItems: Array, -): SubstitutionGuardViolation | undefined { - for (const item of hostItems) { - const ph = item.placeholder; - if (!ph) continue; - const { targets } = item; - const anyHeader = targets.some((t) => t.location === 'header' && !t.name); - const headerNames = new Set(targets.flatMap((t) => (t.location === 'header' && t.name ? [t.name] : []))); - const anyPath = targets.some((t) => t.location === 'path'); - const anyQuery = targets.some((t) => t.location === 'query' && !t.name); - const queryNames = targets.flatMap((t) => (t.location === 'query' && t.name ? [t.name] : [])); - const bodyPaths = targets.flatMap((t) => (t.location === 'body' ? [t.path] : [])); - // `body:*` is the explicit escape hatch for bodies we can't parse into a path. - const bodyAnywhere = bodyPaths.includes('*'); - - // Split the request target into the URL path and the query string: they are - // separate substitution locations (`path` vs `query`/`query:`). - const queryStart = req.requestTarget.indexOf('?'); - const pathPart = queryStart === -1 ? req.requestTarget : req.requestTarget.slice(0, queryStart); - const queryPart = queryStart === -1 ? '' : req.requestTarget.slice(queryStart + 1); - - // Headers: total occurrences vs. those in an allowed header. The any-header - // default excludes a denylist of never-secret forward/log headers; an explicit - // header: target still wins (so a named denied header is allowed). - let headerTotal = 0; - let headerAllowed = 0; - let offendingHeader: string | undefined; - for (const h of req.headers) { - const c = countOccurrences(h.value, ph); - if (!c) continue; - headerTotal += c; - const allowed = headerNames.has(h.name) || (anyHeader && !isNeverAutoSubstituteHeader(h.name)); - if (allowed) headerAllowed += c; - else offendingHeader ||= h.name; - } - if (headerAllowed < headerTotal) { - const denied = anyHeader && !!offendingHeader && isNeverAutoSubstituteHeader(offendingHeader); - return { - kind: 'location', item, location: 'header', suggestion: headerSuggestion(offendingHeader, denied, targets), - }; - } - - // URL path: all-or-nothing (`path` allows a token anywhere in the path). - const pathTotal = countOccurrences(pathPart, ph); - if (pathTotal > 0 && !anyPath) { - return { - kind: 'location', item, location: 'path', suggestion: locationSuggestion('path', targets), - }; - } - - // Query string: total occurrences vs. those in an allowed param. - const queryTotal = countOccurrences(queryPart, ph); - let queryAllowed = 0; - if (queryTotal) { - if (anyQuery) { - queryAllowed = queryTotal; - } else if (queryNames.length) { - const params = new URLSearchParams(queryPart); - for (const name of queryNames) for (const v of params.getAll(name)) queryAllowed += countOccurrences(v, ph); - } - } - if (queryAllowed < queryTotal) { - return { - kind: 'location', item, location: 'query', suggestion: locationSuggestion('query', targets), - }; - } - - // Body: total occurrences vs. those at an allowed path. `body:*` allows anywhere - // (no parse needed); otherwise an unparseable body (leaves === null) allows - // nothing, so a `body:` target fails closed on a body we can't parse. - const bodyTotal = countOccurrences(req.body, ph); - let bodyAllowed = 0; - if (bodyTotal && bodyAnywhere) { - bodyAllowed = bodyTotal; - } else if (bodyTotal && bodyPaths.length) { - const leaves = bodyStringLeaves(req.body, req.contentType); - if (leaves) { - for (const leaf of leaves) if (bodyPaths.includes(leaf.path)) bodyAllowed += countOccurrences(leaf.value, ph); - } - } - if (bodyAllowed < bodyTotal) { - return { - kind: 'location', item, location: 'body', suggestion: locationSuggestion('body', targets), - }; - } - - const total = headerTotal + pathTotal + queryTotal + bodyTotal; - if (total > item.maxOccurrences) return { kind: 'occurrences', item, count: total }; - } - return undefined; -} - -export function replacePlaceholdersWithReal(value: string, managedItems: Array): string { - let next = value; - // Longest placeholder first, mirroring the scrub direction: if one placeholder - // is a substring of another (e.g. `vlk_x` and `vlk_x_1`), replacing the shorter - // one first would corrupt the longer one and splice in the wrong real value. - const sortedByPlaceholderLength = [...managedItems] - .filter((item) => !!item.placeholder) - .sort((a, b) => b.placeholder.length - a.placeholder.length); - for (const item of sortedByPlaceholderLength) { - next = next.split(item.placeholder).join(item.realValue); - } - return next; -} - -/** - * Which managed items' placeholders actually appear in this request — i.e. the - * secrets that will really be injected. Used for the audit log so it records - * what was injected (keys only), not merely what was in scope. - */ -function detectInjectedKeys(parts: Array, hostItems: Array): Array { - const keys: Array = []; - for (const item of hostItems) { - if (!item.placeholder) continue; - if (parts.some((part) => part.includes(item.placeholder))) keys.push(item.key); - } - return keys; -} - -/** - * Find a managed placeholder present in the outbound request that is NOT being - * injected on this route (`injectHere`). Such a placeholder would reach the - * upstream un-substituted and fail with a cryptic auth error, and the cause is - * the proxy rules (wrong path/method, or wrong host) — so we catch it and - * explain, rather than forwarding a doomed request. Placeholders are unique - * per item, so a match is unambiguous (no false positives). - */ -export function findUninjectedPlaceholder( - parts: Array, - managedItems: Array, - injectHere: Array, -): ProxyManagedItem | undefined { - const injectedKeys = new Set(injectHere.map((item) => item.key)); - return managedItems.find( - (item) => item.placeholder.length > 0 - && !injectedKeys.has(item.key) - && parts.some((part) => part.includes(item.placeholder)), - ); -} - -function replaceRealWithPlaceholders(value: string, managedItems: Array): string { - let next = value; - const sortedByRealLength = [...managedItems] - .filter((item) => !!item.realValue && !!item.placeholder) - .sort((a, b) => b.realValue.length - a.realValue.length); - for (const item of sortedByRealLength) { - next = next.split(item.realValue).join(item.placeholder); - } - return next; -} - /** * Fail-closed response for a blocked/failed request. When `teardown` is set (the * MITM tunnel path), short status-only responses don't reliably flush through the @@ -572,60 +267,6 @@ function respondBlocked( if (teardown) res.socket?.destroy(); } -/** Constant-time token comparison (length leak is fine; the token is a uuid, not a password). */ -function tokenMatches(provided: unknown, expected: string): boolean { - if (typeof provided !== 'string') return false; - const providedBuf = Buffer.from(provided); - const expectedBuf = Buffer.from(expected); - if (providedBuf.length !== expectedBuf.length) return false; - return timingSafeEqual(providedBuf, expectedBuf); -} - -function isLoopbackAddress(addr: string | undefined): boolean { - if (!addr) return false; - return addr === '::1' || addr === '::ffff:127.0.0.1' || addr.startsWith('127.'); -} - -/** True if a listen host binds only loopback (so remote peers can't reach it). */ -function isLoopbackBind(host: string): boolean { - return host === 'localhost' || isLoopbackAddress(host); -} - -/** Extract the token from a `Proxy-Authorization: Basic base64(user:token)` header. */ -export function parseProxyAuthToken(header: string | Array | undefined): string | undefined { - const value = Array.isArray(header) ? header[0] : header; - if (typeof value !== 'string') return undefined; - const spaceIdx = value.indexOf(' '); - if (spaceIdx === -1) return undefined; - const scheme = value.slice(0, spaceIdx); - const encoded = value.slice(spaceIdx + 1).trim(); - if (scheme.toLowerCase() !== 'basic' || !encoded) return undefined; - let decoded: string; - try { - decoded = Buffer.from(encoded, 'base64').toString('utf8'); - } catch { - return undefined; - } - const colon = decoded.indexOf(':'); - // Basic is `user:pass`; the token is the password half (username is cosmetic). - return colon === -1 ? decoded : decoded.slice(colon + 1); -} - -/** - * Data-plane gate. Loopback peers are same-uid-trusted and always pass (the - * historical model). A non-loopback peer — only reachable when the listener is - * bound off-loopback — must present the session's `Proxy-Authorization` token. - */ -export function dataPlaneAuthOk( - peerAddr: string | undefined, - header: string | Array | undefined, - token: string | undefined, -): boolean { - if (isLoopbackAddress(peerAddr)) return true; - if (!token) return false; // non-loopback bind without a token: fail closed - return tokenMatches(parseProxyAuthToken(header), token); -} - /** Transport-specific inputs for a proxied request, shared by the MITM-tunnel and * absolute-form (plain http) handlers so the policy/approval/injection/forwarding * logic lives in one place. */ @@ -644,154 +285,28 @@ type ProxiedRequestTransport = { tunnelTeardown: boolean; }; -function transformHeaders( - headers: http.IncomingHttpHeaders, - transformValue: HeaderTransformFn, -): Record> { - const out: Record> = {}; - for (const [key, val] of Object.entries(headers)) { - if (val === undefined) continue; - if (Array.isArray(val)) { - out[key] = val.map((v) => transformValue(v)); - } else { - out[key] = transformValue(String(val)); - } - } - return out; -} - -function getHeaderValue( - headers: http.IncomingHttpHeaders, - key: string, -): string | undefined { - const raw = headers[key.toLowerCase()]; - if (raw === undefined) return undefined; - if (Array.isArray(raw)) return raw[0]; - return String(raw); -} - -function isUncompressedResponse(headers: http.IncomingHttpHeaders): boolean { - const contentEncoding = getHeaderValue(headers, 'content-encoding'); - if (!contentEncoding) return true; - const tokens = contentEncoding.split(',').map((token) => token.trim().toLowerCase()).filter(Boolean); - if (!tokens.length) return true; - return tokens.every((token) => token === 'identity'); -} - -function isTextLikeResponse(headers: http.IncomingHttpHeaders): boolean { - const contentType = getHeaderValue(headers, 'content-type')?.toLowerCase(); - if (!contentType) return false; - return contentType.startsWith('text/') - || contentType.includes('json') - || contentType.includes('xml') - || contentType.includes('javascript') - || contentType.includes('x-www-form-urlencoded') - || contentType.includes('graphql'); -} - -// Only buffer-and-redact bounded, reasonably small text bodies. Anything we -// can't size up front (SSE, chunked streams) or that's too large is streamed -// straight through — buffering it would break streaming (e.g. LLM token-by-token -// responses hang until complete) for a low-value protection: the injected secret -// is in the request, not the response. Header redaction still applies regardless. -const MAX_REDACT_BODY_BYTES = 2 * 1024 * 1024; - -function isStreamingResponse(headers: http.IncomingHttpHeaders): boolean { - const contentType = getHeaderValue(headers, 'content-type')?.toLowerCase() ?? ''; - return contentType.includes('text/event-stream'); -} - -function isBoundedRedactableBody(headers: http.IncomingHttpHeaders): boolean { - const lenRaw = getHeaderValue(headers, 'content-length'); - if (lenRaw === undefined) return false; // unknown size — treat as a stream, never buffer - const len = Number(lenRaw); - return Number.isFinite(len) && len >= 0 && len <= MAX_REDACT_BODY_BYTES; -} - -function shouldRedactResponseBody(headers: http.IncomingHttpHeaders): boolean { - return isUncompressedResponse(headers) - && isTextLikeResponse(headers) - && !isStreamingResponse(headers) - && isBoundedRedactableBody(headers); -} - -function redactOutgoingHeaders( - headers: http.IncomingHttpHeaders, - managedItems: Array, -): Record> { - return transformHeaders( - headers, - (value) => replaceRealWithPlaceholders(value, managedItems), - ); -} - -/** Returns the first managed item whose real value still appears in `text` (a leak), if any. */ -function findRealLeak(text: string, managedItems: Array): ProxyManagedItem | undefined { - return managedItems.find((item) => item.realValue.length > 0 && text.includes(item.realValue)); -} - -/** Item keys whose real value appears in `text` — i.e. the keys that get scrubbed back to placeholders. */ -function detectScrubbedKeys(text: string, managedItems: Array): Array { - const keys: Array = []; - for (const item of managedItems) { - if (item.realValue.length > 0 && text.includes(item.realValue)) keys.push(item.key); - } - return keys; -} - -/** - * Length of the longest suffix of `text` that is a strict prefix of some real - * value — i.e. a partial real value that might complete in the next chunk and - * so must be held back. Returns 0 (emit everything) when the text doesn't end - * mid-secret, which keeps streaming responsive instead of buffering a fixed - * window every chunk. - */ -function pendingRealPrefixLen(text: string, managedItems: Array): number { - let best = 0; - for (const item of managedItems) { - const real = item.realValue; - if (!real) continue; - const maxK = Math.min(real.length - 1, text.length); - for (let k = maxK; k > best; k -= 1) { - if (text.endsWith(real.slice(0, k))) { - best = k; - break; - } - } - } - return best; -} - /** * Scrub real values back to placeholders on an *unbounded text stream* (e.g. * SSE), chunk by chunk, so a reflected secret in a streamed response is still * replaced for the child without buffering the whole stream. A StringDecoder - * keeps multi-byte UTF-8 chars intact across chunks; only a trailing *partial* - * real value is held back, so complete chunks flow through immediately. + * keeps multi-byte UTF-8 chars intact across chunks; the hold-back of trailing + * partial real values lives in the shared StreamingScrubber. */ function createScrubbingTransform( managedItems: Array, matchedKeys?: Set, ): Transform { const decoder = new StringDecoder('utf8'); - let carry = ''; - const note = (text: string) => { - if (matchedKeys) for (const key of detectScrubbedKeys(text, managedItems)) matchedKeys.add(key); - }; + const scrubber = new StreamingScrubber( + managedItems, + matchedKeys ? (key) => matchedKeys.add(key) : undefined, + ); return new Transform({ transform(chunk, _enc, cb) { - const decoded = carry + decoder.write(chunk as Buffer); - note(decoded); - const scrubbed = replaceRealWithPlaceholders(decoded, managedItems); - const hold = pendingRealPrefixLen(scrubbed, managedItems); - const emitLen = scrubbed.length - hold; - carry = scrubbed.slice(emitLen); - cb(null, Buffer.from(scrubbed.slice(0, emitLen), 'utf8')); + cb(null, Buffer.from(scrubber.push(decoder.write(chunk as Buffer)), 'utf8')); }, flush(cb) { - const decoded = carry + decoder.end(); - note(decoded); - cb(null, Buffer.from(replaceRealWithPlaceholders(decoded, managedItems), 'utf8')); + cb(null, Buffer.from(scrubber.flush(decoder.end()), 'utf8')); }, }); } @@ -1027,10 +542,13 @@ export async function startLocalProxyRuntime({ } catch { /* client went away */ } }; - // Shared request pipeline for both transports (MITM tunnel + absolute-form http): - // egress gate → per-call policy (block) → cleartext guard → approval gate → - // scrub+inject → forward upstream (verified identity) → scrub response. Every - // failure path fails closed via respondBlocked. + // Shared request pipeline for both transports (MITM tunnel + absolute-form http). + // The decision order — egress gate → per-call policy (block) → cleartext guard → + // uninjected-placeholder guard → substitution guards → approval gate → + // scrub+inject — lives in @varlock/proxy-core's two-phase pipeline; this + // adapter buffers the body between phases, records activity, responds to + // blocked outcomes (every failure path fails closed via respondBlocked), and + // forwards allowed requests upstream over a verified-identity connection. const processProxiedRequest = async ( req: http.IncomingMessage, res: http.ServerResponse, @@ -1041,172 +559,60 @@ export async function startLocalProxyRuntime({ return; } - const baseActivity = { - host: t.host, method: t.method, path: t.pathOnly, url: t.requestTarget, + // One policy snapshot per request: `reconfigure` swaps the bindings, and a + // request must not see a mix of old and new policy across its phases. + const policy: ProxyPolicyState = { rules, managedItems, egressMode }; + const facts: ProxiedRequestFacts = { + host: t.host, + isHttps: t.isHttps, + method: t.method, + pathOnly: t.pathOnly, + requestTarget: t.requestTarget, }; - const shouldRewrite = hostMatchesProxyRules(t.host, rules); - const shouldAllowEgress = egressMode === 'permissive' || shouldRewrite; - if (!shouldAllowEgress) { - onActivity?.({ - ...baseActivity, matched: shouldRewrite, blocked: true, decision: 'blocked-egress', - }); - respondBlocked(res, 403, `Blocked by the varlock credential proxy: ${t.host} is not allowed by your egress policy (strict mode only permits hosts with a matching @proxy rule). Add a @proxy rule for this host, or use permissive egress, to allow it.`, false); - return; - } - - // Per-call policy (static authorization): evaluate host + method + path; a - // matching `block` rule denies the request and it never reaches upstream. - const facts: RequestFacts = { host: t.host, method: t.method, path: t.pathOnly }; - const policyDecision = shouldRewrite ? evaluateProxyPolicy(facts, rules, egressMode) : undefined; - const ruleIdStr = policyDecision?.matchedRule ? describeRule(policyDecision.matchedRule) : undefined; - const ruleId = ruleIdStr ? { ruleId: ruleIdStr } : {}; - if (policyDecision?.verdict === 'deny') { - // Two deny kinds: an explicit `block` rule (denylist), or strict egress with - // no allow rule matching this method/path on an otherwise-ruled host. - const egressStrictDeny = policyDecision.denyKind === 'egress-strict'; - onActivity?.({ - ...baseActivity, ...ruleId, matched: true, blocked: true, decision: egressStrictDeny ? 'blocked-egress' : 'deny', - }); - const message = egressStrictDeny - ? `Blocked by the varlock credential proxy: no @proxy rule matches ${t.method} ${t.host}${t.pathOnly}. ` - + 'The host has a @proxy rule, but none matches this method and path, and egress is strict. ' - + 'Add a matching (or broader) @proxy rule, or use permissive egress.' - : `Blocked by the varlock credential proxy: a @proxy block rule denies ${t.method} ${t.host}${t.pathOnly}.`; - respondBlocked(res, 403, message, t.tunnelTeardown); - return; - } - - // Approval-gated keys (contributed only by `@proxy(approval)` rules) are - // withheld unless the verdict actually routes through the approval gate below. - // A plain-`allow` verdict from a more-specific rule must NOT smuggle a broader - // approval rule's secret in without a prompt (see getRequestScopedManagedItems). - const hostItems = shouldRewrite - ? getRequestScopedManagedItems(facts, rules, managedItems, { - includeApprovalGatedKeys: policyDecision?.verdict === 'require-approval', - }) - : []; - - // Invariant #2/#5: never inject a secret into a cleartext (non-TLS) connection — - // no cert means no verifiable identity. Fail closed. (MITM is always https, so - // this only fires on the absolute-form http path.) - if (hostItems.length > 0 && !t.isHttps) { - onActivity?.({ - ...baseActivity, ...ruleId, matched: true, blocked: true, decision: 'blocked-cleartext', - }); - respondBlocked(res, 403, `Blocked by the varlock credential proxy: refusing to inject a secret into a cleartext (non-TLS) connection to ${t.host}.`, false); + const pre = evaluateProxiedRequestPreBody(facts, policy); + if (pre.kind === 'blocked') { + onActivity?.(pre.activity); + respondBlocked(res, pre.status, pre.message, pre.teardownOnTunnel && t.tunnelTeardown); return; } const body = await readBody(req); const bodyText = body.toString('utf8'); - const scanParts = [t.requestTarget, JSON.stringify(req.headers), bodyText]; - const injectedKeys = shouldRewrite ? detectInjectedKeys(scanParts, hostItems) : []; - // Helpful-failure guard: when NO rule injects anything on this route yet the - // request carries a managed placeholder, the real value won't be substituted - // and the upstream would reject it with a cryptic auth error — and the cause - // is the proxy rules (wrong path/method, or wrong host). Explain it instead of - // forwarding a doomed request. Scoped to `hostItems.length === 0` so a request - // that DOES inject on this route can still carry an unrelated placeholder - // (e.g. another item's, bound for a different host) through untouched. - const leaked = hostItems.length === 0 - ? findUninjectedPlaceholder(scanParts, managedItems, hostItems) - : undefined; - if (leaked) { - onActivity?.({ - ...baseActivity, ...ruleId, matched: shouldRewrite, blocked: true, decision: 'blocked-uninjected', - }); - respondBlocked(res, 403, `Blocked by the varlock credential proxy: this request to ${t.host}${t.pathOnly} carries the placeholder for ${leaked.key}, ` - + 'but no @proxy rule injects it here — the real value was not substituted and the request would fail upstream. ' - + 'Add or broaden a @proxy rule so it matches this request (host + path + method).', t.tunnelTeardown); - return; - } - - // Substitution guards: before any placeholder is swapped for its real value, - // enforce *where* (target: header / header:name / query:param / body:path) and - // *how often* (occurrence cap) each injected secret may appear. Default is any - // header, once. This is what keeps a clever request from moving the real secret - // into an exfiltration-friendly spot (an email body, a duplicated field) on an - // otherwise-allowed host — the secret is only ever substituted where the rule - // explicitly allows. - if (shouldRewrite && hostItems.length > 0) { - const guardReq: SubstitutionGuardRequest = { - headers: Object.entries(req.headers).map(([name, value]) => ({ - name: name.toLowerCase(), - value: Array.isArray(value) ? value.join('\n') : String(value ?? ''), - })), - requestTarget: t.requestTarget, - body: bodyText, - contentType: getHeaderValue(req.headers, 'content-type'), - }; - const violation = checkSubstitutionGuards(guardReq, hostItems); - if (violation) { - const decision = violation.kind === 'location' ? 'blocked-location' : 'blocked-occurrences'; - onActivity?.({ - ...baseActivity, ...ruleId, matched: true, blocked: true, decision, - }); - const message = violation.kind === 'location' - ? `Blocked by the varlock credential proxy: ${violation.item.key}'s placeholder appears in the ${violation.location} of this request, which its @proxy rule doesn't allow. ` - + `${violation.suggestion}. ` - + 'If that placement was not intentional, it may be an attempt to place the secret somewhere it could leak.' - : `Blocked by the varlock credential proxy: ${violation.item.key}'s placeholder appears ${violation.count} times in this request, but at most ${violation.item.maxOccurrences} is allowed. ` - + 'A valid request uses the secret once; extra copies can exfiltrate it. If this API legitimately repeats it, raise maxOccurrences on the @proxy rule.'; - respondBlocked(res, 403, message, t.tunnelTeardown); - return; + // Bridge the transport's approval provider into the pipeline's gate: build an + // ApprovalRequest committed to this exact request (body hash included) and + // honor only a decision bound to it (Invariant #8). The pipeline fails closed + // around this (missing gate or thrown error ⇒ denied). + const approvalGate: ApprovalGateFn | undefined = approvalProvider + ? async (input) => { + const request = createApprovalRequest({ ...input, body }); + const decision = await approvalProvider.requestApproval(request); + return isApprovalValid(request, decision); } - } + : undefined; - // Invariant #8: a require-approval rule holds the request for an out-of-band, - // request-bound decision. Fail closed (deny) unless explicitly approved. - if (policyDecision?.verdict === 'require-approval') { - const approved = await runApprovalGate({ - approvalProvider, - method: t.method, - host: t.host, - path: t.pathOnly, - body, - ruleId: ruleIdStr, - each: policyDecision.matchedRule?.approval?.each, - maxDurationMs: policyDecision.matchedRule?.approval?.maxDurationMs, - injectedKeys, - }); - if (!approved) { - onActivity?.({ - ...baseActivity, ...ruleId, matched: true, blocked: true, decision: 'approval-denied', - }); - respondBlocked(res, 403, `Blocked by the varlock credential proxy: this request to ${t.host} required approval and it was not granted.`, t.tunnelTeardown); - return; - } + const outcome = await evaluateProxiedRequestWithBody( + pre, + facts, + policy, + { headers: req.headers, bodyText }, + { approvalGate }, + ); + if (outcome.kind === 'blocked') { + onActivity?.(outcome.activity); + respondBlocked(res, outcome.status, outcome.message, outcome.teardownOnTunnel && t.tunnelTeardown); + return; } + onActivity?.(outcome.activity); + const { hostItems, shouldRewrite } = outcome; - onActivity?.({ - ...baseActivity, - ...ruleId, - matched: shouldRewrite, - blocked: false, - decision: policyDecision?.verdict === 'require-approval' ? 'approval-granted' : 'allow', - ...(injectedKeys.length ? { injectedKeys } : {}), - }); - - // Substitute placeholder → real value. The guards above already proved every - // occurrence sits at an allowed target for its item, and placeholders are unique - // per item, so a blind string-replace across all three parts only ever hits the - // approved spot — no need to re-scope per location (which would also risk - // re-serializing/altering the body). const rewrittenBody = shouldRewrite - ? Buffer.from(replacePlaceholdersWithReal(bodyText, hostItems), 'utf8') + ? Buffer.from(outcome.rewrittenBodyText, 'utf8') : body; - const rewrittenPath = shouldRewrite - ? replacePlaceholdersWithReal(t.requestTarget, hostItems) - : t.requestTarget; + const rewrittenPath = outcome.rewrittenTarget; - const upstreamHeaders = transformHeaders( - req.headers, - shouldRewrite - ? (value) => replacePlaceholdersWithReal(value, hostItems) - : (value) => value, - ); + const upstreamHeaders = transformHeaders(req.headers, outcome.transformHeaderValue); delete upstreamHeaders['proxy-connection']; delete upstreamHeaders.connection; // Hop-by-hop: addressed to this proxy, never the upstream. A client with @@ -1226,7 +632,7 @@ export async function startLocalProxyRuntime({ // a handed-in socket), so we pin by IP — the secret only ever reaches an // address already proven to hold a valid cert for the rule host, defeating // DNS-poison/rebind. Cleartext (http) upstreams never carry an injected secret - // — the cleartext guard above fails closed when hostItems.length > 0 && !isHttps. + // — the pipeline's cleartext guard fails closed when items are in scope. let verifiedAddress: string | undefined; if (t.isHttps) { try { diff --git a/packages/varlock/src/proxy/session-registry.ts b/packages/varlock/src/proxy/session-registry.ts index 6edb32e1b..d008de890 100644 --- a/packages/varlock/src/proxy/session-registry.ts +++ b/packages/varlock/src/proxy/session-registry.ts @@ -9,7 +9,7 @@ import { dirname, join } from 'node:path'; import { getUserVarlockDir } from '../lib/user-config-dir'; import { getAncestorPids } from './process-ancestry'; import type { ProxyResolutionView } from '../env-graph'; -import type { ProxyEgressMode } from './types'; +import type { ProxyEgressMode } from './core/types'; import { PROXY_CHILD_ENV_VAR, PROXY_SCHEMA_FINGERPRINT_ENV_VAR, diff --git a/packages/varlock/tsup.proxy-core.config.ts b/packages/varlock/tsup.proxy-core.config.ts new file mode 100644 index 000000000..b3b8017bf --- /dev/null +++ b/packages/varlock/tsup.proxy-core.config.ts @@ -0,0 +1,22 @@ +import { defineConfig } from 'tsup'; + +// Transport-agnostic proxy core, exposed as the `varlock/proxy-core` subpath +// for gateway adapters (e.g. a Cloudflare Worker) that can't use node builtins. +// Built as its own self-contained bundle with `platform: 'neutral'`, so the +// build FAILS if a node-only import ever sneaks into src/proxy/core. +// +// A separate config file (run after the main `tsup` in the build script) rather +// than another entry in tsup.config.ts's array: tsup builds array configs in +// parallel, and the main config's dts pass deletes this entry's d.ts output. +export default defineConfig({ + entry: { 'proxy-core': 'src/proxy/core/index.ts' }, + + clean: false, + sourcemap: true, + treeshake: true, + outDir: 'dist', + format: ['esm'], + splitting: false, + dts: true, + platform: 'neutral', +});