Skip to content
Merged
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
21 changes: 13 additions & 8 deletions packages/junior/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,10 @@ import { GET as dashboardGET } from "@/handlers/diagnostics-dashboard";
import { GET as healthGET } from "@/handlers/health";
import { GET as mcpOauthCallbackGET } from "@/handlers/mcp-oauth-callback";
import { GET as oauthCallbackGET } from "@/handlers/oauth-callback";
import { ALL as sandboxEgressProxyALL } from "@/handlers/sandbox-egress-proxy";
import {
ALL as sandboxEgressProxyALL,
isSandboxEgressRequest,
} from "@/handlers/sandbox-egress-proxy";
import { POST as turnResumePOST } from "@/handlers/turn-resume";
import { POST as webhooksPOST } from "@/handlers/webhooks";
import type { WaitUntilFn } from "@/handlers/types";
Expand Down Expand Up @@ -70,6 +73,15 @@ export async function createApp(options?: JuniorAppOptions): Promise<Hono> {
return c.text("Internal Server Error", 500);
});

app.use("*", async (c, next) => {
// Vercel Sandbox proxying preserves the original upstream path, so detect
// authenticated proxy traffic before ordinary application routes claim it.
if (isSandboxEgressRequest(c.req.raw)) {
return await sandboxEgressProxyALL(c.req.raw);
}
await next();
});

app.get("/", () => dashboardGET());
app.get("/health", () => healthGET());

Expand All @@ -91,13 +103,6 @@ export async function createApp(options?: JuniorAppOptions): Promise<Hono> {
return turnResumePOST(c.req.raw, waitUntil);
});

app.all("/api/internal/sandbox-egress/:egressId", (c) => {
return sandboxEgressProxyALL(c.req.raw, c.req.param("egressId"));
});
app.all("/api/internal/sandbox-egress/:egressId/*", (c) => {
return sandboxEgressProxyALL(c.req.raw, c.req.param("egressId"));
});

app.post("/api/webhooks/:platform", (c) => {
return webhooksPOST(c.req.raw, c.req.param("platform"), waitUntil);
});
Expand Down
16 changes: 0 additions & 16 deletions packages/junior/src/chat/sandbox/egress-oidc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,24 +77,9 @@ async function getJwks(
return jwks;
}

function validateSandboxClaim(payload: JWTPayload, egressId: string): void {
if (payload.sandbox_id !== egressId) {
throw new Error("Vercel OIDC token belongs to a different sandbox");
}
}

/** Validate that a verified Vercel Sandbox proxy token is bound to this route. */
export function validateVercelSandboxOidcClaims(
payload: JWTPayload,
egressId: string,
): void {
validateSandboxClaim(payload, egressId);
}

