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
5 changes: 5 additions & 0 deletions .changeset/selfhost-admin-area-access.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"executor": patch
---

Hide the self-hosted Admin area from non-admin members and refuse direct access before member details or invite controls are rendered.
8 changes: 8 additions & 0 deletions .changeset/tidy-trace-secrets.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
"@executor-js/sdk": patch
"@executor-js/api": patch
---

Redact redirect, referrer, trace-state, and MCP session headers from outbound HTTP traces.

Allow hosts to require HTTPS for outbound requests and reject redirects to plaintext endpoints. Executor Cloud enables this policy. Explicit private-network development access remains available.
5 changes: 5 additions & 0 deletions .changeset/update-openapi-yaml-parser.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@executor-js/plugin-openapi": patch
---

Update the YAML parser to include fixes for malformed-input denial of service.
12 changes: 6 additions & 6 deletions apps/cloud/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -58,12 +58,12 @@
"@jitl/quickjs-wasmfile-release-sync": "catalog:",
"@modelcontextprotocol/sdk": "^1.29.0",
"@opentelemetry/api": "~1.9.0",
"@opentelemetry/exporter-logs-otlp-http": "^0.214.0",
"@opentelemetry/exporter-trace-otlp-http": "^0.214.0",
"@opentelemetry/resources": "^2.6.1",
"@opentelemetry/sdk-logs": "^0.214.0",
"@opentelemetry/sdk-trace-base": "^2.6.1",
"@opentelemetry/sdk-trace-web": "^2.6.1",
"@opentelemetry/exporter-logs-otlp-http": "^0.220.0",
"@opentelemetry/exporter-trace-otlp-http": "^0.220.0",
"@opentelemetry/resources": "^2.9.0",
"@opentelemetry/sdk-logs": "^0.220.0",
"@opentelemetry/sdk-trace-base": "^2.9.0",
"@opentelemetry/sdk-trace-web": "^2.9.0",
"@opentelemetry/semantic-conventions": "^1.40.0",
"@sentry/cloudflare": "^10.48.0",
"@sentry/react": "^10.48.0",
Expand Down
7 changes: 7 additions & 0 deletions apps/cloud/src/auth/access-token-options.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import type { JWTVerifyOptions } from "jose";

/** Require expiring WorkOS tokens and cap local verification at 24 hours. */
export const workosAccessTokenOptions: JWTVerifyOptions = {
requiredClaims: ["exp", "iat"],
maxTokenAge: "24h",
};
6 changes: 3 additions & 3 deletions apps/cloud/src/auth/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,9 +65,9 @@ const CliLoginResponse = Schema.Struct({
clientId: Schema.String,
});

