From 6f108237d4c3009843ce737c17299a2e3c0d2b11 Mon Sep 17 00:00:00 2001 From: Colin McDonnell Date: Fri, 13 Feb 2026 20:01:48 +0000 Subject: [PATCH] Deployment protection bypass (#298) * test preview bypass 2 Co-authored-by: Cursor * 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 * 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 --------- Co-authored-by: Cursor --- .github/workflows/pullfrog.yml | 2 + README.md | 2 +- entry | 95 +++++++++++++++++++--------------- get-installation-token/entry | 67 +++++++++++++++--------- mcp/upload.ts | 8 ++- utils/apiFetch.ts | 50 ++++++++++++++++++ utils/apiUrl.ts | 29 ++++++----- utils/github.ts | 25 +++------ utils/runContext.ts | 23 ++++---- utils/secrets.ts | 25 ++------- 10 files changed, 187 insertions(+), 139 deletions(-) create mode 100644 utils/apiFetch.ts diff --git a/.github/workflows/pullfrog.yml b/.github/workflows/pullfrog.yml index f5ae018..808bda4 100644 --- a/.github/workflows/pullfrog.yml +++ b/.github/workflows/pullfrog.yml @@ -32,6 +32,8 @@ jobs: with: prompt: ${{ inputs.prompt }} env: + API_URL: ${{ secrets.API_URL }} + VERCEL_AUTOMATION_BYPASS_SECRET: ${{ secrets.VERCEL_AUTOMATION_BYPASS_SECRET }} # add any additional keys your agent(s) need # optionally, comment out any you won't use ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} diff --git a/README.md b/README.md index edc80e8..ffe8977 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ - +

diff --git a/entry b/entry index 103c40f..d5c1fca 100755 --- a/entry +++ b/entry @@ -139417,15 +139417,43 @@ var Octokit2 = Octokit.plugin(requestLog, legacyRestEndpointMethods, paginateRes ); // utils/apiUrl.ts -function getApiUrl() { - const url4 = process.env.API_URL || "https://pullfrog.com"; - log.debug(`resolved API_URL: ${url4}`); - return url4; +function isLocalUrl(url4) { + return url4.hostname === "localhost" || url4.hostname === "127.0.0.1"; } -function getVercelBypassHeaders() { - const secret = process.env.VERCEL_AUTOMATION_BYPASS_SECRET; - if (!secret) return {}; - return { "x-vercel-protection-bypass": secret }; +function getApiUrl() { + const raw = process.env.API_URL || "https://pullfrog.com"; + const parsed2 = new URL(raw); + 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 @@ -139465,37 +139493,26 @@ function isOIDCAvailable() { } async function acquireTokenViaOIDC(opts) { const oidcToken = await core2.getIDToken("pullfrog-api"); - const apiUrl = getApiUrl(); - const params = new URLSearchParams(); const repos = [...opts?.repos ?? []]; const targetRepo = process.env.GITHUB_REPOSITORY?.split("/")[1]; if (targetRepo) { repos.push(targetRepo); } - if (repos.length) { - params.set("repos", repos.join(",")); - } - const queryString = params.toString() ? `?${params.toString()}` : ""; + const reposParam = repos.length ? `?repos=${repos.join(",")}` : ""; const timeoutMs = 3e4; const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), timeoutMs); try { - const fetchOptions = { + const tokenResponse = await apiFetch({ + path: `/api/github/installation-token${reposParam}`, method: "POST", headers: { Authorization: `Bearer ${oidcToken}`, - "Content-Type": "application/json", - ...getVercelBypassHeaders() + "Content-Type": "application/json" }, + body: opts?.permissions ? JSON.stringify({ permissions: opts.permissions }) : void 0, 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); if (!tokenResponse.ok) { throw new Error(`Token exchange failed: ${tokenResponse.status} ${tokenResponse.statusText}`); @@ -144006,13 +144023,12 @@ function UploadFileTool(ctx) { const contentLength = buffer.length; const fileType = await fileTypeFromBuffer(buffer); const contentType = fileType?.mime || "application/octet-stream"; - const apiUrl = getApiUrl(); - const response = await fetch(`${apiUrl}/api/upload/signed-url`, { + const response = await apiFetch({ + path: "/api/upload/signed-url", method: "POST", headers: { Authorization: `Bearer ${ctx.apiToken}`, - "Content-Type": "application/json", - ...getVercelBypassHeaders() + "Content-Type": "application/json" }, body: JSON.stringify({ filename, @@ -146718,23 +146734,18 @@ var defaultRunContext = { apiToken: "" }; async function fetchRunContext(params) { - const apiUrl = getApiUrl(); const timeoutMs = 3e4; const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), timeoutMs); try { - const response = await fetch( - `${apiUrl}/api/repo/${params.repoContext.owner}/${params.repoContext.name}/run-context`, - { - method: "GET", - headers: { - Authorization: `Bearer ${params.token}`, - "Content-Type": "application/json", - ...getVercelBypassHeaders() - }, - signal: controller.signal - } - ); + const response = await apiFetch({ + path: `/api/repo/${params.repoContext.owner}/${params.repoContext.name}/run-context`, + headers: { + Authorization: `Bearer ${params.token}`, + "Content-Type": "application/json" + }, + signal: controller.signal + }); clearTimeout(timeoutId); if (!response.ok) { return defaultRunContext; diff --git a/get-installation-token/entry b/get-installation-token/entry index 81b1507..ef66160 100755 --- a/get-installation-token/entry +++ b/get-installation-token/entry @@ -25681,15 +25681,43 @@ var core2 = __toESM(require_core(), 1); import { createSign } from "node:crypto"; // utils/apiUrl.ts -function getApiUrl() { - const url = process.env.API_URL || "https://pullfrog.com"; - log.debug(`resolved API_URL: ${url}`); - return url; +function isLocalUrl(url) { + return url.hostname === "localhost" || url.hostname === "127.0.0.1"; } -function getVercelBypassHeaders() { - const secret = process.env.VERCEL_AUTOMATION_BYPASS_SECRET; - if (!secret) return {}; - return { "x-vercel-protection-bypass": secret }; +function getApiUrl() { + const raw = process.env.API_URL || "https://pullfrog.com"; + const parsed = new URL(raw); + 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 @@ -25729,37 +25757,26 @@ function isOIDCAvailable() { } async function acquireTokenViaOIDC(opts) { const oidcToken = await core2.getIDToken("pullfrog-api"); - const apiUrl = getApiUrl(); - const params = new URLSearchParams(); const repos = [...opts?.repos ?? []]; const targetRepo = process.env.GITHUB_REPOSITORY?.split("/")[1]; if (targetRepo) { repos.push(targetRepo); } - if (repos.length) { - params.set("repos", repos.join(",")); - } - const queryString = params.toString() ? `?${params.toString()}` : ""; + const reposParam = repos.length ? `?repos=${repos.join(",")}` : ""; const timeoutMs = 3e4; const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), timeoutMs); try { - const fetchOptions = { + const tokenResponse = await apiFetch({ + path: `/api/github/installation-token${reposParam}`, method: "POST", headers: { Authorization: `Bearer ${oidcToken}`, - "Content-Type": "application/json", - ...getVercelBypassHeaders() + "Content-Type": "application/json" }, + body: opts?.permissions ? JSON.stringify({ permissions: opts.permissions }) : void 0, 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); if (!tokenResponse.ok) { throw new Error(`Token exchange failed: ${tokenResponse.status} ${tokenResponse.statusText}`); diff --git a/mcp/upload.ts b/mcp/upload.ts index 6a76b24..4b1c94c 100644 --- a/mcp/upload.ts +++ b/mcp/upload.ts @@ -2,7 +2,7 @@ import * as fs from "node:fs"; import * as path from "node:path"; import { type } from "arktype"; 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 { execute, tool } from "./shared.ts"; @@ -25,14 +25,12 @@ export function UploadFileTool(ctx: ToolContext) { const fileType = await fileTypeFromBuffer(buffer); const contentType = fileType?.mime || "application/octet-stream"; - const apiUrl = getApiUrl(); - - const response = await fetch(`${apiUrl}/api/upload/signed-url`, { + const response = await apiFetch({ + path: "/api/upload/signed-url", method: "POST", headers: { Authorization: `Bearer ${ctx.apiToken}`, "Content-Type": "application/json", - ...getVercelBypassHeaders(), }, body: JSON.stringify({ filename, diff --git a/utils/apiFetch.ts b/utils/apiFetch.ts new file mode 100644 index 0000000..b264491 --- /dev/null +++ b/utils/apiFetch.ts @@ -0,0 +1,50 @@ +import { getApiUrl } from "./apiUrl.ts"; +import { log } from "./cli.ts"; + +type ApiFetchOptions = { + path: string; + method?: string | undefined; + headers?: Record | 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 { + 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 = { + ...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); +} diff --git a/utils/apiUrl.ts b/utils/apiUrl.ts index 5988d17..233fef8 100644 --- a/utils/apiUrl.ts +++ b/utils/apiUrl.ts @@ -1,24 +1,27 @@ 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. * * 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). + * + * enforces https:// for non-local URLs to prevent cleartext credential transmission. */ export function getApiUrl(): string { - const url = process.env.API_URL || "https://pullfrog.com"; - log.debug(`resolved API_URL: ${url}`); - return url; -} + const raw = process.env.API_URL || "https://pullfrog.com"; + const parsed = new URL(raw); -/** - * returns headers needed to bypass Vercel deployment protection on preview deployments. - * 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 { - const secret = process.env.VERCEL_AUTOMATION_BYPASS_SECRET; - if (!secret) return {}; - 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; } diff --git a/utils/github.ts b/utils/github.ts index 71b8851..ded0116 100644 --- a/utils/github.ts +++ b/utils/github.ts @@ -2,7 +2,7 @@ import { createSign } from "node:crypto"; import * as core from "@actions/core"; import { throttling } from "@octokit/plugin-throttling"; import { Octokit } from "@octokit/rest"; -import { getApiUrl, getVercelBypassHeaders } from "./apiUrl.ts"; +import { apiFetch } from "./apiFetch.ts"; import { retry } from "./retry.ts"; export interface InstallationToken { @@ -85,41 +85,28 @@ type AcquireTokenOptions = { async function acquireTokenViaOIDC(opts?: AcquireTokenOptions): Promise { 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 targetRepo = process.env.GITHUB_REPOSITORY?.split("/")[1]; if (targetRepo) { repos.push(targetRepo); } - if (repos.length) { - params.set("repos", repos.join(",")); - } - const queryString = params.toString() ? `?${params.toString()}` : ""; + const reposParam = repos.length ? `?repos=${repos.join(",")}` : ""; const timeoutMs = 30000; const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), timeoutMs); try { - const fetchOptions: RequestInit = { + const tokenResponse = await apiFetch({ + path: `/api/github/installation-token${reposParam}`, method: "POST", headers: { Authorization: `Bearer ${oidcToken}`, "Content-Type": "application/json", - ...getVercelBypassHeaders(), }, + body: opts?.permissions ? JSON.stringify({ permissions: opts.permissions }) : undefined, 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); diff --git a/utils/runContext.ts b/utils/runContext.ts index 046d795..b0d2ad5 100644 --- a/utils/runContext.ts +++ b/utils/runContext.ts @@ -1,5 +1,5 @@ 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"; export interface Mode { @@ -52,24 +52,19 @@ export async function fetchRunContext(params: { token: string; repoContext: RepoContext; }): Promise { - const apiUrl = getApiUrl(); const timeoutMs = 30000; const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), timeoutMs); try { - const response = await fetch( - `${apiUrl}/api/repo/${params.repoContext.owner}/${params.repoContext.name}/run-context`, - { - method: "GET", - headers: { - Authorization: `Bearer ${params.token}`, - "Content-Type": "application/json", - ...getVercelBypassHeaders(), - }, - signal: controller.signal, - } - ); + const response = await apiFetch({ + path: `/api/repo/${params.repoContext.owner}/${params.repoContext.name}/run-context`, + headers: { + Authorization: `Bearer ${params.token}`, + "Content-Type": "application/json", + }, + signal: controller.signal, + }); clearTimeout(timeoutId); diff --git a/utils/secrets.ts b/utils/secrets.ts index 4f0ff52..a7bb2e3 100644 --- a/utils/secrets.ts +++ b/utils/secrets.ts @@ -3,7 +3,6 @@ * Redacts actual secret values rather than using pattern matching */ -import { agentsManifest } from "../external.ts"; import { getGitHubInstallationToken } from "./token.ts"; // patterns for sensitive env var names @@ -52,28 +51,14 @@ export function resolveEnv(mode: EnvMode | undefined): Record