Skip to content

Commit 3fd28a5

Browse files
authored
Harden OAuth sessions and patch vulnerable dependencies (#2000)
* Update YAML, URI, and serialization dependencies * Harden sessions, credential traces, and runtime dependencies * Require HTTPS for cloud outbound requests * Normalize dependency resolution for frozen installs * Prebundle browser telemetry before cloud development starts
1 parent 521c77b commit 3fd28a5

26 files changed

Lines changed: 1300 additions & 700 deletions

‎.changeset/tidy-trace-secrets.md‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
"@executor-js/sdk": patch
3+
"@executor-js/api": patch
4+
---
5+
6+
Redact redirect, referrer, trace-state, and MCP session headers from outbound HTTP traces.
7+
8+
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.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@executor-js/plugin-openapi": patch
3+
---
4+
5+
Update the YAML parser to include fixes for malformed-input denial of service.

‎apps/cloud/package.json‎

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -58,12 +58,12 @@
5858
"@jitl/quickjs-wasmfile-release-sync": "catalog:",
5959
"@modelcontextprotocol/sdk": "^1.29.0",
6060
"@opentelemetry/api": "~1.9.0",
61-
"@opentelemetry/exporter-logs-otlp-http": "^0.214.0",
62-
"@opentelemetry/exporter-trace-otlp-http": "^0.214.0",
63-
"@opentelemetry/resources": "^2.6.1",
64-
"@opentelemetry/sdk-logs": "^0.214.0",
65-
"@opentelemetry/sdk-trace-base": "^2.6.1",
66-
"@opentelemetry/sdk-trace-web": "^2.6.1",
61+
"@opentelemetry/exporter-logs-otlp-http": "^0.220.0",
62+
"@opentelemetry/exporter-trace-otlp-http": "^0.220.0",
63+
"@opentelemetry/resources": "^2.9.0",
64+
"@opentelemetry/sdk-logs": "^0.220.0",
65+
"@opentelemetry/sdk-trace-base": "^2.9.0",
66+
"@opentelemetry/sdk-trace-web": "^2.9.0",
6767
"@opentelemetry/semantic-conventions": "^1.40.0",
6868
"@sentry/cloudflare": "^10.48.0",
6969
"@sentry/react": "^10.48.0",
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import type { JWTVerifyOptions } from "jose";
2+
3+
/** Require expiring WorkOS tokens and cap local verification at 24 hours. */
4+
export const workosAccessTokenOptions: JWTVerifyOptions = {
5+
requiredClaims: ["exp", "iat"],
6+
maxTokenAge: "24h",
7+
};

‎apps/cloud/src/auth/api.ts‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -65,9 +65,9 @@ const CliLoginResponse = Schema.Struct({
6565
clientId: Schema.String,
6666
});
6767

68-
// `state` is optional — some WorkOS-initiated redirects arrive at the
69-
// callback without the state we set on /auth/login. The CSRF check is
70-
// only enforced when state is present (see callback handler).
68+
// Decode missing state so the callback can reject it with the same explicit
69+
// login-state failure as a mismatched value. Every successful callback must
70+
// match the state cookie created by /auth/login.
7171
const AuthCallbackSearch = Schema.Struct({
7272
code: Schema.String,
7373
state: Schema.optional(Schema.String),

‎apps/cloud/src/auth/handlers.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -317,7 +317,10 @@ export const CloudAuthPublicHandlers = HttpApiBuilder.group(
317317
// make the next page load optimistically paint the app shell for a
318318
// signed-out browser.
319319
return deleteResponseCookie(
320-
deleteResponseCookie(response, "wos-session"),
320+
deleteResponseCookie(
321+
HttpServerResponse.setHeader(response, "Clear-Site-Data", '"cache", "storage"'),
322+
"wos-session",
323+
),
321324
AUTH_HINT_COOKIE,
322325
);
323326
}),

‎apps/cloud/src/auth/workos-callback-state.node.test.ts‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,3 +164,29 @@ describe("workos callback · CSRF state hardening", () => {
164164
expect(replay.status).toBe(400);
165165
});
166166
});
167+
168+
describe("logout browser cleanup", () => {
169+
it("clears browser storage when the browser presents an auth hint", async () => {
170+
const response = await run(
171+
new Request("https://executor.test/auth/logout", {
172+
method: "POST",
173+
headers: { cookie: "executor-auth-hint=1" },
174+
redirect: "manual",
175+
}),
176+
);
177+
expect(response.status).toBe(302);
178+
expect(response.headers.get("clear-site-data")).toBe('"cache", "storage"');
179+
expect(response.headers.get("set-cookie")).toContain("Max-Age=0");
180+
});
181+
182+
it("does not clear storage for a request without same-site cookies", async () => {
183+
const response = await run(
184+
new Request("https://executor.test/auth/logout", {
185+
method: "POST",
186+
redirect: "manual",
187+
}),
188+
);
189+
expect(response.status).toBe(302);
190+
expect(response.headers.get("clear-site-data")).toBeNull();
191+
});
192+
});

