Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .bumpy/proxy-core-extraction.md
Original file line number Diff line number Diff line change
@@ -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
7 changes: 6 additions & 1 deletion packages/varlock/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion packages/varlock/src/cli/commands/proxy.command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
2 changes: 1 addition & 1 deletion packages/varlock/src/env-graph/lib/decorators.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion packages/varlock/src/env-graph/lib/env-graph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion packages/varlock/src/proxy/approval.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
38 changes: 4 additions & 34 deletions packages/varlock/src/proxy/audit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>;
};
// 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 = {
Expand Down
34 changes: 34 additions & 0 deletions packages/varlock/src/proxy/core/activity.ts
Original file line number Diff line number Diff line change
@@ -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<string>;
};
62 changes: 62 additions & 0 deletions packages/varlock/src/proxy/core/auth.ts
Original file line number Diff line number Diff line change
@@ -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<string> | 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<string> | 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);
}
94 changes: 94 additions & 0 deletions packages/varlock/src/proxy/core/headers.ts
Original file line number Diff line number Diff line change
@@ -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<string, string | Array<string> | undefined>;

export type HeaderTransformFn = (value: string) => string;

export function transformHeaders(
headers: HeadersRecord,
transformValue: HeaderTransformFn,
): Record<string, string | Array<string>> {
const out: Record<string, string | Array<string>> = {};
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<ProxyManagedItem>,
): Record<string, string | Array<string>> {
return transformHeaders(
headers,
(value) => replaceRealWithPlaceholders(value, managedItems),
);
}
8 changes: 8 additions & 0 deletions packages/varlock/src/proxy/core/index.ts
Original file line number Diff line number Diff line change
@@ -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';
Loading
Loading