diff --git a/apps/web/__tests__/unit/agent-s3-endpoint-policy.test.ts b/apps/web/__tests__/unit/agent-s3-endpoint-policy.test.ts new file mode 100644 index 00000000000..7dc6ebfdd0a --- /dev/null +++ b/apps/web/__tests__/unit/agent-s3-endpoint-policy.test.ts @@ -0,0 +1,202 @@ +import { Agent as HttpAgent } from "node:http"; +import { Agent as HttpsAgent } from "node:https"; +import type { S3ClientConfig } from "@aws-sdk/client-s3"; +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; + +const state = vi.hoisted(() => ({ + isCap: "true" as string | undefined, + nodeEnv: "production", + s3Configs: [] as Array>, + send: vi.fn(async () => ({})), +})); + +vi.mock("server-only", () => ({})); +vi.mock("next/cache", () => ({ revalidatePath: vi.fn() })); +vi.mock("@/lib/rate-limit", () => ({ + isRateLimited: vi.fn(async () => false), + RATE_LIMIT_IDS: { + AGENT_TOKEN_EXCHANGE: "rl_agent_token_exchange", + AGENT_AUTHORIZATION: "rl_agent_authorization", + AGENT_UNLOCK: "rl_agent_unlock", + AGENT_LOOM_IMPORT: "rl_loom_import_per_user", + }, +})); + +vi.mock("@cap/env", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + buildEnv: new Proxy(actual.buildEnv, { + get: (target, property) => + property === "NEXT_PUBLIC_IS_CAP" + ? state.isCap + : Reflect.get(target, property), + }), + get NODE_ENV() { + return state.nodeEnv; + }, + }; +}); + +vi.mock("@aws-sdk/client-s3", async (importOriginal) => { + const actual = await importOriginal(); + class S3Client extends actual.S3Client { + constructor(config: S3ClientConfig) { + super(config); + const credentials = config.credentials as + | { accessKeyId?: string } + | undefined; + if (credentials?.accessKeyId === "AKIASYNTHETIC") { + state.s3Configs.push(config as Record); + this.send = state.send as unknown as S3Client["send"]; + } + } + } + return { ...actual, S3Client }; +}); + +vi.mock("@/lib/agent-write", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + runAgentExternalMutation: (input: { + execute: (providerIdempotencyKey: string) => unknown; + }) => input.execute("synthetic-provider-key"), + }; +}); + +const principalRow = { + tokenId: "agk_synthetic", + scopes: ["caps:read", "integrations:read", "integrations:write"], + expiresAt: new Date(Date.now() + 60 * 60 * 1000), + revokedAt: null, + lastUsedAt: new Date(), + id: "usr_synthetic", + email: "owner@example.com", + activeOrganizationId: "org_synthetic", + role: "owner", + hasProSeat: true, + stripeSubscriptionStatus: "active", + thirdPartyStripeSubscriptionId: null, +}; + +const query = (): unknown => + new Proxy(() => undefined, { + get: (_, property) => + property === "then" + ? (resolve: (value: unknown) => void) => resolve([principalRow]) + : query, + apply: query, + }); + +vi.mock("@cap/database", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, db: query }; +}); + +const token = `cap_cli_${"A".repeat(43)}`; + +describe("agent API S3 endpoint policy", () => { + let POST: typeof import("@/app/api/v1/[...route]/route").POST; + let PUT: typeof import("@/app/api/v1/[...route]/route").PUT; + let blockedS3EndpointMessage: string; + + beforeAll(async () => { + Object.assign(process.env, { + NEXT_PUBLIC_WEB_URL: "http://localhost", + WEB_URL: "http://localhost", + DATABASE_URL: "mysql://unused:unused@127.0.0.1:3306/unused", + NEXTAUTH_SECRET: "synthetic-test-secret-that-is-long-enough", + NEXTAUTH_URL: "http://localhost", + CAP_AWS_BUCKET: "synthetic", + CAP_AWS_REGION: "us-east-1", + CAP_AWS_ACCESS_KEY: "synthetic", + CAP_AWS_SECRET_KEY: "synthetic", + NODE_ENV: "test", + }); + const route = await import("@/app/api/v1/[...route]/route"); + POST = route.POST; + PUT = route.PUT; + ({ blockedS3EndpointMessage } = await import("@/lib/s3-endpoint-policy")); + }); + + beforeEach(() => { + state.isCap = "true"; + state.nodeEnv = "production"; + state.s3Configs.length = 0; + state.send.mockClear(); + }); + + const s3Request = (method: "POST" | "PUT", endpoint: string) => + new Request( + `http://localhost/api/v1/organizations/org_synthetic/storage/s3${ + method === "POST" ? "/test" : "" + }`, + { + method, + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + "Idempotency-Key": "synthetic-idempotency-key", + "X-Cap-Confirmation": "user", + }, + body: JSON.stringify({ + provider: "aws", + accessKeyId: "AKIASYNTHETIC", + secretAccessKey: "synthetic-secret", + endpoint, + bucketName: "synthetic-bucket", + region: "us-east-1", + }), + }, + ); + + it.each([ + ["PUT", "http://169.254.169.254"], + ["PUT", "http://localhost:9000"], + ["POST", "http://127.0.0.1:9000"], + ["POST", " http://[::1]:9000 "], + ] as const)( + "rejects %s with private endpoint %s on Cap Cloud", + async (method, endpoint) => { + const handler = method === "POST" ? POST : PUT; + const response = await handler(s3Request(method, endpoint)); + const body = await response.json(); + + expect(response.status).toBe(400); + expect(body).toMatchObject({ + code: "INVALID_REQUEST", + message: blockedS3EndpointMessage, + }); + expect(state.s3Configs).toHaveLength(0); + expect(state.send).not.toHaveBeenCalled(); + }, + ); + + it("tests public endpoints through public-only agents on Cap Cloud", async () => { + const response = await POST(s3Request("POST", "https://8.8.8.8")); + + expect(response.status).toBe(200); + expect(state.s3Configs).toHaveLength(1); + const requestHandler = state.s3Configs[0]?.requestHandler as { + httpAgent?: unknown; + httpsAgent?: unknown; + }; + expect(requestHandler.httpAgent).toBeInstanceOf(HttpAgent); + expect(requestHandler.httpsAgent).toBeInstanceOf(HttpsAgent); + expect(state.send).toHaveBeenCalledTimes(1); + }); + + it("allows local endpoints on self-hosted deployments", async () => { + state.isCap = undefined; + const response = await POST(s3Request("POST", "http://localhost:9000")); + + expect(response.status).toBe(200); + expect(state.s3Configs).toHaveLength(1); + expect(state.s3Configs[0]).toMatchObject({ + endpoint: "http://localhost:9000", + }); + expect(state.s3Configs[0]?.requestHandler).toBeUndefined(); + expect(state.send).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/web/__tests__/unit/organization-storage-s3-endpoint.test.ts b/apps/web/__tests__/unit/organization-storage-s3-endpoint.test.ts new file mode 100644 index 00000000000..1ba8dd70155 --- /dev/null +++ b/apps/web/__tests__/unit/organization-storage-s3-endpoint.test.ts @@ -0,0 +1,184 @@ +import { Agent as HttpAgent } from "node:http"; +import { Agent as HttpsAgent } from "node:https"; +import type { Organisation } from "@cap/web-domain"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const state = vi.hoisted(() => ({ + buildEnv: { NEXT_PUBLIC_IS_CAP: "true" as string | undefined }, + NODE_ENV: "production", + s3Configs: [] as Array>, + send: vi.fn(async () => ({})), + insert: vi.fn(), +})); + +vi.mock("@cap/env", () => ({ + get buildEnv() { + return state.buildEnv; + }, + get NODE_ENV() { + return state.NODE_ENV; + }, + serverEnv: () => ({ NEXTAUTH_SECRET: "synthetic-secret" }), +})); + +vi.mock("@aws-sdk/client-s3", () => ({ + HeadBucketCommand: class { + constructor(readonly input: unknown) {} + }, + S3Client: class { + constructor(config: Record) { + state.s3Configs.push(config); + } + send = state.send; + }, +})); + +const query = (result: unknown): unknown => + new Proxy(() => undefined, { + get: (_, property) => + property === "then" + ? (resolve: (value: unknown) => void) => resolve(result) + : () => query(result), + }); + +type FakeDatabase = { + select: () => unknown; + update: () => unknown; + insert: (...args: unknown[]) => unknown; + transaction: ( + callback: (tx: FakeDatabase) => Promise, + ) => Promise; +}; + +const database: FakeDatabase = { + select: () => query([{ id: "org_synthetic", name: "Org", ownerId: "usr" }]), + update: () => query(undefined), + insert: (...args: unknown[]) => { + state.insert(...args); + return query(undefined); + }, + transaction: async (callback) => callback(database), +}; + +vi.mock("@cap/database", () => ({ db: () => database })); +vi.mock("@cap/database/auth/session", () => ({ + getCurrentUser: async () => ({ id: "usr_synthetic" }), +})); +vi.mock("@cap/database/crypto", () => ({ + encrypt: async (value: string) => `enc:${value}`, + decrypt: async (value: string) => value.replace(/^enc:/, ""), +})); +vi.mock("@cap/database/helpers", () => ({ nanoId: () => "synthetic_id" })); +vi.mock("@cap/database/schema", () => ({ + organizations: {}, + s3Buckets: {}, + storageIntegrations: {}, + storageObjects: {}, + videos: {}, +})); +vi.mock("@cap/utils", () => ({ userIsPro: () => true })); +vi.mock("@cap/web-backend", () => ({})); +vi.mock("@cap/web-domain", () => ({ + S3Bucket: { S3BucketId: { make: (id: string) => id } }, + Storage: { StorageIntegrationId: { make: (id: string) => id } }, +})); +vi.mock("next/cache", () => ({ revalidatePath: vi.fn() })); +vi.mock("@/lib/server", () => ({ runPromise: vi.fn() })); +vi.mock("@/actions/organization/authorization", () => ({ + requireOrganizationSettingsManager: async () => undefined, +})); + +import { + saveOrganizationS3Config, + testOrganizationS3Config, +} from "@/actions/organization/storage"; +import { blockedS3EndpointMessage } from "@/lib/s3-endpoint-policy"; + +const input = (endpoint: string) => ({ + organizationId: "org_synthetic" as Organisation.OrganisationId, + provider: "aws", + accessKeyId: "AKIASYNTHETIC", + secretAccessKey: "synthetic-secret", + endpoint, + bucketName: "synthetic-bucket", + region: "us-east-1", +}); + +describe("organization storage S3 endpoint policy", () => { + beforeEach(() => { + state.buildEnv.NEXT_PUBLIC_IS_CAP = "true"; + state.NODE_ENV = "production"; + state.s3Configs.length = 0; + state.send.mockClear(); + state.insert.mockClear(); + }); + + it.each([ + "http://169.254.169.254", + "http://127.0.0.1:9000", + "http://localhost:9000", + "http://[::1]:9000", + ])("refuses to save %s on Cap Cloud", async (endpoint) => { + await expect(saveOrganizationS3Config(input(endpoint))).rejects.toThrow( + blockedS3EndpointMessage, + ); + expect(state.insert).not.toHaveBeenCalled(); + }); + + it.each([ + "http://169.254.169.254", + "http://10.0.0.5", + "http://minio.localhost", + ])("refuses to test %s on Cap Cloud", async (endpoint) => { + await expect(testOrganizationS3Config(input(endpoint))).rejects.toThrow( + blockedS3EndpointMessage, + ); + expect(state.s3Configs).toHaveLength(0); + expect(state.send).not.toHaveBeenCalled(); + }); + + it("saves public and default endpoints on Cap Cloud", async () => { + await expect( + saveOrganizationS3Config(input("https://8.8.8.8")), + ).resolves.toEqual({ success: true }); + await expect(saveOrganizationS3Config(input(""))).resolves.toEqual({ + success: true, + }); + expect(state.insert).toHaveBeenCalledTimes(2); + }); + + it("tests public endpoints through public-only agents on Cap Cloud", async () => { + await expect( + testOrganizationS3Config(input("https://8.8.8.8")), + ).resolves.toEqual({ success: true }); + + expect(state.s3Configs).toHaveLength(1); + const requestHandler = state.s3Configs[0]?.requestHandler as { + httpAgent?: unknown; + httpsAgent?: unknown; + }; + expect(requestHandler.httpAgent).toBeInstanceOf(HttpAgent); + expect(requestHandler.httpsAgent).toBeInstanceOf(HttpsAgent); + expect(state.send).toHaveBeenCalledTimes(1); + }); + + it.each([ + ["self-hosted", undefined, "production"], + ["development", "true", "development"], + ])("allows local endpoints on %s deployments", async (_, isCap, nodeEnv) => { + state.buildEnv.NEXT_PUBLIC_IS_CAP = isCap; + state.NODE_ENV = nodeEnv; + + await expect( + saveOrganizationS3Config(input("http://localhost:9000")), + ).resolves.toEqual({ success: true }); + await expect( + testOrganizationS3Config(input("http://192.168.1.10:9000")), + ).resolves.toEqual({ success: true }); + + expect(state.insert).toHaveBeenCalledTimes(1); + expect(state.s3Configs).toHaveLength(1); + expect(state.s3Configs[0]?.endpoint).toBe("http://192.168.1.10:9000"); + expect(state.s3Configs[0]?.requestHandler).toBeUndefined(); + }); +}); diff --git a/apps/web/__tests__/unit/proxy-frame-headers.test.ts b/apps/web/__tests__/unit/proxy-frame-headers.test.ts index a7dc56c98c7..30ac2a61cab 100644 --- a/apps/web/__tests__/unit/proxy-frame-headers.test.ts +++ b/apps/web/__tests__/unit/proxy-frame-headers.test.ts @@ -50,6 +50,11 @@ describe("proxy frame headers", () => { "/invite/invite-id", "/cli/authorize", "/mcp/authorize", + "/admin", + "/admin/replace-video", + "/admin/reprocess-video", + "/signup", + "/verify-otp", ])("sets frame headers on %s", async (path) => { const response = await send(`https://cap.so${path}`); expect(response.status).toBe(200); @@ -66,6 +71,9 @@ describe("proxy frame headers", () => { "/cli/complete", "/dashboards", "/invited", + "/administrator", + "/signups", + "/verify-otp-help", ])("leaves %s without frame headers", async (path) => { const response = await send(`https://cap.so${path}`); expectNoFrameHeaders(response); diff --git a/apps/web/__tests__/unit/s3-endpoint-policy.test.ts b/apps/web/__tests__/unit/s3-endpoint-policy.test.ts index fe03acc6a0d..92b46116268 100644 --- a/apps/web/__tests__/unit/s3-endpoint-policy.test.ts +++ b/apps/web/__tests__/unit/s3-endpoint-policy.test.ts @@ -1,4 +1,5 @@ import { Agent, createServer, get } from "node:http"; +import { Agent as HttpsAgent } from "node:https"; import type { AddressInfo } from "node:net"; import { beforeEach, describe, expect, it, vi } from "vitest"; @@ -11,8 +12,10 @@ vi.mock("@cap/env", () => env); import { isBlockedS3Endpoint, + isDisallowedS3Endpoint, isPrivateAddress, publicOnlyLookup, + publicOnlyS3Agents, shouldBlockPrivateS3Endpoints, } from "@/lib/s3-endpoint-policy"; @@ -156,3 +159,54 @@ describe("shouldBlockPrivateS3Endpoints", () => { expect(shouldBlockPrivateS3Endpoints()).toBe(false); }); }); + +describe("isDisallowedS3Endpoint", () => { + beforeEach(() => { + env.buildEnv.NEXT_PUBLIC_IS_CAP = "true"; + env.NODE_ENV = "production"; + }); + + it("blocks private endpoints on Cap Cloud production", async () => { + await expect(isDisallowedS3Endpoint("http://127.0.0.1:9000")).resolves.toBe( + true, + ); + await expect(isDisallowedS3Endpoint("https://8.8.8.8")).resolves.toBe( + false, + ); + }); + + it("leaves an empty endpoint to the AWS default", async () => { + await expect(isDisallowedS3Endpoint("")).resolves.toBe(false); + }); + + it("allows private endpoints outside Cap Cloud production", async () => { + env.buildEnv.NEXT_PUBLIC_IS_CAP = undefined; + await expect(isDisallowedS3Endpoint("http://localhost:9000")).resolves.toBe( + false, + ); + + env.buildEnv.NEXT_PUBLIC_IS_CAP = "true"; + env.NODE_ENV = "development"; + await expect(isDisallowedS3Endpoint("http://10.0.0.5:9000")).resolves.toBe( + false, + ); + }); +}); + +describe("publicOnlyS3Agents", () => { + beforeEach(() => { + env.buildEnv.NEXT_PUBLIC_IS_CAP = "true"; + env.NODE_ENV = "production"; + }); + + it("returns public-only agents on Cap Cloud production", () => { + const agents = publicOnlyS3Agents(); + expect(agents?.httpAgent).toBeInstanceOf(Agent); + expect(agents?.httpsAgent).toBeInstanceOf(HttpsAgent); + }); + + it("keeps the SDK default handler elsewhere", () => { + env.buildEnv.NEXT_PUBLIC_IS_CAP = undefined; + expect(publicOnlyS3Agents()).toBeUndefined(); + }); +}); diff --git a/apps/web/actions/organization/storage.ts b/apps/web/actions/organization/storage.ts index a3944803362..3ae86666291 100644 --- a/apps/web/actions/organization/storage.ts +++ b/apps/web/actions/organization/storage.ts @@ -25,6 +25,11 @@ import { import { type Organisation, S3Bucket, Storage } from "@cap/web-domain"; import { and, desc, eq, isNull } from "drizzle-orm"; import { revalidatePath } from "next/cache"; +import { + blockedS3EndpointMessage, + isDisallowedS3Endpoint, + publicOnlyS3Agents, +} from "@/lib/s3-endpoint-policy"; import { runPromise } from "@/lib/server"; import { requireOrganizationSettingsManager } from "./authorization"; @@ -384,6 +389,9 @@ export async function saveOrganizationS3Config(input: S3ConfigInput) { const { user } = await requireOrganizationStorageManagerPro( input.organizationId, ); + if (await isDisallowedS3Endpoint(input.endpoint)) { + throw new Error(blockedS3EndpointMessage); + } const credentials = await getS3InputCredentials(input); const encryptedConfig = { provider: input.provider, @@ -426,6 +434,9 @@ export async function removeOrganizationS3Config( export async function testOrganizationS3Config(input: S3ConfigInput) { await requireOrganizationStorageManagerPro(input.organizationId); + if (await isDisallowedS3Endpoint(input.endpoint)) { + throw new Error(blockedS3EndpointMessage); + } const credentials = await getS3InputCredentials(input); const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 5000); @@ -436,6 +447,7 @@ export async function testOrganizationS3Config(input: S3ConfigInput) { accessKeyId: credentials.accessKeyId, secretAccessKey: credentials.secretAccessKey, }, + requestHandler: publicOnlyS3Agents(), }); try { diff --git a/apps/web/app/api/desktop/[...route]/s3Config.ts b/apps/web/app/api/desktop/[...route]/s3Config.ts index 496ed72400e..fba622ce630 100644 --- a/apps/web/app/api/desktop/[...route]/s3Config.ts +++ b/apps/web/app/api/desktop/[...route]/s3Config.ts @@ -1,5 +1,3 @@ -import { Agent as HttpAgent } from "node:http"; -import { Agent as HttpsAgent } from "node:https"; import { HeadBucketCommand, S3Client } from "@aws-sdk/client-s3"; import { db } from "@cap/database"; import { decrypt, encrypt } from "@cap/database/crypto"; @@ -12,9 +10,9 @@ import { Hono } from "hono"; import { z } from "zod"; import { withAuth } from "@/app/api/utils"; import { - isBlockedS3Endpoint, - publicOnlyLookup, - shouldBlockPrivateS3Endpoints, + blockedS3EndpointMessage, + isDisallowedS3Endpoint, + publicOnlyS3Agents, } from "@/lib/s3-endpoint-policy"; import { getAccessibleOrganization, @@ -65,16 +63,10 @@ const getS3ErrorMetadata = (error: unknown) => { }; const blockedEndpointResponse = { - error: - "Invalid endpoint. Please provide a public S3-compatible endpoint URL.", + error: blockedS3EndpointMessage, details: "The provided endpoint is not allowed.", }; -const isDisallowedEndpoint = async (endpoint: string) => - Boolean(endpoint) && - shouldBlockPrivateS3Endpoints() && - (await isBlockedS3Endpoint(endpoint)); - app.post( "/", zValidator( @@ -92,7 +84,7 @@ app.post( const user = c.get("user"); const data = c.req.valid("json"); - if (await isDisallowedEndpoint(data.endpoint)) { + if (await isDisallowedS3Endpoint(data.endpoint)) { return c.json(blockedEndpointResponse, { status: 400 }); } @@ -249,8 +241,7 @@ app.post( const TIMEOUT_MS = 5000; // 5 second timeout const data = c.req.valid("json"); - const blockPrivateAddresses = shouldBlockPrivateS3Endpoints(); - if (await isDisallowedEndpoint(data.endpoint)) { + if (await isDisallowedS3Endpoint(data.endpoint)) { return c.json( { ...blockedEndpointResponse, metadata: undefined }, { status: 400 }, @@ -270,13 +261,10 @@ app.post( accessKeyId: data.accessKeyId, secretAccessKey: data.secretAccessKey, }, - requestHandler: blockPrivateAddresses - ? { - abortSignal: controller.signal, - httpAgent: new HttpAgent({ lookup: publicOnlyLookup }), - httpsAgent: new HttpsAgent({ lookup: publicOnlyLookup }), - } - : { abortSignal: controller.signal }, + requestHandler: { + abortSignal: controller.signal, + ...publicOnlyS3Agents(), + }, }); try { diff --git a/apps/web/app/api/v1/[...route]/route.ts b/apps/web/app/api/v1/[...route]/route.ts index aa3ccf2aa13..70cbf08f52d 100644 --- a/apps/web/app/api/v1/[...route]/route.ts +++ b/apps/web/app/api/v1/[...route]/route.ts @@ -126,6 +126,11 @@ import { } from "@/lib/permissions/roles"; import { normalizePlaybackSpeed } from "@/lib/playback-speed"; import { isRateLimited, RATE_LIMIT_IDS } from "@/lib/rate-limit"; +import { + blockedS3EndpointMessage, + isDisallowedS3Endpoint, + publicOnlyS3Agents, +} from "@/lib/s3-endpoint-policy"; import { transcribeVideo } from "@/lib/transcribe"; import { startVideoProcessingWorkflow } from "@/lib/video-processing"; import { isVideoStorageKey } from "@/lib/video-storage-key"; @@ -1336,6 +1341,9 @@ const resolveAgentS3Config = Effect.fn("Agent.resolveS3Config")(function* ( } catch { return yield* badRequest(requestId, "S3 endpoint is invalid"); } + if (yield* Effect.promise(() => isDisallowedS3Endpoint(endpoint))) { + return yield* badRequest(requestId, blockedS3EndpointMessage); + } } let accessKeyId = payload.accessKeyId.trim(); let secretAccessKey = payload.secretAccessKey.trim(); @@ -1412,6 +1420,7 @@ const testAgentS3Config = Effect.fn("Agent.testS3Config")(function* ( accessKeyId: config.accessKeyId, secretAccessKey: config.secretAccessKey, }, + requestHandler: publicOnlyS3Agents(), }); await client.send( new HeadBucketCommand({ Bucket: config.bucketName }), diff --git a/apps/web/lib/s3-endpoint-policy.ts b/apps/web/lib/s3-endpoint-policy.ts index 0306255fd99..f30b64a45fe 100644 --- a/apps/web/lib/s3-endpoint-policy.ts +++ b/apps/web/lib/s3-endpoint-policy.ts @@ -1,5 +1,7 @@ import { lookup as dnsLookup, type LookupAddress } from "node:dns"; import { lookup } from "node:dns/promises"; +import { Agent as HttpAgent } from "node:http"; +import { Agent as HttpsAgent } from "node:https"; import { BlockList, isIP, type LookupFunction } from "node:net"; import { buildEnv, NODE_ENV } from "@cap/env"; @@ -114,3 +116,19 @@ export const publicOnlyLookup: LookupFunction = ( callback(null, address, family); }); }; + +export const blockedS3EndpointMessage = + "Invalid endpoint. Please provide a public S3-compatible endpoint URL."; + +export const isDisallowedS3Endpoint = async (endpoint: string) => + Boolean(endpoint) && + shouldBlockPrivateS3Endpoints() && + (await isBlockedS3Endpoint(endpoint)); + +export const publicOnlyS3Agents = () => + shouldBlockPrivateS3Endpoints() + ? { + httpAgent: new HttpAgent({ lookup: publicOnlyLookup }), + httpsAgent: new HttpsAgent({ lookup: publicOnlyLookup }), + } + : undefined; diff --git a/apps/web/proxy.ts b/apps/web/proxy.ts index 859667cd95b..80acfcc16e7 100644 --- a/apps/web/proxy.ts +++ b/apps/web/proxy.ts @@ -41,6 +41,9 @@ const FRAME_PROTECTED_PATHS = [ "/invite", "/cli/authorize", "/mcp/authorize", + "/admin", + "/signup", + "/verify-otp", ]; const isFrameProtectedPath = (path: string) =>