Skip to content
Draft
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 .changeset/pinned-host-sandbox.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@vercel/sandbox": minor
---

Support `httpOnly: true` in domain allow-list rules to pin outgoing HTTP Host headers to the allowed domain.
40 changes: 30 additions & 10 deletions packages/vercel-sandbox/src/network-policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,16 +14,19 @@ export type NetworkTransformer = {
/**
* 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;
};
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.
Expand Down Expand Up @@ -79,6 +82,17 @@ export type NetworkPolicyRule = {
*/
match?: NetworkPolicyMatch;
} & (
| {
/**
* Pin the outgoing HTTP Host header on matching requests to the allowed
* domain to prevent domain fronting. Only supported for exact domains.
* Cannot be combined with another rule action.
*/
httpOnly: true;
transform?: never;
forwardURL?: never;
response?: never;
}
| {
/**
* Transforms to apply to matching requests.
Expand Down Expand Up @@ -156,6 +170,12 @@ export type NetworkPolicyRule = {
* }
*
* @example
* // Pin the HTTP Host header to the allowed domain
* {
* allow: { "api.example.com": [{ httpOnly: true }] }
* }
*
* @example
* // Custom access with request transformers
* {
* allow: {
Expand Down
98 changes: 94 additions & 4 deletions packages/vercel-sandbox/src/utils/network-policy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,84 @@ describe("toAPINetworkPolicy", () => {
});
});

it("compiles httpOnly into a pinned Host header without changing the input", () => {
const policy: NetworkPolicy = {
allow: {
"api.example.com": [{ httpOnly: true }],
"other.example.com": [],
},
};

expect(toAPINetworkPolicy(policy)).toEqual({
allow: {
"api.example.com": [
{ transform: [{ headers: { Host: "api.example.com" } }] },
],
"other.example.com": [],
},
});
expect(policy.allow).toEqual({
"api.example.com": [{ httpOnly: true }],
"other.example.com": [],
});
});

it("keeps matches and the order of rules with httpOnly", () => {
expect(
toAPINetworkPolicy({
allow: {
"api.example.com": [
{ match: { method: ["GET"] }, httpOnly: true },
{ response: { statusCode: 403 } },
],
},
}),
).toEqual({
allow: {
"api.example.com": [
{
match: { method: ["GET"] },
transform: [{ headers: { Host: "api.example.com" } }],
},
{ response: { statusCode: 403 } },
],
},
});
});

it("rejects httpOnly for wildcard domains", () => {
for (const domain of ["*", "*.example.com"]) {
expect(() =>
toAPINetworkPolicy({
allow: { [domain]: [{ httpOnly: true }] },
}),
).toThrow("httpOnly requires an exact domain");
}
});

it("rejects httpOnly values other than true", () => {
const policy = {
allow: { "api.example.com": [{ httpOnly: false }] },
} as unknown as NetworkPolicy;
expect(() => toAPINetworkPolicy(policy)).toThrow("httpOnly must be true");
});

it("rejects httpOnly combined with another action", () => {
const policy = {
allow: {
"api.example.com": [
{
httpOnly: true,
transform: [{ headers: { authorization: "secret" } }],
},
],
},
} as unknown as NetworkPolicy;
expect(() => toAPINetworkPolicy(policy)).toThrow(
"httpOnly cannot be combined with another rule action",
);
});

it("keeps record-form rules in v2 allow-map shape", () => {
expect(
toAPINetworkPolicy({
Expand Down Expand Up @@ -295,7 +373,9 @@ describe("toAPINetworkPolicy", () => {

it("rejects a response body without a contentType", () => {
const networkPolicy = {
allow: { "api.example.com": [{ response: { statusCode: 403, body: "nope" } }] },
allow: {
"api.example.com": [{ response: { statusCode: 403, body: "nope" } }],
},
} as unknown as NetworkPolicy;

expect(() => toAPINetworkPolicy(networkPolicy)).toThrow(
Expand All @@ -307,7 +387,13 @@ describe("toAPINetworkPolicy", () => {
const networkPolicy = {
allow: {
"api.example.com": [
{ response: { statusCode: 204, body: "nope", contentType: "text/plain" } },
{
response: {
statusCode: 204,
body: "nope",
contentType: "text/plain",
},
},
],
},
} as unknown as NetworkPolicy;
Expand Down Expand Up @@ -511,7 +597,9 @@ describe("fromAPINetworkPolicy", () => {
match: {
method: ["POST"],
path: { startsWith: "/v1/" },
headers: [{ key: { exact: "x-route" }, value: { exact: "proxy" } }],
headers: [
{ key: { exact: "x-route" }, value: { exact: "proxy" } },
],
},
forwardURL: "https://proxy.example.com",
},
Expand All @@ -528,7 +616,9 @@ describe("fromAPINetworkPolicy", () => {
match: {
method: ["POST"],
path: { startsWith: "/v1/" },
headers: [{ key: { exact: "x-route" }, value: { exact: "proxy" } }],
headers: [
{ key: { exact: "x-route" }, value: { exact: "proxy" } },
],
},
forwardURL: "https://proxy.example.com",
},
Expand Down
40 changes: 38 additions & 2 deletions packages/vercel-sandbox/src/utils/network-policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,40 @@ export function toAPINetworkPolicy(
const apiPolicy =
policy === "allow-all" || policy === "deny-all"
? { mode: policy }
: policy;
: {
...policy,
...(policy.allow && !Array.isArray(policy.allow)
? {
allow: Object.fromEntries(
Object.entries(policy.allow).map(([domain, rules]) => [
domain,
rules.map((rule) => {
if (!("httpOnly" in rule)) return rule;
if (rule.httpOnly !== true) {
throw new Error("httpOnly must be true");
}
if (domain.includes("*")) {
throw new Error("httpOnly requires an exact domain");
}
if (
"transform" in rule ||
"forwardURL" in rule ||
"response" in rule
) {
throw new Error(
"httpOnly cannot be combined with another rule action",
);
}
return {
...(rule.match ? { match: rule.match } : {}),
transform: [{ headers: { Host: domain } }],
};
}),
]),
),
}
: {}),
};

NetworkPolicyRequestValidator.parse(apiPolicy);
return apiPolicy;
Expand Down Expand Up @@ -70,7 +103,10 @@ export function fromAPINetworkPolicy(
allow[domain] = rulesByDomain.get(domain) ?? [];
}
// Include L7 rules for domains not in allowedDomains
for (const rule of [...(api.injectionRules ?? []), ...(api.forwardRules ?? [])]) {
for (const rule of [
...(api.injectionRules ?? []),
...(api.forwardRules ?? []),
]) {
if (!(rule.domain in allow)) {
allow[rule.domain] = rulesByDomain.get(rule.domain) ?? [];
}
Expand Down
Loading