diff --git a/api/index.ts b/app/api/proxy/route.ts similarity index 65% rename from api/index.ts rename to app/api/proxy/route.ts index 348e204..01ec346 100644 --- a/api/index.ts +++ b/app/api/proxy/route.ts @@ -4,13 +4,21 @@ import { normalizeTargetUrl, stripRelayHeaders, isAllowedTarget, -} from "../relay-utils"; +} from "@/lib/relay-utils"; -export const config = { runtime: "edge" }; +export const runtime = "edge"; const ALLOWED_METHODS = new Set(["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"]); -export default async function handler(req: Request): Promise { +export async function POST(req: Request) { return await handler(req); } +export async function GET(req: Request) { return await handler(req); } +export async function PUT(req: Request) { return await handler(req); } +export async function DELETE(req: Request) { return await handler(req); } +export async function PATCH(req: Request) { return await handler(req); } +export async function HEAD(req: Request) { return await handler(req); } +export async function OPTIONS(req: Request) { return await handler(req); } + +async function handler(req: Request): Promise { if (!ALLOWED_METHODS.has(req.method)) { return new Response(JSON.stringify({ error: "Method not allowed" }), { status: 405, diff --git a/app/globals.css b/app/globals.css new file mode 100644 index 0000000..b5c61c9 --- /dev/null +++ b/app/globals.css @@ -0,0 +1,3 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; diff --git a/app/layout.tsx b/app/layout.tsx new file mode 100644 index 0000000..aa0548e --- /dev/null +++ b/app/layout.tsx @@ -0,0 +1,19 @@ +import type { Metadata } from "next"; +import "./globals.css"; + +export const metadata: Metadata = { + title: "Edge Proxy Relay", + description: "Securely relay requests through Vercel Edge", +}; + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( + + {children} + + ); +} diff --git a/app/page.tsx b/app/page.tsx new file mode 100644 index 0000000..7dabd7e --- /dev/null +++ b/app/page.tsx @@ -0,0 +1,199 @@ +"use client"; + +import { useState } from "react"; + +export default function Home() { + const [targetUrl, setTargetUrl] = useState("https://httpbin.org/anything"); + const [method, setMethod] = useState("GET"); + const [requestBody, setRequestBody] = useState(JSON.stringify({ + message: "Hello from Edge Proxy", + timestamp: Date.now(), + }, null, 2)); + const [headerKey, setHeaderKey] = useState(""); + const [headerValue, setHeaderValue] = useState(""); + const [response, setResponse] = useState(null); + const [loading, setLoading] = useState(false); + + const methods = ["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"]; + + async function sendRequest() { + setLoading(true); + setResponse(null); + + const headers: Record = { + 'x-relay-target': targetUrl, + 'Content-Type': 'application/json' + }; + + if (headerKey && headerValue) { + headers[headerKey] = headerValue; + } + + try { + const start = Date.now(); + const fetchOptions: any = { + method, + headers + }; + + if (!['GET', 'HEAD', 'OPTIONS'].includes(method) && requestBody) { + fetchOptions.body = requestBody; + } + + const res = await fetch('/api/proxy', fetchOptions); + const elapsed = Date.now() - start; + + let responseText = ''; + const contentType = res.headers.get('content-type') || ''; + + if (contentType.includes('application/json')) { + const data = await res.json(); + responseText = JSON.stringify(data, null, 2); + } else { + responseText = await res.text(); + } + + const headersObj: Record = {}; + res.headers.forEach((value, key) => { + headersObj[key] = value; + }); + + setResponse({ + status: res.status, + statusText: res.statusText, + time: `${elapsed}ms`, + body: responseText, + headers: JSON.stringify(headersObj, null, 2) + }); + } catch (err: any) { + setResponse({ + status: 'Error', + body: err.message, + headers: '-' + }); + } finally { + setLoading(false); + } + } + + return ( +
+

Edge Proxy Relay Test

+

Standardized Next.js Migration

+ +
+

Target Configuration

+
+
+ + setTargetUrl(e.target.value)} + /> +
+
+ {['https://httpbin.org/anything', 'https://httpbin.org/get', 'https://jsonplaceholder.typicode.com/posts/1'].map(url => ( + + ))} +
+
+
+ +
+

Method

+
+ {methods.map(m => ( + + ))} +
+
+ +
+
+

Body (JSON)

+ -
-
- -
- - - - -
- - -
-
-
- -
-

Custom Headers

-
-
- - -
-
- - -
-
-
- - - -
-

Response

-
-
- Status: - - -
-
- Time: - - -
-
- Size: - - -
-
-
-
- -
Response will appear here...
-
-
- -
Response headers will appear here...
-
-
-
- - - - \ No newline at end of file diff --git a/src/index.test.ts b/src/index.test.ts deleted file mode 100644 index c2a0f97..0000000 --- a/src/index.test.ts +++ /dev/null @@ -1,257 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { - buildRelayRequest, - createRelayResponse, - normalizeTargetUrl, - shouldSendBody, - stripRelayHeaders, -} from "~/relay-utils"; - -describe("normalizeTargetUrl", () => { - test("returns null when target is null", () => { - expect(normalizeTargetUrl(null, "/")).toBeNull(); - }); - - test("returns null when target is empty string", () => { - expect(normalizeTargetUrl("", "/")).toBeNull(); - }); - - test("removes trailing slash from target", () => { - expect(normalizeTargetUrl("https://httpbin.org/", "/get")).toBe( - "https://httpbin.org/get", - ); - }); - - test("keeps target without trailing slash", () => { - expect(normalizeTargetUrl("https://httpbin.org", "/get")).toBe( - "https://httpbin.org/get", - ); - }); - - test("appends relay path", () => { - expect(normalizeTargetUrl("https://example.com", "/api/users")).toBe( - "https://example.com/api/users", - ); - }); - - test("handles nested paths", () => { - expect( - normalizeTargetUrl("https://api.example.com", "/v1/users/123/profile"), - ).toBe("https://api.example.com/v1/users/123/profile"); - }); -}); - -describe("stripRelayHeaders", () => { - test("removes x-relay-target header", () => { - const headers = new Headers({ "x-relay-target": "https://test.com" }); - const stripped = stripRelayHeaders(headers); - expect(stripped.get("x-relay-target")).toBeNull(); - }); - - test("removes x-relay-path header", () => { - const headers = new Headers({ "x-relay-path": "/test" }); - const stripped = stripRelayHeaders(headers); - expect(stripped.get("x-relay-path")).toBeNull(); - }); - - test("removes host header", () => { - const headers = new Headers({ host: "localhost:3000" }); - const stripped = stripRelayHeaders(headers); - expect(stripped.get("host")).toBeNull(); - }); - - test("preserves other headers", () => { - const headers = new Headers({ - "x-relay-target": "https://test.com", - "x-custom-header": "value", - authorization: "Bearer token", - }); - const stripped = stripRelayHeaders(headers); - expect(stripped.get("x-custom-header")).toBe("value"); - expect(stripped.get("authorization")).toBe("Bearer token"); - }); - - test("handles multiple relay headers", () => { - const headers = new Headers({ - "x-relay-target": "https://test.com", - "x-relay-path": "/path", - host: "test.com", - "content-type": "application/json", - }); - const stripped = stripRelayHeaders(headers); - expect(stripped.get("content-type")).toBe("application/json"); - expect(stripped.get("x-relay-target")).toBeNull(); - expect(stripped.get("x-relay-path")).toBeNull(); - expect(stripped.get("host")).toBeNull(); - }); -}); - -describe("shouldSendBody", () => { - test("returns false for GET", () => { - expect(shouldSendBody("GET")).toBe(false); - }); - - test("returns false for HEAD", () => { - expect(shouldSendBody("HEAD")).toBe(false); - }); - - test("returns true for POST", () => { - expect(shouldSendBody("POST")).toBe(true); - }); - - test("returns true for PUT", () => { - expect(shouldSendBody("PUT")).toBe(true); - }); - - test("returns true for PATCH", () => { - expect(shouldSendBody("PATCH")).toBe(true); - }); - - test("returns true for DELETE", () => { - expect(shouldSendBody("DELETE")).toBe(true); - }); - - test("is case-sensitive", () => { - expect(shouldSendBody("get")).toBe(true); - expect(shouldSendBody("post")).toBe(true); - }); -}); - -describe("buildRelayRequest", () => { - test("sets correct method", () => { - const req = new Request("http://test.com", { method: "POST" }); - const headers = new Headers(); - const result = buildRelayRequest(req, headers); - expect(result.method).toBe("POST"); - }); - - test("sets headers", () => { - const req = new Request("http://test.com"); - const headers = new Headers({ "x-custom": "value" }); - const result = buildRelayRequest(req, headers); - expect(result.headers).toBe(headers); - }); - - test("omits body for GET", () => { - const req = new Request("http://test.com", { method: "GET" }); - const headers = new Headers(); - const result = buildRelayRequest(req, headers); - expect(result.body).toBeUndefined(); - }); - - test("omits body for HEAD", () => { - const req = new Request("http://test.com", { method: "HEAD" }); - const headers = new Headers(); - const result = buildRelayRequest(req, headers); - expect(result.body).toBeUndefined(); - }); - - test("includes body for POST", () => { - const body = JSON.stringify({ test: true }); - const req = new Request("http://test.com", { method: "POST", body }); - const headers = new Headers(); - const result = buildRelayRequest(req, headers); - expect(result.body).toBeDefined(); - expect(result.duplex).toBe("half"); - }); - - test("includes body for PUT", () => { - const body = JSON.stringify({ update: true }); - const req = new Request("http://test.com", { method: "PUT", body }); - const headers = new Headers(); - const result = buildRelayRequest(req, headers); - expect(result.body).toBeDefined(); - }); - - test("includes body for PATCH", () => { - const body = JSON.stringify({ patch: true }); - const req = new Request("http://test.com", { method: "PATCH", body }); - const headers = new Headers(); - const result = buildRelayRequest(req, headers); - expect(result.body).toBeDefined(); - }); - - test("includes body for DELETE", () => { - const body = JSON.stringify({ delete: true }); - const req = new Request("http://test.com", { method: "DELETE", body }); - const headers = new Headers(); - const result = buildRelayRequest(req, headers); - expect(result.body).toBeDefined(); - }); -}); - -describe("createRelayResponse", () => { - test("preserves status", async () => { - const mockResponse = new Response("body", { status: 418 }); - const result = createRelayResponse(mockResponse); - expect(result.status).toBe(418); - }); - - test("preserves headers", async () => { - const mockResponse = new Response("body", { - headers: { "x-custom": "value" }, - }); - const result = createRelayResponse(mockResponse); - expect(result.headers.get("x-custom")).toBe("value"); - }); - - test("preserves body", async () => { - const mockResponse = new Response("test body content"); - const result = createRelayResponse(mockResponse); - expect(await result.text()).toBe("test body content"); - }); - - test("handles different status codes", async () => { - const mockResponse = new Response(null, { status: 404 }); - const result = createRelayResponse(mockResponse); - expect(result.status).toBe(404); - }); - - test("handles 500 status", async () => { - const mockResponse = new Response("error", { status: 500 }); - const result = createRelayResponse(mockResponse); - expect(result.status).toBe(500); - }); -}); - -describe("integration: full relay flow", () => { - test("complete flow with mocked fetch", async () => { - const target = "https://httpbin.org"; - const relayPath = "/get"; - const targetUrl = normalizeTargetUrl(target, relayPath); - - expect(targetUrl).toBe("https://httpbin.org/get"); - - const originalHeaders = new Headers({ - "x-relay-target": target, - "x-relay-path": relayPath, - host: "localhost", - "x-custom": "preserved", - }); - - const strippedHeaders = stripRelayHeaders(originalHeaders); - expect(strippedHeaders.get("x-relay-target")).toBeNull(); - expect(strippedHeaders.get("x-relay-path")).toBeNull(); - expect(strippedHeaders.get("host")).toBeNull(); - expect(strippedHeaders.get("x-custom")).toBe("preserved"); - }); - - test("POST flow with body preservation", () => { - const req = new Request("http://test.com", { - method: "POST", - body: JSON.stringify({ data: "test" }), - headers: { "content-type": "application/json" }, - }); - - const options = buildRelayRequest(req, new Headers()); - expect(options.method).toBe("POST"); - expect(options.body).toBeDefined(); - }); - - test("error case: missing target", () => { - const target = null; - const relayPath = "/test"; - const result = normalizeTargetUrl(target, relayPath); - expect(result).toBeNull(); - }); -}); diff --git a/src/index.ts b/src/index.ts deleted file mode 100644 index 122c2d7..0000000 --- a/src/index.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { - buildRelayRequest, - createRelayResponse, - normalizeTargetUrl, - stripRelayHeaders, - isAllowedTarget, -} from "~/relay-utils"; - -export const config = { runtime: "edge" }; - -// Only allow safe methodsa -const ALLOWED_METHODS = new Set(["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"]); - -export default async function handler(req: Request): Promise { - // Method validation - if (!ALLOWED_METHODS.has(req.method)) { - return new Response(JSON.stringify({ error: "Method not allowed" }), { - status: 405, - headers: { "content-type": "application/json" }, - }); - } - - const target = req.headers.get("x-relay-target"); - const relayPath = req.headers.get("x-relay-path") || "/"; - - const targetUrl = normalizeTargetUrl(target, relayPath); - if (!targetUrl) { - return new Response( - JSON.stringify({ error: "Missing x-relay-target header" }), - { - status: 400, - headers: { "content-type": "application/json" }, - }, - ); - } - - // Target validation (SSRF prevention) - if (!isAllowedTarget(targetUrl)) { - return new Response( - JSON.stringify({ error: "Target domain not allowed" }), - { - status: 403, - headers: { "content-type": "application/json" }, - }, - ); - } - - const headers = stripRelayHeaders(new Headers(req.headers)); - const fetchOptions = buildRelayRequest(req, headers); - - const response = await fetch(targetUrl, fetchOptions); - return createRelayResponse(response); -} diff --git a/relay-utils.ts b/src/lib/relay-utils.ts similarity index 100% rename from relay-utils.ts rename to src/lib/relay-utils.ts diff --git a/src/relay-utils.ts b/src/relay-utils.ts deleted file mode 100644 index 8855773..0000000 --- a/src/relay-utils.ts +++ /dev/null @@ -1,143 +0,0 @@ -// Allowlist: only these headers are forwarded to prevent leaking -// Vercel internal metadata, credentials, and infrastructure info -const ALLOWED_HEADERS = new Set([ - // Standard request headers - "content-type", - "accept", - "accept-encoding", - "accept-language", - "user-agent", - "referer", - "origin", - // Auth headers (but NOT cookies) - "authorization", - "proxy-authorization", - // Content negotiation - "cache-control", - // Custom headers (no prefix restriction, but Vercel-specific are blocked) -]); - -// Blocklist: sensitive headers that should NEVER be forwarded -const BLOCKED_HEADERS = new Set([ - // Vercel infrastructure headers - "x-vercel-id", - "x-vercel-deployment-url", - "x-vercel-oidc-token", - "x-vercel-oidc-token-ts", - "x-vercel-signature", - "x-vercel-edgified", - "x-vercel-ip-city", - "x-vercel-ip-country", - "x-vercel-ip-country-region", - "x-vercel-ip-latency", - "x-vercel-deployment-config", - "x-vercel-rewritten-query", - // Cloudflare specific - "cf-ray", - "cf-connecting-ip", - "cf-ipcountry", - "cf-ray-id", - // Forwarding proxies (can leak internal network info) - "x-forwarded-for", - "x-forwarded-host", - "x-forwarded-proto", - "forwarded", - // Cookies (should be explicitly handled, not blindly forwarded) - "cookie", - "set-cookie", - // Internal infrastructure - "x-real-ip", - "x-cluster-client-ip", - // Authentication tokens - "x-api-key", - // Caching - "x-cache", -]); - -// Internal relay headers -const INTERNAL_HEADERS = new Set([ - "x-relay-target", - "x-relay-path", - "host", -]); - -export interface RelayOptions { - stripHeaders?: string[]; -} - -export function isAllowedTarget(url: string): boolean { - try { - const parsed = new URL(url); - // Only allow HTTP/HTTPS (prevents file://, data:, etc.) - return ["http:", "https:"].includes(parsed.protocol); - } catch { - return false; - } -} - -export function normalizeTargetUrl( - target: string | null, - relayPath: string, -): string | null { - if (!target) return null; - return target.replace(/\/$/, "") + relayPath; -} - -export function filterHeaders(headers: Headers): Headers { - const filtered = new Headers(); - - for (const [key, value] of headers.entries()) { - const lowerKey = key.toLowerCase(); - - // Skip internal relay headers - if (INTERNAL_HEADERS.has(lowerKey)) continue; - - // Skip blocked headers (security critical) - if (BLOCKED_HEADERS.has(lowerKey)) continue; - - // Block headers with sensitive infrastructure prefixes - if (lowerKey.startsWith("x-vercel-")) continue; - if (lowerKey.startsWith("cf-")) continue; - if (lowerKey.startsWith("x-forwarded-")) continue; - - // For known safe headers, always allow - if (ALLOWED_HEADERS.has(lowerKey)) { - filtered.set(key, value); - continue; - } - - // Allow custom headers (no sensitive prefix) - // Custom headers typically use kebab-case (e.g., x-custom-header) - filtered.set(key, value); - } - - return filtered; -} - -export function stripRelayHeaders(headers: Headers): Headers { - // Use the secure filter instead of manual deletion - return filterHeaders(headers); -} - -export function shouldSendBody(method: string): boolean { - return method !== "GET" && method !== "HEAD"; -} - -export function buildRelayRequest( - req: Request, - _headers: Headers, -): RequestInit { - return { - method: req.method, - headers: _headers, - body: shouldSendBody(req.method) ? req.body : undefined, - duplex: "half" as const, - }; -} - -export function createRelayResponse(response: Response): Response { - return new Response(response.body, { - status: response.status, - headers: response.headers, - }); -} diff --git a/tailwind.config.js b/tailwind.config.js new file mode 100644 index 0000000..2a8d4c0 --- /dev/null +++ b/tailwind.config.js @@ -0,0 +1,11 @@ +/** @type {import('tailwindcss').Config} */ +module.exports = { + content: [ + "./app/**/*.{js,ts,jsx,tsx,mdx}", + "./src/**/*.{js,ts,jsx,tsx,mdx}", + ], + theme: { + extend: {}, + }, + plugins: [], +} diff --git a/tsconfig.json b/tsconfig.json index 3dd10a1..af05591 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,30 +1,27 @@ { - "compilerOptions": { - "lib": ["ESNext"], - "target": "ESNext", - "module": "Preserve", - "moduleDetection": "force", - "jsx": "react-jsx", - "allowJs": true, - "types": ["bun"], - - // Bundler mode - "moduleResolution": "bundler", - "allowImportingTsExtensions": true, - "verbatimModuleSyntax": true, - "noEmit": true, - - // Best practices - "strict": true, - "skipLibCheck": true, - "noFallthroughCasesInSwitch": true, - "noUncheckedIndexedAccess": true, - "noImplicitOverride": true, - - // Path aliases - "paths": { - "~/*": ["./src/*"] - } - }, - "include": ["src/**/*"] + "compilerOptions": { + "target": "ESNext", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "plugins": [ + { + "name": "next" + } + ], + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "exclude": ["node_modules"] } diff --git a/vercel.json b/vercel.json deleted file mode 100644 index b8e948d..0000000 --- a/vercel.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "$schema": "https://openapi.vercel.sh/vercel.json", - "bunVersion": "1", - "installCommand": "bun install", - "outputDirectory": "dist", - "trailingSlash": false, - "rewrites": [ - { - "source": "/api", - "destination": "/api/index.ts" - }, - { - "source": "/api/(.*)", - "destination": "/api/index.ts" - } - ] -}