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
202 changes: 202 additions & 0 deletions apps/web/__tests__/unit/agent-s3-endpoint-policy.test.ts
Original file line number Diff line number Diff line change
@@ -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<Record<string, unknown>>,
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<typeof import("@cap/env")>();
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<typeof import("@aws-sdk/client-s3")>();
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<string, unknown>);
this.send = state.send as unknown as S3Client["send"];
}
}
}
return { ...actual, S3Client };
});

vi.mock("@/lib/agent-write", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/agent-write")>();
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<typeof import("@cap/database")>();
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);
});
});
184 changes: 184 additions & 0 deletions apps/web/__tests__/unit/organization-storage-s3-endpoint.test.ts
Original file line number Diff line number Diff line change
@@ -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<Record<string, unknown>>,
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<string, unknown>) {
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<unknown>,
) => Promise<unknown>;
};

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();
});
});
Loading
Loading