‎apps/cloud/src/auth/workos.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { Context, Data, Effect, Layer, Option, Predicate, Schema } from "effect"
77
import { GeneratePortalLinkIntent, WorkOS } from "@workos-inc/node/worker";
88
import { defaults as ironDefaults, unseal as unsealIron } from "iron-webcrypto";
99
import { decodeJwt, jwtVerify } from "jose";
10+
import { workosAccessTokenOptions } from "./access-token-options";
1011
import { JWKSInvalid, JWKSNoMatchingKey, JWKSTimeout } from "jose/errors";
1112
import { parseCookie } from "./cookies";
1213
import { createCachedRemoteJWKSet, type CachedRemoteJWKSet } from "./jwks-cache";
@@ -179,7 +180,7 @@ const getWorkOSSessionJwks = (() => {
179180

180181
const verifyJwtOnce = (accessToken: string, jwks: CachedRemoteJWKSet) =>
181182
Effect.tryPromise({
182-
try: () => jwtVerify(accessToken, jwks),
183+
try: () => jwtVerify(accessToken, jwks, workosAccessTokenOptions),
183184
catch: (cause) => new ServiceAdapterError({ cause }),
184185
});
185186

‎apps/cloud/src/engine/execution-stack.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,7 @@ export const CloudHostConfig: Layer.Layer<HostConfig> = Layer.sync(HostConfig, (
9696
// the e2e dev-server env opts in with `"true"` so in-scenario fixture
9797
// servers on localhost are reachable. See `hosted-http-client.ts`.
9898
allowLocalNetwork: env.ALLOW_LOCAL_NETWORK === "true",
99+
requireTls: true,
99100
webBaseUrl: env.VITE_PUBLIC_SITE_URL ?? "https://executor.sh",
100101
oauthCallbackPath: `${CLOUD_MOUNT_PREFIX}/oauth/callback`,
101102
// WorkOS Vault is cloud's credential storage implementation detail, not a

‎apps/cloud/src/mcp/jwt.ts‎

Lines changed: 22 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,26 @@
77
// the dependency points one way only.
88
// ---------------------------------------------------------------------------
99

10-
import { Data, Effect, Result, Schema } from "effect";
10+
import { Data, Effect, Option, Result, Schema } from "effect";
1111
import { jwtVerify, type JWTVerifyGetKey } from "jose";
1212
import { JWKSInvalid, JWKSTimeout, JWTExpired } from "jose/errors";
13+
import { workosAccessTokenOptions } from "../auth/access-token-options";
14+
15+
const parseIdentityClaims = Schema.decodeUnknownOption(
16+
Schema.Struct({
17+
sub: Schema.NonEmptyString,
18+
org_id: Schema.optionalKey(Schema.NullOr(Schema.NonEmptyString)),
19+
}),
20+
);
21+
22+
const identityFromClaims = (payload: unknown): VerifiedToken | null =>
23+
Option.match(parseIdentityClaims(payload), {
24+
onNone: () => null,
25+
onSome: (claims) => ({
26+
accountId: claims.sub,
27+
organizationId: claims.org_id ?? null,
28+
}),
29+
});
1330

1431
export type VerifiedToken = {
1532
/** The WorkOS account ID (user ID). */
@@ -91,18 +108,14 @@ export const verifyMcpAccessToken = (
91108
const { payload } = yield* Effect.tryPromise({
92109
try: () =>
93110
jwtVerify(token, jwks, {
111+
...workosAccessTokenOptions,
94112
issuer: options.issuer,
95113
audience: options.audience,
96114
}),
97115
catch: classifyJwtVerificationError,
98116
}).pipe(withJwtVerificationSpan);
99117

100-
if (!payload.sub) return null;
101-
102-
return {
103-
accountId: payload.sub,
104-
organizationId: (payload.org_id as string | undefined) ?? null,
105-
} satisfies VerifiedToken;
118+
return identityFromClaims(payload);
106119
});
107120

108121
export const verifyWorkOSMcpAccessToken = (
@@ -134,14 +147,9 @@ export const verifyWorkOSMcpAccessToken = (
134147
export const verifyWorkosUserManagementToken = (token: string, jwks: JWTVerifyGetKey) =>
135148
Effect.gen(function* () {
136149
const { payload } = yield* Effect.tryPromise({
137-
try: () => jwtVerify(token, jwks),
150+
try: () => jwtVerify(token, jwks, workosAccessTokenOptions),
138151
catch: classifyJwtVerificationError,
139152
}).pipe(withJwtVerificationSpan);
140153

141-
if (!payload.sub) return null;
142-
143-
return {
144-
accountId: payload.sub,
145-
organizationId: (payload.org_id as string | undefined) ?? null,
146-
} satisfies VerifiedToken;
154+
return identityFromClaims(payload);
147155
});

0 commit comments

Comments
 (0)