diff --git a/.changeset/beige-dogs-melt.md b/.changeset/beige-dogs-melt.md new file mode 100644 index 00000000..3638db29 --- /dev/null +++ b/.changeset/beige-dogs-melt.md @@ -0,0 +1,5 @@ +--- +"@vercel/sandbox": minor +--- + +Add L7 request matchers and `forwardURL` support for network policy rules. diff --git a/packages/vercel-sandbox/src/api-client/validators.ts b/packages/vercel-sandbox/src/api-client/validators.ts index ec4173b4..2bd1f631 100644 --- a/packages/vercel-sandbox/src/api-client/validators.ts +++ b/packages/vercel-sandbox/src/api-client/validators.ts @@ -2,12 +2,37 @@ import { z } from "zod"; export type SandboxMetaData = z.infer; +const InjectionRuleMatcherValidator = z.object({ + exact: z.string().optional(), + startsWith: z.string().optional(), + regex: z.string().optional(), +}); + +const InjectionRuleKeyValueMatcherValidator = z.object({ + key: InjectionRuleMatcherValidator.optional(), + value: InjectionRuleMatcherValidator.optional(), +}); + +const InjectionRuleMatchValidator = z.object({ + path: InjectionRuleMatcherValidator.optional(), + method: z.array(z.string()).optional(), + queryString: z.array(InjectionRuleKeyValueMatcherValidator).optional(), + headers: z.array(InjectionRuleKeyValueMatcherValidator).optional(), +}); + export const InjectionRuleValidator = z.object({ domain: z.string(), // headers are only sent in requests headers: z.record(z.string()).optional(), // headerNames are returned in responses headerNames: z.array(z.string()).optional(), + match: InjectionRuleMatchValidator.optional(), +}); + +export const ForwardRuleValidator = z.object({ + domain: z.string(), + forwardURL: z.string(), + match: InjectionRuleMatchValidator.optional(), }); export const NetworkPolicyValidator = z.union([ @@ -20,6 +45,7 @@ export const NetworkPolicyValidator = z.union([ allowedCIDRs: z.array(z.string()).optional(), deniedCIDRs: z.array(z.string()).optional(), injectionRules: z.array(InjectionRuleValidator).optional(), + forwardRules: z.array(ForwardRuleValidator).optional(), }) .passthrough(), ]); diff --git a/packages/vercel-sandbox/src/index.ts b/packages/vercel-sandbox/src/index.ts index 35daeb35..94faff56 100644 --- a/packages/vercel-sandbox/src/index.ts +++ b/packages/vercel-sandbox/src/index.ts @@ -1,6 +1,9 @@ export { Sandbox, type NetworkPolicy, + type NetworkPolicyKeyValueMatcher, + type NetworkPolicyMatch, + type NetworkPolicyMatcher, type NetworkPolicyRule, type NetworkTransformer, } from "./sandbox.js"; diff --git a/packages/vercel-sandbox/src/network-policy.ts b/packages/vercel-sandbox/src/network-policy.ts index 131e098a..53389fbb 100644 --- a/packages/vercel-sandbox/src/network-policy.ts +++ b/packages/vercel-sandbox/src/network-policy.ts @@ -11,12 +11,63 @@ export type NetworkTransformer = { headers?: Record; }; +/** + * Defines how a request value is matched. + */ +export type NetworkPolicyMatcher = { + /** Match the value exactly. */ + exact?: string; +} | { + /** Match values that start with the provided prefix. */ + startsWith?: string; +} | { + /** Match values against an RE2 regular expression. */ + regex?: string; +}; + +/** + * Matcher for key/value request entries such as headers and query parameters. + */ +export type NetworkPolicyKeyValueMatcher = { + /** Matcher for the entry key. */ + key?: NetworkPolicyMatcher; + /** Matcher for the entry value. */ + value?: NetworkPolicyMatcher; +}; + +/** + * Request matcher for a network policy rule. + * + * All specified dimensions must match. Multiple methods are ORed; multiple + * header and query-string matchers are ANDed. + */ +export type NetworkPolicyMatch = { + /** Match on the request path. */ + path?: NetworkPolicyMatcher; + /** Match on the HTTP method. */ + method?: string[]; + /** Match on query-string entries. */ + queryString?: NetworkPolicyKeyValueMatcher[]; + /** Match on request headers. */ + headers?: NetworkPolicyKeyValueMatcher[]; +}; + /** * A rule applied to requests matching a domain in the network policy. */ export type NetworkPolicyRule = { + /** + * Optional request matcher. When provided, transforms only apply to requests + * that match every specified dimension. + */ + match?: NetworkPolicyMatch; /** Transforms to apply to matching requests. */ transform?: NetworkTransformer[]; + /** + * Optional HTTPS proxy URL to forward matching requests to. + * Must not include query string or fragment. + */ + forwardURL?: string; }; /** @@ -60,6 +111,13 @@ export type NetworkPolicyRule = { * allow: { * "ai-gateway.vercel.sh": [ * { + * match: { + * method: ["POST"], + * path: { startsWith: "/v1/" }, + * headers: [ + * { key: { exact: "x-api-key" }, value: { exact: "placeholder" } } + * ] + * }, * transform: [{ * headers: { authorization: "Bearer ..." } * }] diff --git a/packages/vercel-sandbox/src/sandbox.ts b/packages/vercel-sandbox/src/sandbox.ts index 0a48f952..41076db7 100644 --- a/packages/vercel-sandbox/src/sandbox.ts +++ b/packages/vercel-sandbox/src/sandbox.ts @@ -11,6 +11,9 @@ import { Command, CommandFinished } from "./command.js"; import type { RUNTIMES } from "./constants.js"; import type { NetworkPolicy, + NetworkPolicyKeyValueMatcher, + NetworkPolicyMatch, + NetworkPolicyMatcher, NetworkPolicyRule, NetworkTransformer, } from "./network-policy.js"; @@ -24,7 +27,14 @@ import { import { getPrivateParams, type WithPrivate } from "./utils/types.js"; import { FileSystem } from "./filesystem.js"; -export type { NetworkPolicy, NetworkPolicyRule, NetworkTransformer }; +export type { + NetworkPolicy, + NetworkPolicyKeyValueMatcher, + NetworkPolicyMatch, + NetworkPolicyMatcher, + NetworkPolicyRule, + NetworkTransformer, +}; /** @inline */ export interface BaseCreateSandboxParams { diff --git a/packages/vercel-sandbox/src/utils/network-policy.test.ts b/packages/vercel-sandbox/src/utils/network-policy.test.ts index 4d469e81..cc0dcdaf 100644 --- a/packages/vercel-sandbox/src/utils/network-policy.test.ts +++ b/packages/vercel-sandbox/src/utils/network-policy.test.ts @@ -111,6 +111,95 @@ describe("toAPINetworkPolicy", () => { }); }); + it("preserves matcher-bearing rules as ordered injectionRules", () => { + expect( + toAPINetworkPolicy({ + allow: { + "api.example.com": [ + { + match: { + method: ["POST"], + path: { startsWith: "/v1/" }, + headers: [ + { + key: { exact: "x-api-key" }, + value: { exact: "placeholder" }, + }, + ], + }, + transform: [{ headers: { "x-api-key": "real-secret" } }], + }, + { + transform: [{ headers: { "x-api-key": "fallback-secret" } }], + }, + ], + }, + }), + ).toEqual({ + mode: "custom", + allowedDomains: ["api.example.com"], + injectionRules: [ + { + domain: "api.example.com", + headers: { "x-api-key": "real-secret" }, + match: { + method: ["POST"], + path: { startsWith: "/v1/" }, + headers: [ + { + key: { exact: "x-api-key" }, + value: { exact: "placeholder" }, + }, + ], + }, + }, + { + domain: "api.example.com", + headers: { "x-api-key": "fallback-secret" }, + }, + ], + }); + }); + + it("converts record-form forwardURL rules to forwardRules", () => { + expect( + toAPINetworkPolicy({ + allow: { + "api.example.com": [ + { + match: { + method: ["POST"], + path: { startsWith: "/v1/" }, + }, + forwardURL: "https://proxy.example.com", + }, + { + forwardURL: "https://fallback-proxy.example.com", + }, + ], + "registry.npmjs.org": [], + }, + }), + ).toEqual({ + mode: "custom", + allowedDomains: ["api.example.com", "registry.npmjs.org"], + forwardRules: [ + { + domain: "api.example.com", + match: { + method: ["POST"], + path: { startsWith: "/v1/" }, + }, + forwardURL: "https://proxy.example.com", + }, + { + domain: "api.example.com", + forwardURL: "https://fallback-proxy.example.com", + }, + ], + }); + }); + it("converts empty custom object", () => { expect(toAPINetworkPolicy({})).toEqual({ mode: "custom" }); }); @@ -181,6 +270,11 @@ describe("fromAPINetworkPolicy", () => { allow: ["*.npmjs.org"], subnets: { allow: ["10.0.0.0/8"], deny: ["10.1.0.0/16"] }, }, + { + allow: { + "api.example.com": [{ forwardURL: "https://proxy.example.com" }], + }, + }, ]; for (const policy of policies) { @@ -237,4 +331,126 @@ describe("fromAPINetworkPolicy", () => { subnets: { allow: ["10.0.0.0/8"], deny: ["10.1.0.0/16"] }, }); }); + + it("converts ordered injectionRules with matchers", () => { + expect( + fromAPINetworkPolicy({ + mode: "custom", + allowedDomains: ["api.example.com"], + injectionRules: [ + { + domain: "api.example.com", + headerNames: ["x-api-key"], + match: { + method: ["POST"], + path: { startsWith: "/v1/" }, + queryString: [{ key: { exact: "model" } }], + }, + }, + { + domain: "api.example.com", + headerNames: ["x-api-key"], + }, + ], + }), + ).toEqual({ + allow: { + "api.example.com": [ + { + match: { + method: ["POST"], + path: { startsWith: "/v1/" }, + queryString: [{ key: { exact: "model" } }], + }, + transform: [{ headers: { "x-api-key": "" } }], + }, + { + transform: [{ headers: { "x-api-key": "" } }], + }, + ], + }, + }); + }); + + it("converts forwardRules with matchers", () => { + expect( + fromAPINetworkPolicy({ + mode: "custom", + allowedDomains: ["api.example.com", "registry.npmjs.org"], + forwardRules: [ + { + domain: "api.example.com", + match: { + method: ["POST"], + path: { startsWith: "/v1/" }, + headers: [{ key: { exact: "x-route" }, value: { exact: "proxy" } }], + }, + forwardURL: "https://proxy.example.com", + }, + { + domain: "api.example.com", + forwardURL: "https://fallback-proxy.example.com", + }, + ], + }), + ).toEqual({ + allow: { + "api.example.com": [ + { + match: { + method: ["POST"], + path: { startsWith: "/v1/" }, + headers: [{ key: { exact: "x-route" }, value: { exact: "proxy" } }], + }, + forwardURL: "https://proxy.example.com", + }, + { + forwardURL: "https://fallback-proxy.example.com", + }, + ], + "registry.npmjs.org": [], + }, + }); + }); + + it("converts mixed injectionRules and forwardRules", () => { + expect( + fromAPINetworkPolicy({ + mode: "custom", + allowedDomains: ["api.example.com"], + injectionRules: [ + { + domain: "api.example.com", + headerNames: ["authorization"], + }, + ], + forwardRules: [ + { + domain: "api.example.com", + forwardURL: "https://proxy.example.com", + }, + { + domain: "proxy-only.example.com", + forwardURL: "https://proxy-only.example.com", + }, + ], + }), + ).toEqual({ + allow: { + "api.example.com": [ + { + transform: [{ headers: { authorization: "" } }], + }, + { + forwardURL: "https://proxy.example.com", + }, + ], + "proxy-only.example.com": [ + { + forwardURL: "https://proxy-only.example.com", + }, + ], + }, + }); + }); }); diff --git a/packages/vercel-sandbox/src/utils/network-policy.ts b/packages/vercel-sandbox/src/utils/network-policy.ts index febb6b19..95c2834d 100644 --- a/packages/vercel-sandbox/src/utils/network-policy.ts +++ b/packages/vercel-sandbox/src/utils/network-policy.ts @@ -3,6 +3,7 @@ import { NetworkPolicy, NetworkPolicyRule } from "../network-policy.js"; import { NetworkPolicyValidator, InjectionRuleValidator, + ForwardRuleValidator, } from "../api-client/validators.js"; type APINetworkPolicy = z.infer; @@ -14,16 +15,37 @@ export function toAPINetworkPolicy(policy: NetworkPolicy): APINetworkPolicy { if (policy.allow && !Array.isArray(policy.allow)) { const allowedDomains = Object.keys(policy.allow); const injectionRules: z.infer[] = []; + const forwardRules: z.infer[] = []; for (const [domain, rules] of Object.entries(policy.allow)) { - const merged: Record = {}; - for (const rule of rules) { - for (const t of rule.transform ?? []) { - Object.assign(merged, t.headers); + if (rules.some((rule) => rule.match !== undefined)) { + for (const rule of rules) { + const headers = mergeTransformHeaders(rule); + if (Object.keys(headers).length > 0) { + injectionRules.push({ + domain, + headers, + ...(rule.match ? { match: rule.match } : {}), + }); + } + } + } else { + const headers = rules.reduce( + (merged, rule) => Object.assign(merged, mergeTransformHeaders(rule)), + {} as Record, + ); + if (Object.keys(headers).length > 0) { + injectionRules.push({ domain, headers }); } } - if (Object.keys(merged).length > 0) { - injectionRules.push({ domain, headers: merged }); + + for (const rule of rules) { + if (!rule.forwardURL) continue; + forwardRules.push({ + domain, + forwardURL: rule.forwardURL, + ...(rule.match ? { match: rule.match } : {}), + }); } } @@ -31,6 +53,7 @@ export function toAPINetworkPolicy(policy: NetworkPolicy): APINetworkPolicy { mode: "custom", ...(allowedDomains.length > 0 && { allowedDomains }), ...(injectionRules.length > 0 && { injectionRules }), + ...(forwardRules.length > 0 && { forwardRules }), ...(policy.subnets?.allow && { allowedCIDRs: policy.subnets.allow }), ...(policy.subnets?.deny && { deniedCIDRs: policy.subnets.deny }), }; @@ -44,6 +67,14 @@ export function toAPINetworkPolicy(policy: NetworkPolicy): APINetworkPolicy { }; } +function mergeTransformHeaders(rule: NetworkPolicyRule): Record { + const headers: Record = {}; + for (const transform of rule.transform ?? []) { + Object.assign(headers, transform.headers); + } + return headers; +} + export function fromAPINetworkPolicy(api: APINetworkPolicy): NetworkPolicy { if (api.mode === "allow-all") return "allow-all"; if (api.mode === "deny-all") return "deny-all"; @@ -58,33 +89,42 @@ export function fromAPINetworkPolicy(api: APINetworkPolicy): NetworkPolicy { } : undefined; - // If injectionRules are present, reconstruct the record form. + // If L7 rules are present, reconstruct the record form. // The API returns headerNames (secret values are stripped), so we // populate each header value with "". - if (api.injectionRules && api.injectionRules.length > 0) { - const rulesByDomain = new Map( - api.injectionRules.map((r) => [r.domain, r.headerNames ?? []]), - ); + if ( + (api.injectionRules && api.injectionRules.length > 0) || + (api.forwardRules && api.forwardRules.length > 0) + ) { + const rulesByDomain = new Map(); + for (const rule of api.injectionRules ?? []) { + const headers = Object.fromEntries( + (rule.headerNames ?? []).map((n) => [n, ""]), + ); + const rules = rulesByDomain.get(rule.domain) ?? []; + rules.push({ + ...(rule.match ? { match: rule.match } : {}), + transform: [{ headers }], + }); + rulesByDomain.set(rule.domain, rules); + } + for (const rule of api.forwardRules ?? []) { + const rules = rulesByDomain.get(rule.domain) ?? []; + rules.push({ + ...(rule.match ? { match: rule.match } : {}), + forwardURL: rule.forwardURL, + }); + rulesByDomain.set(rule.domain, rules); + } const allow: Record = {}; for (const domain of api.allowedDomains ?? []) { - const headerNames = rulesByDomain.get(domain); - if (headerNames && headerNames.length > 0) { - const headers = Object.fromEntries( - headerNames.map((n) => [n, ""]), - ); - allow[domain] = [{ transform: [{ headers }] }]; - } else { - allow[domain] = []; - } + allow[domain] = rulesByDomain.get(domain) ?? []; } - // Include injection rules for domains not in allowedDomains - for (const rule of api.injectionRules) { + // Include L7 rules for domains not in allowedDomains + for (const rule of [...(api.injectionRules ?? []), ...(api.forwardRules ?? [])]) { if (!(rule.domain in allow)) { - const headers = Object.fromEntries( - (rule.headerNames ?? []).map((n) => [n, ""]), - ); - allow[rule.domain] = [{ transform: [{ headers }] }]; + allow[rule.domain] = rulesByDomain.get(rule.domain) ?? []; } }