Deployment protection bypass (#298)

* test preview bypass 2

Co-authored-by: Cursor <cursoragent@cursor.com>

* add apiFetch wrapper with Vercel bypass via query param + header

the template workflow was missing VERCEL_AUTOMATION_BYPASS_SECRET,
so all action API calls to preview deployments hit Vercel's
deployment protection without bypass. this also consolidates the
bypass logic into a single fetch wrapper that applies the secret
as both a query parameter (matching server-side forwarding) and
a header for belt-and-suspenders reliability.

Co-authored-by: Cursor <cursoragent@cursor.com>

* security hardening for Vercel bypass

- redact bypass token from webhook forwarder logs and response body
- remove dead x-preview-api-forward header
- refactor getAllSecrets() to use SENSITIVE_PATTERNS instead of hardcoded list
- enforce https:// on API_URL (localhost exempt for local dev)

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Colin McDonnell
2026-02-13 20:01:48 +00:00
committed by pullfrog[bot]
co-authored by Cursor
parent d5508d99bb
commit 6f108237d4
10 changed files with 187 additions and 139 deletions
+2
View File
@@ -32,6 +32,8 @@ jobs:
with: with:
prompt: ${{ inputs.prompt }} prompt: ${{ inputs.prompt }}
env: env:
API_URL: ${{ secrets.API_URL }}
VERCEL_AUTOMATION_BYPASS_SECRET: ${{ secrets.VERCEL_AUTOMATION_BYPASS_SECRET }}
# add any additional keys your agent(s) need # add any additional keys your agent(s) need
# optionally, comment out any you won't use # optionally, comment out any you won't use
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
+1 -1
View File
@@ -1,4 +1,4 @@
<!-- test preview system --> <!-- trivial touch --> <!-- test preview system --> <!-- test bypass 2 -->
<p align="center"> <p align="center">
<h1 align="center"> <h1 align="center">
<picture> <picture>
+49 -38
View File
@@ -139417,15 +139417,43 @@ var Octokit2 = Octokit.plugin(requestLog, legacyRestEndpointMethods, paginateRes
); );
// utils/apiUrl.ts // utils/apiUrl.ts
function getApiUrl() { function isLocalUrl(url4) {
const url4 = process.env.API_URL || "https://pullfrog.com"; return url4.hostname === "localhost" || url4.hostname === "127.0.0.1";
log.debug(`resolved API_URL: ${url4}`);
return url4;
} }
function getVercelBypassHeaders() { function getApiUrl() {
const secret = process.env.VERCEL_AUTOMATION_BYPASS_SECRET; const raw = process.env.API_URL || "https://pullfrog.com";
if (!secret) return {}; const parsed2 = new URL(raw);
return { "x-vercel-protection-bypass": secret }; if (parsed2.protocol !== "https:" && !isLocalUrl(parsed2)) {
throw new Error(
`API_URL must use https:// (got ${parsed2.protocol}). only localhost is exempt.`
);
}
log.debug(`resolved API_URL: ${raw}`);
return raw;
}
// utils/apiFetch.ts
async function apiFetch(options) {
const apiUrl = getApiUrl();
const url4 = new URL(options.path, apiUrl);
const bypassSecret = process.env.VERCEL_AUTOMATION_BYPASS_SECRET;
if (bypassSecret) {
url4.searchParams.set("x-vercel-protection-bypass", bypassSecret);
}
const headers = {
...options.headers
};
if (bypassSecret) {
headers["x-vercel-protection-bypass"] = bypassSecret;
}
log.debug(`api fetch: ${options.method ?? "GET"} ${url4.pathname}`);
const init = {
method: options.method ?? "GET",
headers
};
if (options.body) init.body = options.body;
if (options.signal) init.signal = options.signal;
return fetch(url4.toString(), init);
} }
// utils/retry.ts // utils/retry.ts
@@ -139465,37 +139493,26 @@ function isOIDCAvailable() {
} }
async function acquireTokenViaOIDC(opts) { async function acquireTokenViaOIDC(opts) {
const oidcToken = await core2.getIDToken("pullfrog-api"); const oidcToken = await core2.getIDToken("pullfrog-api");
const apiUrl = getApiUrl();
const params = new URLSearchParams();
const repos = [...opts?.repos ?? []]; const repos = [...opts?.repos ?? []];
const targetRepo = process.env.GITHUB_REPOSITORY?.split("/")[1]; const targetRepo = process.env.GITHUB_REPOSITORY?.split("/")[1];
if (targetRepo) { if (targetRepo) {
repos.push(targetRepo); repos.push(targetRepo);
} }
if (repos.length) { const reposParam = repos.length ? `?repos=${repos.join(",")}` : "";
params.set("repos", repos.join(","));
}
const queryString = params.toString() ? `?${params.toString()}` : "";
const timeoutMs = 3e4; const timeoutMs = 3e4;
const controller = new AbortController(); const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeoutMs); const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
try { try {
const fetchOptions = { const tokenResponse = await apiFetch({
path: `/api/github/installation-token${reposParam}`,
method: "POST", method: "POST",
headers: { headers: {
Authorization: `Bearer ${oidcToken}`, Authorization: `Bearer ${oidcToken}`,
"Content-Type": "application/json", "Content-Type": "application/json"
...getVercelBypassHeaders()
}, },
body: opts?.permissions ? JSON.stringify({ permissions: opts.permissions }) : void 0,
signal: controller.signal signal: controller.signal
}; });
if (opts?.permissions) {
fetchOptions.body = JSON.stringify({ permissions: opts.permissions });
}
const tokenResponse = await fetch(
`${apiUrl}/api/github/installation-token${queryString}`,
fetchOptions
);
clearTimeout(timeoutId); clearTimeout(timeoutId);
if (!tokenResponse.ok) { if (!tokenResponse.ok) {
throw new Error(`Token exchange failed: ${tokenResponse.status} ${tokenResponse.statusText}`); throw new Error(`Token exchange failed: ${tokenResponse.status} ${tokenResponse.statusText}`);
@@ -144006,13 +144023,12 @@ function UploadFileTool(ctx) {
const contentLength = buffer.length; const contentLength = buffer.length;
const fileType = await fileTypeFromBuffer(buffer); const fileType = await fileTypeFromBuffer(buffer);
const contentType = fileType?.mime || "application/octet-stream"; const contentType = fileType?.mime || "application/octet-stream";
const apiUrl = getApiUrl(); const response = await apiFetch({
const response = await fetch(`${apiUrl}/api/upload/signed-url`, { path: "/api/upload/signed-url",
method: "POST", method: "POST",
headers: { headers: {
Authorization: `Bearer ${ctx.apiToken}`, Authorization: `Bearer ${ctx.apiToken}`,
"Content-Type": "application/json", "Content-Type": "application/json"
...getVercelBypassHeaders()
}, },
body: JSON.stringify({ body: JSON.stringify({
filename, filename,
@@ -146718,23 +146734,18 @@ var defaultRunContext = {
apiToken: "" apiToken: ""
}; };
async function fetchRunContext(params) { async function fetchRunContext(params) {
const apiUrl = getApiUrl();
const timeoutMs = 3e4; const timeoutMs = 3e4;
const controller = new AbortController(); const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeoutMs); const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
try { try {
const response = await fetch( const response = await apiFetch({
`${apiUrl}/api/repo/${params.repoContext.owner}/${params.repoContext.name}/run-context`, path: `/api/repo/${params.repoContext.owner}/${params.repoContext.name}/run-context`,
{
method: "GET",
headers: { headers: {
Authorization: `Bearer ${params.token}`, Authorization: `Bearer ${params.token}`,
"Content-Type": "application/json", "Content-Type": "application/json"
...getVercelBypassHeaders()
}, },
signal: controller.signal signal: controller.signal
} });
);
clearTimeout(timeoutId); clearTimeout(timeoutId);
if (!response.ok) { if (!response.ok) {
return defaultRunContext; return defaultRunContext;
+42 -25
View File
@@ -25681,15 +25681,43 @@ var core2 = __toESM(require_core(), 1);
import { createSign } from "node:crypto"; import { createSign } from "node:crypto";
// utils/apiUrl.ts // utils/apiUrl.ts
function getApiUrl() { function isLocalUrl(url) {
const url = process.env.API_URL || "https://pullfrog.com"; return url.hostname === "localhost" || url.hostname === "127.0.0.1";
log.debug(`resolved API_URL: ${url}`);
return url;
} }
function getVercelBypassHeaders() { function getApiUrl() {
const secret = process.env.VERCEL_AUTOMATION_BYPASS_SECRET; const raw = process.env.API_URL || "https://pullfrog.com";
if (!secret) return {}; const parsed = new URL(raw);
return { "x-vercel-protection-bypass": secret }; if (parsed.protocol !== "https:" && !isLocalUrl(parsed)) {
throw new Error(
`API_URL must use https:// (got ${parsed.protocol}). only localhost is exempt.`
);
}
log.debug(`resolved API_URL: ${raw}`);
return raw;
}
// utils/apiFetch.ts
async function apiFetch(options) {
const apiUrl = getApiUrl();
const url = new URL(options.path, apiUrl);
const bypassSecret = process.env.VERCEL_AUTOMATION_BYPASS_SECRET;
if (bypassSecret) {
url.searchParams.set("x-vercel-protection-bypass", bypassSecret);
}
const headers = {
...options.headers
};
if (bypassSecret) {
headers["x-vercel-protection-bypass"] = bypassSecret;
}
log.debug(`api fetch: ${options.method ?? "GET"} ${url.pathname}`);
const init = {
method: options.method ?? "GET",
headers
};
if (options.body) init.body = options.body;
if (options.signal) init.signal = options.signal;
return fetch(url.toString(), init);
} }
// utils/retry.ts // utils/retry.ts
@@ -25729,37 +25757,26 @@ function isOIDCAvailable() {
} }
async function acquireTokenViaOIDC(opts) { async function acquireTokenViaOIDC(opts) {
const oidcToken = await core2.getIDToken("pullfrog-api"); const oidcToken = await core2.getIDToken("pullfrog-api");
const apiUrl = getApiUrl();
const params = new URLSearchParams();
const repos = [...opts?.repos ?? []]; const repos = [...opts?.repos ?? []];
const targetRepo = process.env.GITHUB_REPOSITORY?.split("/")[1]; const targetRepo = process.env.GITHUB_REPOSITORY?.split("/")[1];
if (targetRepo) { if (targetRepo) {
repos.push(targetRepo); repos.push(targetRepo);
} }
if (repos.length) { const reposParam = repos.length ? `?repos=${repos.join(",")}` : "";
params.set("repos", repos.join(","));
}
const queryString = params.toString() ? `?${params.toString()}` : "";
const timeoutMs = 3e4; const timeoutMs = 3e4;
const controller = new AbortController(); const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeoutMs); const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
try { try {
const fetchOptions = { const tokenResponse = await apiFetch({
path: `/api/github/installation-token${reposParam}`,
method: "POST", method: "POST",
headers: { headers: {
Authorization: `Bearer ${oidcToken}`, Authorization: `Bearer ${oidcToken}`,
"Content-Type": "application/json", "Content-Type": "application/json"
...getVercelBypassHeaders()
}, },
body: opts?.permissions ? JSON.stringify({ permissions: opts.permissions }) : void 0,
signal: controller.signal signal: controller.signal
}; });
if (opts?.permissions) {
fetchOptions.body = JSON.stringify({ permissions: opts.permissions });
}
const tokenResponse = await fetch(
`${apiUrl}/api/github/installation-token${queryString}`,
fetchOptions
);
clearTimeout(timeoutId); clearTimeout(timeoutId);
if (!tokenResponse.ok) { if (!tokenResponse.ok) {
throw new Error(`Token exchange failed: ${tokenResponse.status} ${tokenResponse.statusText}`); throw new Error(`Token exchange failed: ${tokenResponse.status} ${tokenResponse.statusText}`);
+3 -5
View File
@@ -2,7 +2,7 @@ import * as fs from "node:fs";
import * as path from "node:path"; import * as path from "node:path";
import { type } from "arktype"; import { type } from "arktype";
import { fileTypeFromBuffer } from "file-type"; import { fileTypeFromBuffer } from "file-type";
import { getApiUrl, getVercelBypassHeaders } from "../utils/apiUrl.ts"; import { apiFetch } from "../utils/apiFetch.ts";
import type { ToolContext } from "./server.ts"; import type { ToolContext } from "./server.ts";
import { execute, tool } from "./shared.ts"; import { execute, tool } from "./shared.ts";
@@ -25,14 +25,12 @@ export function UploadFileTool(ctx: ToolContext) {
const fileType = await fileTypeFromBuffer(buffer); const fileType = await fileTypeFromBuffer(buffer);
const contentType = fileType?.mime || "application/octet-stream"; const contentType = fileType?.mime || "application/octet-stream";
const apiUrl = getApiUrl(); const response = await apiFetch({
path: "/api/upload/signed-url",
const response = await fetch(`${apiUrl}/api/upload/signed-url`, {
method: "POST", method: "POST",
headers: { headers: {
Authorization: `Bearer ${ctx.apiToken}`, Authorization: `Bearer ${ctx.apiToken}`,
"Content-Type": "application/json", "Content-Type": "application/json",
...getVercelBypassHeaders(),
}, },
body: JSON.stringify({ body: JSON.stringify({
filename, filename,
+50
View File
@@ -0,0 +1,50 @@
import { getApiUrl } from "./apiUrl.ts";
import { log } from "./cli.ts";
type ApiFetchOptions = {
path: string;
method?: string | undefined;
headers?: Record<string, string> | undefined;
body?: string | undefined;
signal?: AbortSignal | undefined;
};
/**
* fetch wrapper for hitting the Pullfrog API with Vercel deployment protection bypass.
*
* adds the bypass secret as BOTH a query parameter and a header for maximum reliability.
* the server-side forwarding code uses query params, and the Vercel docs say both work,
* so we do both as belt-and-suspenders.
*
* the query param approach is the primary bypass mechanism (matches server-side forwarding).
* the header is added as a fallback.
*/
export async function apiFetch(options: ApiFetchOptions): Promise<Response> {
const apiUrl = getApiUrl();
const url = new URL(options.path, apiUrl);
const bypassSecret = process.env.VERCEL_AUTOMATION_BYPASS_SECRET;
if (bypassSecret) {
url.searchParams.set("x-vercel-protection-bypass", bypassSecret);
}
const headers: Record<string, string> = {
...options.headers,
};
// also add as header for belt-and-suspenders
if (bypassSecret) {
headers["x-vercel-protection-bypass"] = bypassSecret;
}
log.debug(`api fetch: ${options.method ?? "GET"} ${url.pathname}`);
const init: RequestInit = {
method: options.method ?? "GET",
headers,
};
if (options.body) init.body = options.body;
if (options.signal) init.signal = options.signal;
return fetch(url.toString(), init);
}
+15 -12
View File
@@ -1,24 +1,27 @@
import { log } from "./cli.ts"; import { log } from "./cli.ts";
function isLocalUrl(url: URL): boolean {
return url.hostname === "localhost" || url.hostname === "127.0.0.1";
}
/** /**
* resolve the Pullfrog API base URL. * resolve the Pullfrog API base URL.
* *
* in the action: API_URL is not explicitly set, so this falls back to https://pullfrog.com. * in the action: API_URL is not explicitly set, so this falls back to https://pullfrog.com.
* in local dev: API_URL=http://localhost:3000 (from .env). * in local dev: API_URL=http://localhost:3000 (from .env).
*
* enforces https:// for non-local URLs to prevent cleartext credential transmission.
*/ */
export function getApiUrl(): string { export function getApiUrl(): string {
const url = process.env.API_URL || "https://pullfrog.com"; const raw = process.env.API_URL || "https://pullfrog.com";
log.debug(`resolved API_URL: ${url}`); const parsed = new URL(raw);
return url;
if (parsed.protocol !== "https:" && !isLocalUrl(parsed)) {
throw new Error(
`API_URL must use https:// (got ${parsed.protocol}). only localhost is exempt.`
);
} }
/** log.debug(`resolved API_URL: ${raw}`);
* returns headers needed to bypass Vercel deployment protection on preview deployments. return raw;
* when VERCEL_AUTOMATION_BYPASS_SECRET is set (preview repos), includes the bypass header.
* otherwise returns an empty object (production / local dev).
*/
export function getVercelBypassHeaders(): Record<string, string> {
const secret = process.env.VERCEL_AUTOMATION_BYPASS_SECRET;
if (!secret) return {};
return { "x-vercel-protection-bypass": secret };
} }
+6 -19
View File
@@ -2,7 +2,7 @@ import { createSign } from "node:crypto";
import * as core from "@actions/core"; import * as core from "@actions/core";
import { throttling } from "@octokit/plugin-throttling"; import { throttling } from "@octokit/plugin-throttling";
import { Octokit } from "@octokit/rest"; import { Octokit } from "@octokit/rest";
import { getApiUrl, getVercelBypassHeaders } from "./apiUrl.ts"; import { apiFetch } from "./apiFetch.ts";
import { retry } from "./retry.ts"; import { retry } from "./retry.ts";
export interface InstallationToken { export interface InstallationToken {
@@ -85,41 +85,28 @@ type AcquireTokenOptions = {
async function acquireTokenViaOIDC(opts?: AcquireTokenOptions): Promise<string> { async function acquireTokenViaOIDC(opts?: AcquireTokenOptions): Promise<string> {
const oidcToken = await core.getIDToken("pullfrog-api"); const oidcToken = await core.getIDToken("pullfrog-api");
const apiUrl = getApiUrl();
const params = new URLSearchParams();
// ensure the token covers GITHUB_REPOSITORY (may differ from OIDC claims repo)
const repos = [...(opts?.repos ?? [])]; const repos = [...(opts?.repos ?? [])];
const targetRepo = process.env.GITHUB_REPOSITORY?.split("/")[1]; const targetRepo = process.env.GITHUB_REPOSITORY?.split("/")[1];
if (targetRepo) { if (targetRepo) {
repos.push(targetRepo); repos.push(targetRepo);
} }
if (repos.length) { const reposParam = repos.length ? `?repos=${repos.join(",")}` : "";
params.set("repos", repos.join(","));
}
const queryString = params.toString() ? `?${params.toString()}` : "";
const timeoutMs = 30000; const timeoutMs = 30000;
const controller = new AbortController(); const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeoutMs); const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
try { try {
const fetchOptions: RequestInit = { const tokenResponse = await apiFetch({
path: `/api/github/installation-token${reposParam}`,
method: "POST", method: "POST",
headers: { headers: {
Authorization: `Bearer ${oidcToken}`, Authorization: `Bearer ${oidcToken}`,
"Content-Type": "application/json", "Content-Type": "application/json",
...getVercelBypassHeaders(),
}, },
body: opts?.permissions ? JSON.stringify({ permissions: opts.permissions }) : undefined,
signal: controller.signal, signal: controller.signal,
}; });
if (opts?.permissions) {
fetchOptions.body = JSON.stringify({ permissions: opts.permissions });
}
const tokenResponse = await fetch(
`${apiUrl}/api/github/installation-token${queryString}`,
fetchOptions
);
clearTimeout(timeoutId); clearTimeout(timeoutId);
+4 -9
View File
@@ -1,5 +1,5 @@
import type { AgentName, BashPermission, PushPermission, ToolPermission } from "../external.ts"; import type { AgentName, BashPermission, PushPermission, ToolPermission } from "../external.ts";
import { getApiUrl, getVercelBypassHeaders } from "./apiUrl.ts"; import { apiFetch } from "./apiFetch.ts";
import type { RepoContext } from "./github.ts"; import type { RepoContext } from "./github.ts";
export interface Mode { export interface Mode {
@@ -52,24 +52,19 @@ export async function fetchRunContext(params: {
token: string; token: string;
repoContext: RepoContext; repoContext: RepoContext;
}): Promise<RunContext> { }): Promise<RunContext> {
const apiUrl = getApiUrl();
const timeoutMs = 30000; const timeoutMs = 30000;
const controller = new AbortController(); const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeoutMs); const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
try { try {
const response = await fetch( const response = await apiFetch({
`${apiUrl}/api/repo/${params.repoContext.owner}/${params.repoContext.name}/run-context`, path: `/api/repo/${params.repoContext.owner}/${params.repoContext.name}/run-context`,
{
method: "GET",
headers: { headers: {
Authorization: `Bearer ${params.token}`, Authorization: `Bearer ${params.token}`,
"Content-Type": "application/json", "Content-Type": "application/json",
...getVercelBypassHeaders(),
}, },
signal: controller.signal, signal: controller.signal,
} });
);
clearTimeout(timeoutId); clearTimeout(timeoutId);
+3 -18
View File
@@ -3,7 +3,6 @@
* Redacts actual secret values rather than using pattern matching * Redacts actual secret values rather than using pattern matching
*/ */
import { agentsManifest } from "../external.ts";
import { getGitHubInstallationToken } from "./token.ts"; import { getGitHubInstallationToken } from "./token.ts";
// patterns for sensitive env var names // patterns for sensitive env var names
@@ -52,28 +51,14 @@ export function resolveEnv(mode: EnvMode | undefined): Record<string, string | u
function getAllSecrets(): string[] { function getAllSecrets(): string[] {
const secrets: string[] = []; const secrets: string[] = [];
// get all API key values from agent manifest // collect all env var values matching SENSITIVE_PATTERNS
for (const agent of Object.values(agentsManifest)) {
for (const keyName of agent.apiKeyNames) {
const envKey = keyName.toUpperCase();
const value = process.env[envKey];
if (value) {
secrets.push(value);
}
}
}
// for OpenCode: also scan all API_KEY environment variables (since apiKeyNames is empty)
const opencodeAgent = agentsManifest.opencode;
if (opencodeAgent && opencodeAgent.apiKeyNames.length === 0) {
for (const [key, value] of Object.entries(process.env)) { for (const [key, value] of Object.entries(process.env)) {
if (value && typeof value === "string" && key.includes("API_KEY")) { if (value && isSensitiveEnvName(key)) {
secrets.push(value); secrets.push(value);
} }
} }
}
// add GitHub installation token // add GitHub installation token (stored in memory, not in env)
try { try {
const token = getGitHubInstallationToken(); const token = getGitHubInstallationToken();
if (token) { if (token) {