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
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
26 changes: 26 additions & 0 deletions apps/cloud/src/auth/workos-callback-state.node.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,3 +164,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
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);
});
56 changes: 55 additions & 1 deletion apps/cloud/src/mcp/mcp-auth.node.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,12 @@ import { describe, expect, it } from "@effect/vitest";
import { Effect } from "effect";
import { SignJWT, createLocalJWKSet, exportJWK, generateKeyPair } from "jose";

import { McpJwtVerificationError, verifyMcpAccessToken, verifyWorkOSMcpAccessToken } from "./jwt";
import {
McpJwtVerificationError,
verifyMcpAccessToken,
verifyWorkOSMcpAccessToken,
verifyWorkosUserManagementToken,
} from "./jwt";

const issuer = "https://test-authkit.example.com";
const resource = "https://test-resource.example.com/mcp";
Expand Down Expand Up @@ -113,3 +118,52 @@ describe("MCP AuthKit token verification", () => {
}),
);
});

describe("access token expiry and identity boundaries", () => {
for (const kind of ["mcp", "user-management"] as const) {
for (const invalidClaim of ["missing-exp", "missing-iat", "older-than-one-day"] as const) {
it.effect(`${kind} rejects ${invalidClaim}`, () =>
Effect.gen(function* () {
const { publicKey, privateKey } = yield* Effect.promise(() => generateKeyPair("RS256"));
const jwk = yield* Effect.promise(() => exportJWK(publicKey));
const jwks = createLocalJWKSet({ keys: [{ ...jwk, kid: "expiry-key" }] });
const now = Math.floor(Date.now() / 1000);
const claims = {
sub: "user_test",
org_id: "org_test",
iss: issuer,
aud: resource,
...(invalidClaim === "missing-exp" ? {} : { exp: now + 300 }),
...(invalidClaim === "missing-iat"
? {}
: { iat: invalidClaim === "older-than-one-day" ? now - 86401 : now }),
};
const token = yield* Effect.promise(() =>
new SignJWT(claims)
.setProtectedHeader({ alg: "RS256", kid: "expiry-key" })
.sign(privateKey),
);
const error = yield* Effect.flip(
kind === "mcp"
? verifyMcpAccessToken(token, jwks, { issuer, audience: resource })
: verifyWorkosUserManagementToken(token, jwks),
);
expect(error).toBeInstanceOf(McpJwtVerificationError);
expect(error.reason).not.toBe("system");
}),
);
}
it.effect(`${kind} rejects a non-string organization claim`, () =>
Effect.gen(function* () {
const { jwks, sign } = yield* Effect.promise(() => makeVerifier());
const token = yield* Effect.promise(() =>
sign({ aud: resource, org_id: { id: "org_test" } }),
);
const verified = yield* kind === "mcp"
? verifyMcpAccessToken(token, jwks, { issuer, audience: resource })
: verifyWorkosUserManagementToken(token, jwks);
expect(verified).toBeNull();
}),
);
}
});
2 changes: 2 additions & 0 deletions apps/cloud/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,8 @@ export default defineConfig(({ command, mode }) => {
// pre-bundle. The "<pkg> > <dep>" syntax resolves it starting from that
// package's own node_modules instead.
const lateDiscoveredDeps = [
// Browser telemetry loads this after the initial route dependency scan.
"@opentelemetry/api",
"effect/Match",
"effect/Predicate",
"effect/Exit",
Expand Down
4 changes: 2 additions & 2 deletions apps/desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
},
"dependencies": {
"@sentry/bun": "^10.57.0",
"@sentry/electron": "^7.13.0",
"@sentry/electron": "7.19.0",
"electron-log": "^5",
"electron-store": "^10",
"electron-updater": "^6",
Expand All @@ -47,7 +47,7 @@
"@types/node": "catalog:",
"@zip.js/zip.js": "^2.8.26",
"bun-types": "catalog:",
"electron": "41.2.1",
"electron": "41.10.3",
"electron-builder": "^26",
"electron-vite": "^5",
"quickjs-emscripten": "catalog:",
Expand Down
14 changes: 10 additions & 4 deletions apps/host-selfhost/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -9,19 +9,23 @@
# -e EXECUTOR_WEB_BASE_URL=https://your.domain \
# -v executor-data:/data executor-selfhost

FROM oven/bun:1 AS prod-deps
FROM oven/bun:1.3.11@sha256:0733e50325078969732ebe3b15ce4c4be5082f18c4ac1a0f0ca4839c2e4e42a7 AS prod-deps
WORKDIR /app
COPY . .
RUN bun install --frozen-lockfile --production --ignore-scripts --filter @executor-js/host-selfhost \
&& bun run apps/host-selfhost/scripts/package-runtime.ts
&& bun run apps/host-selfhost/scripts/package-runtime.ts \
&& mkdir -p /runtime-data

FROM oven/bun:1 AS build
FROM oven/bun:1.3.11@sha256:0733e50325078969732ebe3b15ce4c4be5082f18c4ac1a0f0ca4839c2e4e42a7 AS build
RUN apt-get update \
&& apt-get install -y --no-install-recommends python3 make g++ \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY . .
RUN bun install --frozen-lockfile
RUN cd apps/host-selfhost && bun run build

FROM gcr.io/distroless/cc-debian12 AS runtime
FROM gcr.io/distroless/cc-debian12:nonroot@sha256:9dac0a79194e45a7da0158a9c6da57b217585af0786db3845d1f0ec1a0dd182f AS runtime
WORKDIR /app
LABEL org.opencontainers.image.source="https://github.com/UsefulSoftwareCo/executor" \
org.opencontainers.image.description="Single-container self-hosted Executor" \
Expand All @@ -34,6 +38,8 @@ COPY --from=prod-deps /usr/local/bin/bun /usr/local/bin/bun
COPY --from=prod-deps /app/.selfhost-runtime /app
COPY --from=build /app/apps/host-selfhost/dist /app/apps/host-selfhost/dist
WORKDIR /app/apps/host-selfhost
COPY --from=prod-deps --chown=65532:65532 /runtime-data /data
USER 65532:65532
VOLUME ["/data"]
EXPOSE 4788
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=5 \
Expand Down
15 changes: 15 additions & 0 deletions apps/host-selfhost/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,3 +57,18 @@ src/
db/ · mcp/ · execution.ts · plugins.ts · observability.ts
web/ the TanStack Router SPA (setup, login, join, admin, …)
```

## Container permissions when upgrading

The container runs as UID/GID `65532:65532`. New Docker named volumes inherit
that ownership from the image. Existing volumes created by a root-running
release must be backed up and assigned to this UID/GID before the first start
of the new image. Bind-mounted data directories need the same write access.

Stop the old container before changing data-directory ownership. Verify the
exact volume or bind-mount path, make a backup, and change ownership only within
that data directory. The application does not change existing volume ownership
automatically. Keep the previous image and backup available for rollback.

The Docker build and runtime base images are pinned by digest. Update the tag
and digest together when applying upstream security updates.
Loading
Loading