History

Harden hosted outbound requests and org limits

Rhys Sullivan committed 6 months ago

Commit 82e014c1 parent 545fb9a6 files · +261 −24Browse files

Unified · Split

6 files
  1. apps/cloud/src/auth/handlers.ts +7 −0
  2. apps/cloud/src/org/handlers.ts +4 −14
  3. apps/cloud/src/org/member-limits.node.test.ts +38 −0
  4. apps/cloud/src/org/member-limits.ts +25 −0
  5. packages/core/sdk/src/hosted-http-client.test.ts +83 −7
  6. packages/core/sdk/src/hosted-http-client.ts +104 −3
apps/cloud/src/auth/handlers.ts+7−0
@@ -47,6 +47,8 @@ const DELETE_COOKIE_OPTIONS = { · 46 unmodified lines
4747 secure: true,
4848 };
4949
50+const MAX_ORGANIZATIONS_PER_USER = 3;
51+
5052 const randomState = (): string => {
5153 const bytes = new Uint8Array(32);
5254 crypto.getRandomValues(bytes);
@@ -245,6 +247,11 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( · 192 unmodified lines
245247 const session = yield* SessionContext;
246248
247249 const name = payload.name.trim();
250+ const memberships = yield* workos.listUserMemberships(session.accountId);
251+ if (memberships.data.length >= MAX_ORGANIZATIONS_PER_USER) {
252+ return yield* new WorkOSError();
253+ }
254+
248255 const org = yield* workos.createOrganization(name);
249256 yield* workos.createMembership(org.id, session.accountId, "admin");
250257 yield* users.use((s) => s.upsertOrganization({ id: org.id, name: org.name }));
apps/cloud/src/org/handlers.ts+4−14
@@ -8,6 +8,7 @@ import { WorkOSAuth } from "../auth/workos"; · 7 unmodified lines
88 import { AutumnService } from "../services/autumn";
99 import { OrgHttpApi } from "./compose";
1010 import { Forbidden } from "./api";
11+import { getMemberLimitForPlan, selectActiveMemberLimitPlan } from "./member-limits";
1112
1213 const requireAdmin = Effect.gen(function* () {
1314 const auth = yield* AuthContext;
@@ -52,17 +53,6 @@ const assertDomainInSessionOrg = (domainId: string) => · 38 unmodified lines
5253 }
5354 });
5455
55−// Per-plan member limits live in code, not Autumn. Members aren't billable
56−// usage — they're a permission attached to the plan. We still ask Autumn
57−// which plan the org is on (Autumn is the billing source of truth), but
58−// the cap itself is a constant here. `null` = unlimited.
59−const MEMBER_LIMITS: Record<string, number | null> = {
60− free: 3,
61− "free-pay-as-you-go": 3,
62− team: null,
63−};
64−const DEFAULT_MEMBER_LIMIT = 3;
65−
6656 // Compute live seat usage from WorkOS truth (active+pending memberships +
6757 // pending invitations) and look up the per-plan cap from MEMBER_LIMITS.
6858 // Recomputed on every call — no event-counting drift.
@@ -74,8 +64,8 @@ const getMemberSeats = (organizationId: string) => · 5 unmodified lines
7464 const customer = yield* autumn.use((client) =>
7565 client.customers.getOrCreate({ customerId: organizationId }),
7666 );
77− const planId = customer.subscriptions[0]?.planId ?? "free";
78− const limit = planId in MEMBER_LIMITS ? MEMBER_LIMITS[planId] : DEFAULT_MEMBER_LIMIT;
67+ const planId = selectActiveMemberLimitPlan(customer.subscriptions);
68+ const limit = getMemberLimitForPlan(planId);
7969
8070 const memberships = yield* workos.listOrgMembers(organizationId);
8171 const invitations = yield* workos.listPendingInvitations(organizationId);
@@ -258,7 +248,7 @@ export const OrgHandlers = HttpApiBuilder.group(OrgHttpApi, "org", (handlers) => · 176 unmodified lines
258248 featureId: "domain-verification",
259249 }),
260250 )
261− .pipe(Effect.orElseSucceed(() => ({ allowed: true })));
251+ .pipe(Effect.orElseSucceed(() => ({ allowed: false })));
262252
263253 if (!check.allowed) {
264254 return yield* new Forbidden();
apps/cloud/src/org/member-limits.node.test.ts+38−0
@@ -0,0 +1,38 @@
1+import { describe, expect, it } from "@effect/vitest";
2+
3+import { getMemberLimitForPlan, selectActiveMemberLimitPlan } from "./member-limits";
4+
5+describe("member limits", () => {
6+ it("uses an active or trialing subscription before older entries", () => {
7+ expect(
8+ selectActiveMemberLimitPlan([
9+ { planId: "free", status: "canceled" },
10+ { planId: "team", status: "trialing" },
11+ ]),
12+ ).toBe("team");
13+
14+ expect(
15+ selectActiveMemberLimitPlan([
16+ { planId: "free", status: "past_due" },
17+ { planId: "team", status: "active" },
18+ ]),
19+ ).toBe("team");
20+ });
21+
22+ it("falls back to the first subscription, then the free plan", () => {
23+ expect(
24+ selectActiveMemberLimitPlan([
25+ { planId: "free-pay-as-you-go", status: "canceled" },
26+ { planId: "team", status: "incomplete" },
27+ ]),
28+ ).toBe("free-pay-as-you-go");
29+
30+ expect(selectActiveMemberLimitPlan([])).toBe("free");
31+ });
32+
33+ it("resolves member limits from the selected plan", () => {
34+ expect(getMemberLimitForPlan("free")).toBe(3);
35+ expect(getMemberLimitForPlan("team")).toBeNull();
36+ expect(getMemberLimitForPlan("unknown")).toBe(3);
37+ });
38+});
apps/cloud/src/org/member-limits.ts+25−0
@@ -0,0 +1,25 @@
1+const MEMBER_LIMITS: Record<string, number | null> = {
2+ free: 3,
3+ "free-pay-as-you-go": 3,
4+ team: null,
5+};
6+
7+export const DEFAULT_MEMBER_LIMIT = 3;
8+
9+export type AutumnSubscriptionSummary = {
10+ readonly planId?: string | null;
11+ readonly status?: string | null;
12+};
13+
14+export const selectActiveMemberLimitPlan = (
15+ subscriptions: ReadonlyArray<AutumnSubscriptionSummary>,
16+): string => {
17+ const active =
18+ subscriptions.find((subscription) =>
19+ ["active", "trialing"].includes(subscription.status ?? ""),
20+ ) ?? subscriptions[0];
21+ return active?.planId ?? "free";
22+};
23+
24+export const getMemberLimitForPlan = (planId: string): number | null =>
25+ planId in MEMBER_LIMITS ? MEMBER_LIMITS[planId] : DEFAULT_MEMBER_LIMIT;
packages/core/sdk/src/hosted-http-client.test.ts+83−7
@@ -1,12 +1,8 @@
11 import { describe, expect, it } from "@effect/vitest";
2−import { Effect, Result } from "effect";
2+import { Effect, Predicate, Result } from "effect";
33 import { HttpClient, HttpClientRequest } from "effect/unstable/http";
44
5−import {
6− HostedOutboundRequestBlocked,
7− makeHostedHttpClientLayer,
8− validateHostedOutboundUrl,
9−} from "./hosted-http-client";
5+import { makeHostedHttpClientLayer, validateHostedOutboundUrl } from "./hosted-http-client";
106
117 describe("hosted outbound HTTP client", () => {
128 it.effect("allows public HTTP and HTTPS URLs", () =>
@@ -27,7 +23,22 @@ describe("hosted outbound HTTP client", () => { · 14 unmodified lines
2723 "http://169.254.169.254/latest/meta-data/",
2824 ]) {
2925 const error = yield* validateHostedOutboundUrl(url).pipe(Effect.flip);
30− expect(error).toBeInstanceOf(HostedOutboundRequestBlocked);
26+ expect(Predicate.isTagged(error, "HostedOutboundRequestBlocked")).toBe(true);
27+ }
28+ }),
29+ );
30+
31+ it.effect("rejects IPv4-mapped IPv6 URLs for local and private networks", () =>
32+ Effect.gen(function* () {
33+ for (const url of [
34+ "http://[::ffff:127.0.0.1]:3000",
35+ "http://[::ffff:10.0.0.1]/openapi.json",
36+ "http://[::ffff:172.16.0.1]/graphql",
37+ "http://[::ffff:192.168.1.10]/mcp",
38+ "http://[::ffff:169.254.169.254]/latest/meta-data/",
39+ ]) {
40+ const error = yield* validateHostedOutboundUrl(url).pipe(Effect.flip);
41+ expect(Predicate.isTagged(error, "HostedOutboundRequestBlocked")).toBe(true);
3142 }
3243 }),
3344 );
@@ -63,4 +74,69 @@ describe("hosted outbound HTTP client", () => { · 29 unmodified lines
6374 expect(calls).toBe(1);
6475 }),
6576 );
77+
78+ it.effect("rejects cross-origin redirects before following them", () =>
79+ Effect.gen(function* () {
80+ let calls = 0;
81+ const fakeFetch: typeof globalThis.fetch = (async (input) => {
82+ calls++;
83+ const url = input instanceof Request ? input.url : String(input);
84+ if (url === "https://api.example/start") {
85+ return new Response(null, {
86+ status: 302,
87+ headers: { location: "https://elsewhere.example/next" },
88+ });
89+ }
90+ return new Response("unexpected", { status: 200 });
91+ }) as typeof globalThis.fetch;
92+
93+ const result = yield* Effect.gen(function* () {
94+ const client = yield* HttpClient.HttpClient;
95+ return yield* client.execute(HttpClientRequest.get("https://api.example/start"));
96+ }).pipe(Effect.provide(makeHostedHttpClientLayer({ fetch: fakeFetch })), Effect.result);
97+
98+ expect(Result.isFailure(result)).toBe(true);
99+ expect(calls).toBe(1);
100+ }),
101+ );
102+
103+ it.effect("rejects responses with content-length beyond the configured limit", () =>
104+ Effect.gen(function* () {
105+ const fakeFetch: typeof globalThis.fetch = (async () =>
106+ new Response("too large", {
107+ status: 200,
108+ headers: { "content-length": "9" },
109+ })) as typeof globalThis.fetch;
110+
111+ const result = yield* Effect.gen(function* () {
112+ const client = yield* HttpClient.HttpClient;
113+ return yield* client.execute(HttpClientRequest.get("https://public.example/spec"));
114+ }).pipe(
115+ Effect.provide(makeHostedHttpClientLayer({ fetch: fakeFetch, maxResponseBytes: 8 })),
116+ Effect.result,
117+ );
118+
119+ expect(Result.isFailure(result)).toBe(true);
120+ }),
121+ );
122+
123+ it.effect("rejects streamed responses that exceed the configured limit", () =>
124+ Effect.gen(function* () {
125+ const fakeFetch: typeof globalThis.fetch = (async () =>
126+ new Response("too large", { status: 200 })) as typeof globalThis.fetch;
127+
128+ const result = yield* Effect.gen(function* () {
129+ const client = yield* HttpClient.HttpClient;
130+ const response = yield* client.execute(
131+ HttpClientRequest.get("https://public.example/spec"),
132+ );
133+ return yield* response.text;
134+ }).pipe(
135+ Effect.provide(makeHostedHttpClientLayer({ fetch: fakeFetch, maxResponseBytes: 8 })),
136+ Effect.result,
137+ );
138+
139+ expect(Result.isFailure(result)).toBe(true);
140+ }),
141+ );
66142 });
packages/core/sdk/src/hosted-http-client.ts+104−3
@@ -12,9 +12,12 @@ export class HostedOutboundRequestBlocked extends Schema.TaggedErrorClass<Hosted · 11 unmodified lines
1212 export interface HostedHttpClientOptions {
1313 readonly allowLocalNetwork?: boolean;
1414 readonly maxRedirects?: number;
15+ readonly maxResponseBytes?: number;
1516 readonly fetch?: typeof globalThis.fetch;
1617 }
1718
19+const DEFAULT_MAX_RESPONSE_BYTES = 5 * 1024 * 1024;
20+
1821 const parseIpv4 = (hostname: string): readonly [number, number, number, number] | null => {
1922 const parts = hostname.split(".");
2023 if (parts.length !== 4) return null;
@@ -28,6 +31,36 @@ const parseIpv4 = (hostname: string): readonly [number, number, number, number] · 7 unmodified lines
2831 return parsed as [number, number, number, number];
2932 };
3033
34+const parseIpv4MappedIpv6 = (
35+ hostname: string,
36+): readonly [number, number, number, number] | null => {
37+ const prefix = "::ffff:";
38+ if (!hostname.startsWith(prefix)) return null;
39+ const embedded = hostname.slice(prefix.length);
40+ const dotted = parseIpv4(embedded);
41+ if (dotted) return dotted;
42+
43+ const parts = embedded.split(":");
44+ if (parts.length !== 2) return null;
45+
46+ const words = parts.map((part) => Number.parseInt(part, 16));
47+ if (
48+ words.some(
49+ (word, index) =>
50+ parts[index] === "" ||
51+ !/^[0-9a-f]+$/i.test(parts[index]) ||
52+ !Number.isInteger(word) ||
53+ word < 0 ||
54+ word > 0xffff,
55+ )
56+ ) {
57+ return null;
58+ }
59+
60+ const [high, low] = words;
61+ return [high >> 8, high & 0xff, low >> 8, low & 0xff];
62+};
63+
3164 const isPrivateIpv4 = ([a, b]: readonly [number, number, number, number]): boolean =>
3265 a === 0 ||
3366 a === 10 ||
@@ -51,6 +84,8 @@ const isLocalOrPrivateHostname = (hostname: string): boolean => { · 17 unmodified lines
5184 if (normalized === "localhost" || normalized.endsWith(".localhost")) return true;
5285 const ipv4 = parseIpv4(normalized);
5386 if (ipv4) return isPrivateIpv4(ipv4);
87+ const mappedIpv4 = parseIpv4MappedIpv6(normalized);
88+ if (mappedIpv4) return isPrivateIpv4(mappedIpv4);
5489 return (
5590 normalized === "::1" ||
5691 normalized.startsWith("fe80:") ||
@@ -112,14 +147,80 @@ const guardFetch = ( · 55 unmodified lines
112147 response.headers.has("location") &&
113148 redirects < maxRedirects
114149 ) {
115− current = new URL(response.headers.get("location")!, url).toString();
150+ const next = new URL(response.headers.get("location")!, url);
151+ if (next.origin !== new URL(url).origin) {
152+ // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: fetch-compatible adapter must reject blocked requests
153+ throw new HostedOutboundRequestBlocked({
154+ url: next.toString(),
155+ reason: "Cross-origin redirects are not allowed",
156+ });
157+ }
158+ current = next.toString();
116159 continue;
117160 }
118− return response;
161+ return guardResponseBody(
162+ response,
163+ url,
164+ options.maxResponseBytes ?? DEFAULT_MAX_RESPONSE_BYTES,
165+ );
119166 }
120− return underlying(current, { ...init, redirect: "manual" });
167+ const url = current instanceof Request ? current.url : String(current);
168+ const response = await underlying(current, { ...init, redirect: "manual" });
169+ return guardResponseBody(response, url, options.maxResponseBytes ?? DEFAULT_MAX_RESPONSE_BYTES);
121170 }) as typeof globalThis.fetch;
122171
172+const guardResponseBody = (response: Response, url: string, maxResponseBytes: number): Response => {
173+ if (!Number.isFinite(maxResponseBytes) || maxResponseBytes <= 0 || !response.body) {
174+ return response;
175+ }
176+
177+ const contentLength = response.headers.get("content-length");
178+ if (contentLength !== null) {
179+ const parsed = Number(contentLength);
180+ if (Number.isFinite(parsed) && parsed > maxResponseBytes) {
181+ // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: fetch-compatible response adapter must reject oversized responses
182+ throw new HostedOutboundRequestBlocked({
183+ url,
184+ reason: "Response body is too large",
185+ });
186+ }
187+ }
188+
189+ let total = 0;
190+ const reader = response.body.getReader();
191+ const limitedBody = new ReadableStream<Uint8Array>({
192+ async pull(controller) {
193+ const next = await reader.read();
194+ if (next.done) {
195+ controller.close();
196+ return;
197+ }
198+
199+ total += next.value.byteLength;
200+ if (total > maxResponseBytes) {
201+ controller.error(
202+ new HostedOutboundRequestBlocked({
203+ url,
204+ reason: "Response body is too large",
205+ }),
206+ );
207+ return;
208+ }
209+
210+ controller.enqueue(next.value);
211+ },
212+ cancel(reason) {
213+ return reader.cancel(reason);
214+ },
215+ });
216+
217+ return new Response(limitedBody, {
218+ status: response.status,
219+ statusText: response.statusText,
220+ headers: response.headers,
221+ });
222+};
223+
123224 export const makeHostedHttpClientLayer = (
124225 options: HostedHttpClientOptions = {},
125226 ): Layer.Layer<HttpClient.HttpClient> =>