// `state` is optional — some WorkOS-initiated redirects arrive at the
// callback without the state we set on /auth/login. The CSRF check is
// only enforced when state is present (see callback handler).
// Decode missing state so the callback can reject it with the same explicit
// login-state failure as a mismatched value. Every successful callback must
// match the state cookie created by /auth/login.
const AuthCallbackSearch = Schema.Struct({
code: Schema.String,
state: Schema.optional(Schema.String),
Expand Down
5 changes: 4 additions & 1 deletion apps/cloud/src/auth/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -317,7 +317,10 @@ export const CloudAuthPublicHandlers = HttpApiBuilder.group(
// make the next page load optimistically paint the app shell for a
// signed-out browser.
return deleteResponseCookie(
deleteResponseCookie(response, "wos-session"),
deleteResponseCookie(
HttpServerResponse.setHeader(response, "Clear-Site-Data", '"cache", "storage"'),
"wos-session",
),
AUTH_HINT_COOKIE,
);
}),
Expand Down
10 changes: 10 additions & 0 deletions apps/cloud/src/auth/return-to.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,13 @@ describe("isSafeReturnTo", () => {
const unsafe = [
"https://evil.example", // absolute URL — off-origin redirect
"//evil.example", // protocol-relative — same thing in disguise
"/\\evil.example", // browsers normalize backslashes to slashes
"/\t/evil.example", // URL parsing strips embedded tabs
"/\n/evil.example",
"/\r/evil.example",
"/safe/../api/auth/me", // normalized API destination
"/safe/%2e%2e/api/auth/me",
"/api/oauth/callback/../logout",
"/api/auth/logout", // API endpoints are never a landing page
"/api/oauth/callback/extra?state=oauth-state", // only the exact OAuth callback resumes
"/api", // bare /api too
Expand All @@ -47,6 +54,9 @@ describe("safeReturnTo", () => {
it("passes a safe path through", () => {
expect(safeReturnTo("/tools")).toBe("/tools");
});
it("returns the canonical destination while preserving its query and fragment", () => {
expect(safeReturnTo("/old/../tools?view=all#list")).toBe("/tools?view=all#list");
});
it("nulls unsafe and absent values", () => {
expect(safeReturnTo("https://evil.example")).toBeNull();
expect(safeReturnTo(null)).toBeNull();
Expand Down
29 changes: 20 additions & 9 deletions apps/cloud/src/auth/return-to.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,18 +11,29 @@
// Pure string code — imported by server handlers and the login page alike.
// ---------------------------------------------------------------------------

const pathPart = (path: string): string => path.split(/[?#]/, 1)[0] ?? "";
const RETURN_TO_ORIGIN = "https://executor.invalid";

const isOAuthCallbackReturnTo = (path: string): boolean => pathPart(path) === "/api/oauth/callback";
/** Parse a same-origin landing path, or return null for absent or unsafe input. */
export const safeReturnTo = (path: string | null | undefined): string | null => {
if (!path || !path.startsWith("/") || path.startsWith("//")) return null;
// Browsers treat backslashes as path separators and strip some control
// characters. Reject those spellings before interpreting the destination.
for (const character of path) {
if (character === "\\" || character <= " " || character === "\u007f") return null;
}

export const isSafeReturnTo = (path: string): boolean =>
path.startsWith("/") &&
!path.startsWith("//") &&
(!/^\/api(\/|$)/.test(path) || isOAuthCallbackReturnTo(path));
// The fixed origin and single leading slash guarantee a parseable URL.
// Check the normalized pathname so dot segments cannot bypass the API gate.
const destination = new URL(path, RETURN_TO_ORIGIN);
if (destination.origin !== RETURN_TO_ORIGIN) return null;
if (/^\/api(\/|$)/.test(destination.pathname) && destination.pathname !== "/api/oauth/callback") {
return null;
}
return `${destination.pathname}${destination.search}${destination.hash}`;
};

/** The validated returnTo, or null when absent/unsafe. */
export const safeReturnTo = (path: string | null | undefined): string | null =>
path && isSafeReturnTo(path) ? path : null;
/** Whether a value parses as a same-origin landing path. */
export const isSafeReturnTo = (path: string): boolean => safeReturnTo(path) !== null;

/** The /login URL that comes back to `returnTo` ("/" needs no parameter). */
export const loginPath = (returnTo: string): string =>
Expand Down
54 changes: 52 additions & 2 deletions apps/cloud/src/auth/workos-callback-state.node.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@
// HTTP surface (see api.request-scope.node.test.ts).
// ---------------------------------------------------------------------------

import { afterAll, describe, expect, it } from "@effect/vitest";
import { afterAll, describe, expect, it, vi } from "@effect/vitest";
import { waitUntil } from "cloudflare:workers";
import { Effect, Layer } from "effect";
import { HttpRouter, HttpServer } from "effect/unstable/http";
import { HttpApiBuilder } from "effect/unstable/httpapi";
Expand All @@ -21,6 +22,7 @@ import { CloudAuthPublicApi } from "./api";
import { UserStoreService } from "./context";
import { WorkOSClient, type WorkOSClientService } from "./workos";
import { encodeLoginState } from "./login-state";
import { AutumnService } from "../extensions/billing/service";

// The route under test serves under the `/api` prefix in the composed app;
// toWebHandler mounts the raw group, so paths here are relative to the group.
Expand All @@ -46,6 +48,9 @@ const stubWorkOS = Layer.succeed(
if (prop === "listUserMemberships") {
return () => Effect.succeed({ data: [] });
}
if (prop === "listOrgMembers") {
return () => Effect.succeed({ data: [{ status: "active" }] });
}
return () => Effect.die(`unexpected WorkOSClient.${String(prop)} call`);
},
}),
Expand Down Expand Up @@ -88,11 +93,15 @@ const App = HttpApiBuilder.layer(PublicApi).pipe(
Layer.provide(CloudAuthPublicHandlers),
Layer.provide(stubWorkOS),
Layer.provide(stubUsers),
Layer.provide(AutumnService.Default),
Layer.provide(HttpServer.layerServices),
);

const app = HttpRouter.toWebHandler(App, { disableLogger: true });
afterAll(() => app.dispose());
afterAll(async () => {
await Promise.all(vi.mocked(waitUntil).mock.calls.map(([work]) => work));
await app.dispose();
});

const run = (request: Request) => {
// beta.59: the handler type expects a context argument; this layer stack
Expand All @@ -104,6 +113,21 @@ const callbackUrl = (state?: string, code = "code_1") =>
`https://executor.test/auth/callback${state ? `?state=${encodeURIComponent(state)}` : ""}${state ? "&" : "?"}code=${code}`;

describe("workos callback · CSRF state hardening", () => {
for (const returnTo of ["/\\evil.example", "/safe/../api/auth/me"]) {
it(`keeps an unsafe return destination on the homepage: ${JSON.stringify(returnTo)}`, async () => {
const state = encodeLoginState({ nonce: "redirect-boundary", returnTo });
const res = await run(
new Request(callbackUrl(state), {
headers: { cookie: `${STATE_COOKIE}=${state}` },
redirect: "manual",
}),
);
expect(res.status).toBe(302);
expect(res.headers.get("location")).toBe("/");
expect(res.headers.get("set-cookie") ?? "").toContain(SESSION_COOKIE);
});
}

it("rejects a callback with NO state (the former bypass) before any WorkOS call", async () => {
const res = await run(new Request(callbackUrl(undefined), { redirect: "manual" }));
expect(res.status).toBe(400);
Expand Down Expand Up @@ -164,3 +188,29 @@ describe("workos callback · CSRF state hardening", () => {
expect(replay.status).toBe(400);
});
});

describe("logout browser cleanup", () => {
it("clears browser storage when the browser presents an auth hint", async () => {
const response = await run(
new Request("https://executor.test/auth/logout", {
method: "POST",
headers: { cookie: "executor-auth-hint=1" },
redirect: "manual",
}),
);
expect(response.status).toBe(302);
expect(response.headers.get("clear-site-data")).toBe('"cache", "storage"');
expect(response.headers.get("set-cookie")).toContain("Max-Age=0");
});

it("does not clear storage for a request without same-site cookies", async () => {
const response = await run(
new Request("https://executor.test/auth/logout", {
method: "POST",
redirect: "manual",
}),
);
expect(response.status).toBe(302);
expect(response.headers.get("clear-site-data")).toBeNull();
});
});
3 changes: 2 additions & 1 deletion apps/cloud/src/auth/workos.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { Context, Data, Effect, Layer, Option, Predicate, Schema } from "effect"
import { GeneratePortalLinkIntent, WorkOS } from "@workos-inc/node/worker";
import { defaults as ironDefaults, unseal as unsealIron } from "iron-webcrypto";
import { decodeJwt, jwtVerify } from "jose";
import { workosAccessTokenOptions } from "./access-token-options";
import { JWKSInvalid, JWKSNoMatchingKey, JWKSTimeout } from "jose/errors";
import { parseCookie } from "./cookies";
import { createCachedRemoteJWKSet, type CachedRemoteJWKSet } from "./jwks-cache";
Expand Down Expand Up @@ -179,7 +180,7 @@ const getWorkOSSessionJwks = (() => {

const verifyJwtOnce = (accessToken: string, jwks: CachedRemoteJWKSet) =>
Effect.tryPromise({
try: () => jwtVerify(accessToken, jwks),
try: () => jwtVerify(accessToken, jwks, workosAccessTokenOptions),
catch: (cause) => new ServiceAdapterError({ cause }),
});

Expand Down
1 change: 1 addition & 0 deletions apps/cloud/src/engine/execution-stack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ export const CloudHostConfig: Layer.Layer<HostConfig> = Layer.sync(HostConfig, (
// the e2e dev-server env opts in with `"true"` so in-scenario fixture
// servers on localhost are reachable. See `hosted-http-client.ts`.
allowLocalNetwork: env.ALLOW_LOCAL_NETWORK === "true",
requireTls: true,
webBaseUrl: env.VITE_PUBLIC_SITE_URL ?? "https://executor.sh",
oauthCallbackPath: `${CLOUD_MOUNT_PREFIX}/oauth/callback`,
// WorkOS Vault is cloud's credential storage implementation detail, not a
Expand Down
3 changes: 3 additions & 0 deletions apps/cloud/src/engine/first-party-oauth-clients.ts
Original file line number Diff line number Diff line change
Expand Up @@ -328,6 +328,9 @@ export const firstPartyOAuthClientsFor = (
}),
...client(env.FIRST_PARTY_SLACK_CLIENT_ID, env.FIRST_PARTY_SLACK_CLIENT_SECRET, {
name: "slack",
// Slack MCP requires Marketplace approval for use outside the app's workspace.
// Keep the client resolvable for existing connections while withholding it.
unlisted: true,
authorizationUrl: "https://slack.com/oauth/v2_user/authorize",
tokenUrl: "https://slack.com/api/oauth.v2.user.access",
resource: "https://mcp.slack.com",
Expand Down
9 changes: 5 additions & 4 deletions apps/cloud/src/extensions/billing/member-seats.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
// ---------------------------------------------------------------------------

import { Effect } from "effect";
import { waitUntil } from "cloudflare:workers";

import { WorkOSClient } from "../../auth/workos";
import { AutumnService } from "./service";
Expand Down Expand Up @@ -38,16 +39,16 @@ export const reportMemberSeats = (
/**
* Fork `reportMemberSeats` off the calling request, mirroring how execution
* tracking is forked: billing must never stall or fail a user-facing
* request. Only boot-scoped services are captured (WorkOS + Autumn — no
* request-scoped resources), so the forked fiber cannot outlive anything it
* depends on.
* request. Cloudflare owns the promise through waitUntil, so the recount can
* finish after the response. Only boot-scoped WorkOS and Autumn services are
* captured.
*/
export const forkReportMemberSeats = (
organizationId: string,
): Effect.Effect<void, never, WorkOSClient | AutumnService> =>
Effect.gen(function* () {
const ctx = yield* Effect.context<WorkOSClient | AutumnService>();
yield* Effect.sync(() => {
Effect.runForkWith(ctx)(reportMemberSeats(organizationId));
waitUntil(Effect.runPromiseWith(ctx)(reportMemberSeats(organizationId)));
});
});
36 changes: 22 additions & 14 deletions apps/cloud/src/mcp/jwt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,26 @@
// the dependency points one way only.
// ---------------------------------------------------------------------------

import { Data, Effect, Result, Schema } from "effect";
import { Data, Effect, Option, Result, Schema } from "effect";
import { jwtVerify, type JWTVerifyGetKey } from "jose";
import { JWKSInvalid, JWKSTimeout, JWTExpired } from "jose/errors";
import { workosAccessTokenOptions } from "../auth/access-token-options";

const parseIdentityClaims = Schema.decodeUnknownOption(
Schema.Struct({
sub: Schema.NonEmptyString,
org_id: Schema.optionalKey(Schema.NullOr(Schema.NonEmptyString)),
}),
);

const identityFromClaims = (payload: unknown): VerifiedToken | null =>
Option.match(parseIdentityClaims(payload), {
onNone: () => null,
onSome: (claims) => ({
accountId: claims.sub,
organizationId: claims.org_id ?? null,
}),
});

export type VerifiedToken = {
/** The WorkOS account ID (user ID). */
Expand Down Expand Up @@ -91,18 +108,14 @@ export const verifyMcpAccessToken = (
const { payload } = yield* Effect.tryPromise({
try: () =>
jwtVerify(token, jwks, {
...workosAccessTokenOptions,
issuer: options.issuer,
audience: options.audience,
}),
catch: classifyJwtVerificationError,
}).pipe(withJwtVerificationSpan);

if (!payload.sub) return null;

return {
accountId: payload.sub,
organizationId: (payload.org_id as string | undefined) ?? null,
} satisfies VerifiedToken;
return identityFromClaims(payload);
});

export const verifyWorkOSMcpAccessToken = (
Expand Down Expand Up @@ -134,14 +147,9 @@ export const verifyWorkOSMcpAccessToken = (
export const verifyWorkosUserManagementToken = (token: string, jwks: JWTVerifyGetKey) =>
Effect.gen(function* () {
const { payload } = yield* Effect.tryPromise({
try: () => jwtVerify(token, jwks),
try: () => jwtVerify(token, jwks, workosAccessTokenOptions),
catch: classifyJwtVerificationError,
}).pipe(withJwtVerificationSpan);

if (!payload.sub) return null;

return {
accountId: payload.sub,
organizationId: (payload.org_id as string | undefined) ?? null,
} satisfies VerifiedToken;
return identityFromClaims(payload);
});
Loading
Loading