/** Verify Vercel signed this Sandbox firewall proxy request for the active VM session. */
export async function verifyVercelSandboxOidcToken(
token: string,
egressId: string,
): Promise<JWTPayload> {
const unverified = decodeJwt(token);
if (typeof unverified.iss !== "string") {
Expand All @@ -107,6 +92,5 @@ export async function verifyVercelSandboxOidcToken(
// The Sandbox proxy token is request identity. Do not compare its audience,
// team, or project claims with deployment OIDC; the egress session decides
// whether this VM session may activate requester-bound credentials.
validateSandboxClaim(verified.payload, egressId);
return verified.payload;
}
16 changes: 4 additions & 12 deletions packages/junior/src/chat/sandbox/egress-policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,6 @@ import { getPluginProviders } from "@/chat/plugins/registry";
import type { PluginManifest } from "@/chat/plugins/types";
import { resolveBaseUrl } from "@/chat/oauth-flow";

const SANDBOX_EGRESS_PROXY_PATH = "/api/internal/sandbox-egress";

/** Return whether an outbound host is covered by a sandbox egress domain rule. */
export function matchesSandboxEgressDomain(
host: string,
Expand Down Expand Up @@ -42,27 +40,21 @@ export function resolveSandboxEgressProviderForHost(
)?.provider;
}

function proxyUrl(egressId: string): string | undefined {
function proxyUrl(): string | undefined {
const baseUrl = resolveBaseUrl();
if (!baseUrl) {
return undefined;
}
const url = new URL(
`${SANDBOX_EGRESS_PROXY_PATH}/${encodeURIComponent(egressId)}`,
baseUrl,
);
return url.toString();
return new URL("/", baseUrl).toString();
}

/** Build the forwarding policy that keeps provider credentials outside the sandbox. */
export function buildSandboxEgressNetworkPolicy(
egressId: string,
): NetworkPolicy | undefined {
export function buildSandboxEgressNetworkPolicy(): NetworkPolicy | undefined {
const entries = providerEntries();
if (entries.length === 0) {
return undefined;
}
const forwardURL = proxyUrl(egressId);
const forwardURL = proxyUrl();
if (!forwardURL) {
// Credential placeholders must not reach real provider domains. If Junior
// cannot receive forwarded requests, fail setup before running commands.
Expand Down
61 changes: 35 additions & 26 deletions packages/junior/src/chat/sandbox/egress-proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,12 @@ import {
type SandboxEgressCredentialLease,
type SandboxEgressSession,
} from "@/chat/sandbox/egress-session";
import type { JWTPayload } from "jose";

const OIDC_TOKEN_HEADER = "vercel-sandbox-oidc-token";
const FORWARDED_HOST_HEADER = "vercel-forwarded-host";
const FORWARDED_SCHEME_HEADER = "vercel-forwarded-scheme";
const FORWARDED_PORT_HEADER = "vercel-forwarded-port";
const ROUTE_PREFIX = "/api/internal/sandbox-egress";
const HOP_BY_HOP_HEADERS = new Set([
"connection",
"host",
Expand All @@ -44,7 +44,7 @@ const DECODED_RESPONSE_HEADERS = new Set([
const AUTH_REJECTION_STATUS = new Set([401, 403]);
interface ProxyDeps {
fetch?: typeof fetch;
verifyOidc?: (token: string, egressId: string) => Promise<unknown>;
verifyOidc?: (token: string) => Promise<JWTPayload>;
}

type UpstreamUrlResult = { ok: true; url: URL } | { ok: false; error: string };
Expand Down Expand Up @@ -82,22 +82,18 @@ function normalizePort(value: string | null): string | undefined {
return port >= 1 && port <= 65_535 ? trimmed : undefined;
}

function upstreamPath(request: Request, egressId: string): string | undefined {
function sandboxIdFromPayload(payload: JWTPayload): string | undefined {
return typeof payload.sandbox_id === "string"
? payload.sandbox_id
: undefined;
}

function upstreamPath(request: Request): string {
const url = new URL(request.url);
const prefix = `${ROUTE_PREFIX}/${encodeURIComponent(egressId)}`;
if (url.pathname === prefix) {
return `/${url.search}`;
}
if (url.pathname.startsWith(`${prefix}/`)) {
return `${url.pathname.slice(prefix.length)}${url.search}`;
}
return undefined;
return `${url.pathname}${url.search}`;
}

function buildUpstreamUrl(
request: Request,
egressId: string,
): UpstreamUrlResult {
function buildUpstreamUrl(request: Request): UpstreamUrlResult {
const forwardedHost = request.headers.get(FORWARDED_HOST_HEADER);
if (!forwardedHost?.trim()) {
return { ok: false, error: "Missing forwarded host" };
Expand All @@ -119,10 +115,7 @@ function buildUpstreamUrl(
if (forwardedPort && !port) {
return { ok: false, error: "Invalid forwarded port" };
}
const path = upstreamPath(request, egressId);
if (!path) {
return { ok: false, error: "Invalid egress route" };
}
const path = upstreamPath(request);
try {
const url = new URL(`${scheme}://${host}${port ? `:${port}` : ""}${path}`);
return { ok: true, url };
Expand Down Expand Up @@ -230,21 +223,29 @@ function hasTransformForHost(
);
}

/** Return whether a request appears to be from the Vercel Sandbox egress proxy. */
export function isSandboxEgressForwardedRequest(request: Request): boolean {
return Boolean(
request.headers.get(OIDC_TOKEN_HEADER)?.trim() &&
request.headers.get(FORWARDED_HOST_HEADER)?.trim() &&
request.headers.get(FORWARDED_SCHEME_HEADER)?.trim(),
);
}

/** Proxy one Vercel Sandbox firewall egress request through Junior credential activation. */
export async function proxySandboxEgressRequest(
request: Request,
egressId: string,
deps: ProxyDeps = {},
): Promise<Response> {
const oidcToken = request.headers.get(OIDC_TOKEN_HEADER)?.trim();
if (!oidcToken) {
return jsonError("Missing Vercel Sandbox OIDC token", 401);
}

let oidcPayload: JWTPayload;
try {
await (deps.verifyOidc ?? verifyVercelSandboxOidcToken)(
oidcPayload = await (deps.verifyOidc ?? verifyVercelSandboxOidcToken)(
oidcToken,
egressId,
);
} catch (error) {
logWarn(
Expand All @@ -259,7 +260,15 @@ export async function proxySandboxEgressRequest(
return jsonError("Invalid Vercel Sandbox OIDC token", 401);
}

const upstreamResult = buildUpstreamUrl(request, egressId);
const activeEgressId = sandboxIdFromPayload(oidcPayload);
if (!activeEgressId) {
return jsonError(
"Vercel Sandbox OIDC token did not include sandbox_id",
401,
);
}

const upstreamResult = buildUpstreamUrl(request);
if (!upstreamResult.ok) {
return jsonError(upstreamResult.error, 400);
}
Expand All @@ -272,14 +281,14 @@ export async function proxySandboxEgressRequest(

// Vercel OIDC authenticates the forwarded VM session; Junior's egress
// session authorizes credential activation for the current requester.
const session = await getSandboxEgressSession(egressId);
const session = await getSandboxEgressSession(activeEgressId);
if (!session) {
return jsonError("Sandbox egress session is not authorized", 403);
}

let lease: SandboxEgressCredentialLease;
try {
lease = await credentialLease(egressId, provider, session);
lease = await credentialLease(activeEgressId, provider, session);
} catch (error) {
if (error instanceof CredentialUnavailableError) {
return new Response(
Expand Down Expand Up @@ -316,7 +325,7 @@ export async function proxySandboxEgressRequest(
},
"Sandbox egress upstream auth rejected",
);
await clearSandboxEgressCredentialLease(egressId, provider, session);
await clearSandboxEgressCredentialLease(activeEgressId, provider, session);
}

return new Response(upstream.body, {
Expand Down
17 changes: 11 additions & 6 deletions packages/junior/src/handlers/sandbox-egress-proxy.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
import { proxySandboxEgressRequest } from "@/chat/sandbox/egress-proxy";
import {
isSandboxEgressForwardedRequest,
proxySandboxEgressRequest,
} from "@/chat/sandbox/egress-proxy";

/** Handles Vercel Sandbox firewall egress proxy requests. */
export async function ALL(
request: Request,
egressId: string,
): Promise<Response> {
return await proxySandboxEgressRequest(request, egressId);
export async function ALL(request: Request): Promise<Response> {
return await proxySandboxEgressRequest(request);
}

/** Return whether a request should be routed through sandbox egress proxying. */
export function isSandboxEgressRequest(request: Request): boolean {
return isSandboxEgressForwardedRequest(request);
}
Loading
Loading