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
47 secure: true,47 secure: true,
48};48};
49 49
50const MAX_ORGANIZATIONS_PER_USER = 3;
51
50const randomState = (): string => {52const randomState = (): string => {
51 const bytes = new Uint8Array(32);53 const bytes = new Uint8Array(32);
52 crypto.getRandomValues(bytes);54 crypto.getRandomValues(bytes);
@@ -245,6 +247,11 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( · 192 unmodified lines
245 const session = yield* SessionContext;247 const session = yield* SessionContext;
246 248
247 const name = payload.name.trim();249 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
248 const org = yield* workos.createOrganization(name);255 const org = yield* workos.createOrganization(name);
249 yield* workos.createMembership(org.id, session.accountId, "admin");256 yield* workos.createMembership(org.id, session.accountId, "admin");
250 yield* users.use((s) => s.upsertOrganization({ id: org.id, name: org.name }));257 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
8import { AutumnService } from "../services/autumn";8import { AutumnService } from "../services/autumn";
9import { OrgHttpApi } from "./compose";9import { OrgHttpApi } from "./compose";
10import { Forbidden } from "./api";10import { Forbidden } from "./api";
11import { getMemberLimitForPlan, selectActiveMemberLimitPlan } from "./member-limits";
11 12
12const requireAdmin = Effect.gen(function* () {13const requireAdmin = Effect.gen(function* () {
13 const auth = yield* AuthContext;14 const auth = yield* AuthContext;
@@ -52,17 +53,6 @@ const assertDomainInSessionOrg = (domainId: string) => · 38 unmodified lines
52 }53 }
53 });54 });
54 55
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.
59const MEMBER_LIMITS: Record<string, number | null> = {
60 free: 3,
61 "free-pay-as-you-go": 3,
62 team: null,
63};
64const DEFAULT_MEMBER_LIMIT = 3;
65
66// Compute live seat usage from WorkOS truth (active+pending memberships +56// Compute live seat usage from WorkOS truth (active+pending memberships +
67// pending invitations) and look up the per-plan cap from MEMBER_LIMITS.57// pending invitations) and look up the per-plan cap from MEMBER_LIMITS.
68// Recomputed on every call — no event-counting drift.58// Recomputed on every call — no event-counting drift.
@@ -74,8 +64,8 @@ const getMemberSeats = (organizationId: string) => · 5 unmodified lines
74 const customer = yield* autumn.use((client) =>64 const customer = yield* autumn.use((client) =>
75 client.customers.getOrCreate({ customerId: organizationId }),65 client.customers.getOrCreate({ customerId: organizationId }),
76 );66 );
77 const planId = customer.subscriptions[0]?.planId ?? "free";67 const planId = selectActiveMemberLimitPlan(customer.subscriptions);
78 const limit = planId in MEMBER_LIMITS ? MEMBER_LIMITS[planId] : DEFAULT_MEMBER_LIMIT;68 const limit = getMemberLimitForPlan(planId);
79 69
80 const memberships = yield* workos.listOrgMembers(organizationId);70 const memberships = yield* workos.listOrgMembers(organizationId);
81 const invitations = yield* workos.listPendingInvitations(organizationId);71 const invitations = yield* workos.listPendingInvitations(organizationId);
@@ -258,7 +248,7 @@ export const OrgHandlers = HttpApiBuilder.group(OrgHttpApi, "org", (handlers) => · 176 unmodified lines
258 featureId: "domain-verification",248 featureId: "domain-verification",
259 }),249 }),
260 )250 )
261 .pipe(Effect.orElseSucceed(() => ({ allowed: true })));251 .pipe(Effect.orElseSucceed(() => ({ allowed: false })));
262 252
263 if (!check.allowed) {253 if (!check.allowed) {
264 return yield* new Forbidden();254 return yield* new Forbidden();
apps/cloud/src/org/member-limits.node.test.ts+38−0
@@ -0,0 +1,38 @@
1import { describe, expect, it } from "@effect/vitest";
2
3import { getMemberLimitForPlan, selectActiveMemberLimitPlan } from "./member-limits";
4
5describe("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 @@
1const MEMBER_LIMITS: Record<string, number | null> = {
2 free: 3,
3 "free-pay-as-you-go": 3,
4 team: null,
5};
6
7export const DEFAULT_MEMBER_LIMIT = 3;
8
9export type AutumnSubscriptionSummary = {
10 readonly planId?: string | null;
11 readonly status?: string | null;
12};
13
14export 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
24export 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 @@
1import { describe, expect, it } from "@effect/vitest";1import { describe, expect, it } from "@effect/vitest";
2import { Effect, Result } from "effect";2import { Effect, Predicate, Result } from "effect";
3import { HttpClient, HttpClientRequest } from "effect/unstable/http";3import { HttpClient, HttpClientRequest } from "effect/unstable/http";
4 4
5import {5import { makeHostedHttpClientLayer, validateHostedOutboundUrl } from "./hosted-http-client";
6 HostedOutboundRequestBlocked,
7 makeHostedHttpClientLayer,
8 validateHostedOutboundUrl,
9} from "./hosted-http-client";
10 6
11describe("hosted outbound HTTP client", () => {7describe("hosted outbound HTTP client", () => {
12 it.effect("allows public HTTP and HTTPS URLs", () =>8 it.effect("allows public HTTP and HTTPS URLs", () =>
@@ -27,7 +23,22 @@ describe("hosted outbound HTTP client", () => { · 14 unmodified lines
27 "http://169.254.169.254/latest/meta-data/",23 "http://169.254.169.254/latest/meta-data/",
28 ]) {24 ]) {
29 const error = yield* validateHostedOutboundUrl(url).pipe(Effect.flip);25 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);
31 }42 }
32 }),43 }),
33 );44 );
@@ -63,4 +74,69 @@ describe("hosted outbound HTTP client", () => { · 29 unmodified lines
63 expect(calls).toBe(1);74 expect(calls).toBe(1);
64 }),75 }),
65 );76 );
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 );
66});142});
packages/core/sdk/src/hosted-http-client.ts+104−3
@@ -12,9 +12,12 @@ export class HostedOutboundRequestBlocked extends Schema.TaggedErrorClass<Hosted · 11 unmodified lines
12export interface HostedHttpClientOptions {12export interface HostedHttpClientOptions {
13 readonly allowLocalNetwork?: boolean;13 readonly allowLocalNetwork?: boolean;
14 readonly maxRedirects?: number;14 readonly maxRedirects?: number;
15 readonly maxResponseBytes?: number;
15 readonly fetch?: typeof globalThis.fetch;16 readonly fetch?: typeof globalThis.fetch;
16}17}
17 18
19const DEFAULT_MAX_RESPONSE_BYTES = 5 * 1024 * 1024;
20
18const parseIpv4 = (hostname: string): readonly [number, number, number, number] | null => {21const parseIpv4 = (hostname: string): readonly [number, number, number, number] | null => {
19 const parts = hostname.split(".");22 const parts = hostname.split(".");
20 if (parts.length !== 4) return null;23 if (parts.length !== 4) return null;
@@ -28,6 +31,36 @@ const parseIpv4 = (hostname: string): readonly [number, number, number, number] · 7 unmodified lines
28 return parsed as [number, number, number, number];31 return parsed as [number, number, number, number];
29};32};
30 33
34const 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
31const isPrivateIpv4 = ([a, b]: readonly [number, number, number, number]): boolean =>64const isPrivateIpv4 = ([a, b]: readonly [number, number, number, number]): boolean =>
32 a === 0 ||65 a === 0 ||
33 a === 10 ||66 a === 10 ||
@@ -51,6 +84,8 @@ const isLocalOrPrivateHostname = (hostname: string): boolean => { · 17 unmodified lines
51 if (normalized === "localhost" || normalized.endsWith(".localhost")) return true;84 if (normalized === "localhost" || normalized.endsWith(".localhost")) return true;
52 const ipv4 = parseIpv4(normalized);85 const ipv4 = parseIpv4(normalized);
53 if (ipv4) return isPrivateIpv4(ipv4);86 if (ipv4) return isPrivateIpv4(ipv4);
87 const mappedIpv4 = parseIpv4MappedIpv6(normalized);
88 if (mappedIpv4) return isPrivateIpv4(mappedIpv4);
54 return (89 return (
55 normalized === "::1" ||90 normalized === "::1" ||
56 normalized.startsWith("fe80:") ||91 normalized.startsWith("fe80:") ||
@@ -112,14 +147,80 @@ const guardFetch = ( · 55 unmodified lines
112 response.headers.has("location") &&147 response.headers.has("location") &&
113 redirects < maxRedirects148 redirects < maxRedirects
114 ) {149 ) {
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();
116 continue;159 continue;
117 }160 }
118 return response;161 return guardResponseBody(
162 response,
163 url,
164 options.maxResponseBytes ?? DEFAULT_MAX_RESPONSE_BYTES,
165 );
119 }166 }
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);
121 }) as typeof globalThis.fetch;170 }) as typeof globalThis.fetch;
122 171
172const 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
123export const makeHostedHttpClientLayer = (224export const makeHostedHttpClientLayer = (
124 options: HostedHttpClientOptions = {},225 options: HostedHttpClientOptions = {},
125): Layer.Layer<HttpClient.HttpClient> =>226): Layer.Layer<HttpClient.HttpClient> =>