Unified · Split
| @@ -47,6 +47,8 @@ const DELETE_COOKIE_OPTIONS = { · 46 unmodified lines | |||
| 47 | secure: true, | 47 | secure: true, |
| 48 | }; | 48 | }; |
| 49 | 49 | ||
| 50 | const MAX_ORGANIZATIONS_PER_USER = 3; | ||
| 51 | |||
| 50 | const randomState = (): string => { | 52 | const 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 })); |
| @@ -8,6 +8,7 @@ import { WorkOSAuth } from "../auth/workos"; · 7 unmodified lines | |||
| 8 | import { AutumnService } from "../services/autumn"; | 8 | import { AutumnService } from "../services/autumn"; |
| 9 | import { OrgHttpApi } from "./compose"; | 9 | import { OrgHttpApi } from "./compose"; |
| 10 | import { Forbidden } from "./api"; | 10 | import { Forbidden } from "./api"; |
| 11 | import { getMemberLimitForPlan, selectActiveMemberLimitPlan } from "./member-limits"; | ||
| 11 | 12 | ||
| 12 | const requireAdmin = Effect.gen(function* () { | 13 | const 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. | ||
| 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 | |||
| 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(); |
| @@ -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 | }); | ||
| @@ -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; | ||
| @@ -1,12 +1,8 @@ | |||
| 1 | import { describe, expect, it } from "@effect/vitest"; | 1 | import { describe, expect, it } from "@effect/vitest"; |
| 2 | import { Effect, Result } from "effect"; | 2 | import { Effect, Predicate, Result } from "effect"; |
| 3 | import { HttpClient, HttpClientRequest } from "effect/unstable/http"; | 3 | import { HttpClient, HttpClientRequest } from "effect/unstable/http"; |
| 4 | 4 | ||
| 5 | import { | 5 | import { makeHostedHttpClientLayer, validateHostedOutboundUrl } from "./hosted-http-client"; |
| 6 | HostedOutboundRequestBlocked, | ||
| 7 | makeHostedHttpClientLayer, | ||
| 8 | validateHostedOutboundUrl, | ||
| 9 | } from "./hosted-http-client"; | ||
| 10 | 6 | ||
| 11 | describe("hosted outbound HTTP client", () => { | 7 | describe("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 | }); |
| @@ -12,9 +12,12 @@ export class HostedOutboundRequestBlocked extends Schema.TaggedErrorClass<Hosted · 11 unmodified lines | |||
| 12 | export interface HostedHttpClientOptions { | 12 | export 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 | ||
| 19 | const DEFAULT_MAX_RESPONSE_BYTES = 5 * 1024 * 1024; | ||
| 20 | |||
| 18 | const parseIpv4 = (hostname: string): readonly [number, number, number, number] | null => { | 21 | const 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 | ||
| 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 | |||
| 31 | const isPrivateIpv4 = ([a, b]: readonly [number, number, number, number]): boolean => | 64 | const 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 < maxRedirects | 148 | 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 | ||
| 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 | |||
| 123 | export const makeHostedHttpClientLayer = ( | 224 | export const makeHostedHttpClientLayer = ( |
| 124 | options: HostedHttpClientOptions = {}, | 225 | options: HostedHttpClientOptions = {}, |
| 125 | ): Layer.Layer<HttpClient.HttpClient> => | 226 | ): Layer.Layer<HttpClient.HttpClient> => |