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
49 changes: 49 additions & 0 deletions apps/desktop/src/electron/ElectronProtocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,55 @@ export function getDesktopScheme(isDevelopment: boolean): string {
return isDevelopment ? DESKTOP_DEVELOPMENT_SCHEME : DESKTOP_PRODUCTION_SCHEME;
}

/**
* Declare the desktop schemes standard *before* Electron is ready.
*
* Custom schemes are non-standard by default, and Chromium gives a
* non-standard scheme an **opaque** origin — every request the renderer makes
* carries `Origin: null` rather than `t3code://app`. That matters beyond
* tidiness: the server rejects `null` on the control-plane WebSocket upgrade
* (`apps/server/src/auth/websocketOrigin.ts`), because `null` is also what a
* sandboxed hostile iframe sends, so it cannot be allow-listed. Without this
* call the renderer is indistinguishable from that attacker and is refused.
*
* Measured with a real Electron renderer on `t3code://app`: with
* `protocol.handle` alone the upgrade arrives as `Origin: null`; adding this
* registration makes it arrive as `Origin: t3code://app`.
*
* Must be called at module scope — Electron requires it before the `ready`
* event, and it throws if called afterwards. Measured on Electron 41.5.0:
* `ready` fires ~87 ms in, and a caller that awaits any real I/O first is
* already too late, so this cannot be moved inside a layer.
*
* **This is deliberately not the only registration of these schemes.**
* `@clerk/electron`'s `createClerkBridge` registers whichever scheme is active
* (see `../app/DesktopClerk.ts`), and Electron documents the API as one-shot.
* Measured on the pinned version, a second call before `ready` neither throws
* nor replaces: the privileges *merge*, and `standard: true` wins regardless of
* which call sets it. Clerk's registration is nonetheless not sufficient on its
* own — it only covers the active scheme, and it runs during layer construction,
* which is the window `ready` can beat. The privileges below are kept identical
* to Clerk's so the merged result does not depend on call order.
*/
export function registerDesktopSchemesAsPrivileged(): void {
Electron.protocol.registerSchemesAsPrivileged(
[DESKTOP_PRODUCTION_SCHEME, DESKTOP_DEVELOPMENT_SCHEME].map((scheme) => ({
scheme,
privileges: {
// `standard` is the one that grants a real origin; the rest keep the
// renderer's capabilities equivalent to the https page it replaces, and
// match `@clerk/electron`'s own registration exactly.
standard: true,
secure: true,
supportFetchAPI: true,
corsEnabled: true,
allowServiceWorkers: true,
stream: true,
},
})),
);
}

export function getDesktopOrigin(isDevelopment: boolean): string {
return `${getDesktopScheme(isDevelopment)}://${DESKTOP_HOST}`;
}
Expand Down
6 changes: 6 additions & 0 deletions apps/desktop/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -197,4 +197,10 @@ const desktopRuntimeLayer = desktopClerkLayer.pipe(
),
);

// Must run before Electron reaches `ready`, which building the runtime below
// will trigger — so it goes here at module scope rather than inside a layer.
// Without it the renderer's custom scheme gets an opaque origin and the server
// refuses its WebSocket upgrade. See registerDesktopSchemesAsPrivileged.
ElectronProtocol.registerDesktopSchemesAsPrivileged();

DesktopApp.program.pipe(Effect.provide(desktopRuntimeLayer), NodeRuntime.runMain);
4 changes: 3 additions & 1 deletion apps/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@
],
"type": "module",
"scripts": {
"dev": "node --watch src/bin.ts",
"dev": "node src/bin.ts",
"dev:watch": "node --watch src/bin.ts",
"build:bundle": "vp pack",
"start": "node dist/bin.mjs",
"typecheck": "tsgo --noEmit",
Expand All @@ -29,6 +30,7 @@
"@effect/platform-node-shared": "catalog:",
"@effect/sql-sqlite-bun": "catalog:",
"@ff-labs/fff-node": "0.9.4",
"@modelcontextprotocol/sdk": "1.29.0",
"@opencode-ai/sdk": "^1.3.15",
"@pierre/diffs": "catalog:",
"effect": "catalog:",
Expand Down
199 changes: 199 additions & 0 deletions apps/server/src/auth/websocketOrigin.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
import { assert, describe, it } from "@effect/vitest";

import { decideWebSocketOrigin } from "./websocketOrigin.ts";

const decide = (input: {
origin?: string | undefined;
host?: string | undefined;
allowedOrigins?: ReadonlyArray<string>;
}) =>
decideWebSocketOrigin({
origin: input.origin,
host: input.host,
allowedOrigins: input.allowedOrigins ?? [],
});

describe("decideWebSocketOrigin", () => {
describe("clients that send no Origin", () => {
// Desktop and mobile both reach `/ws` through `Socket.layerWebSocket` in
// @t3tools/client-runtime, which sends no Origin. Rejecting a missing
// header would lock every non-browser client out of the server.
it("allows an upgrade with no Origin header at all", () => {
const decision = decide({ host: "127.0.0.1:13773" });
assert.deepEqual(decision, { allowed: true, reason: "no-origin" });
});

it("treats an empty or whitespace Origin as absent", () => {
for (const origin of ["", " "]) {
assert.deepEqual(decide({ origin, host: "127.0.0.1:13773" }), {
allowed: true,
reason: "no-origin",
});
}
});
});

describe("same-origin browsers", () => {
it("allows an Origin that matches the Host it was addressed to", () => {
const decision = decide({
origin: "http://127.0.0.1:13773",
host: "127.0.0.1:13773",
});
assert.deepEqual(decision, { allowed: true, reason: "same-origin" });
});

// Tailscale Serve terminates TLS and forwards to loopback, but passes the
// public authority through as Host. Measured against a live Serve mount:
// a request to https://<node>.<tailnet>.ts.net:8446/ arrives with
// `host: <node>.<tailnet>.ts.net:8446`. This is the primary remote path.
it("allows the tailnet origin Tailscale Serve forwards", () => {
const decision = decide({
origin: "https://example-tailnet.ts.net:8446",
host: "example-tailnet.ts.net:8446",
});
assert.deepEqual(decision, { allowed: true, reason: "same-origin" });
});

// Serve on :443 forwards `host` without a port while the browser elides
// :443 from the Origin. Both sides mean the same authority.
it("allows an https origin whose default port is elided on both sides", () => {
const decision = decide({
origin: "https://example-tailnet.ts.net",
host: "example-tailnet.ts.net",
});
assert.deepEqual(decision, { allowed: true, reason: "same-origin" });
});

// The scheme is deliberately not compared: Serve presents https to the
// browser while the backend listener speaks http.
it("allows a scheme mismatch when the authority agrees", () => {
const decision = decide({
origin: "https://example-tailnet.ts.net",
host: "example-tailnet.ts.net:443",
});
assert.deepEqual(decision, { allowed: true, reason: "same-origin" });
});

// Pins an accepted residual rather than asserting a fix: because the scheme
// is not compared and `Host` elides a default port, an origin on one
// scheme's default port matches a portless Host addressed over the other.
// Exploiting it needs hostile content on the same *hostname* over the
// opposite scheme — strictly harder than the preview-gateway threat this
// module exists to stop, which lands on a different port and is refused.
// Closing it would mean trusting `x-forwarded-proto`, which is
// caller-supplied. If this test ever fails, the tradeoff changed: read the
// "Known residual" note in websocketOrigin.ts before editing it green.
it("accepts a default-port origin against a portless Host of the other scheme", () => {
assert.deepEqual(decide({ origin: "http://app.example.com", host: "app.example.com" }), {
allowed: true,
reason: "same-origin",
});
// The port *is* compared whenever either side states one, which is what
// keeps the same-host-different-port gateway attack refused.
assert.deepEqual(decide({ origin: "http://app.example.com:8080", host: "app.example.com" }), {
allowed: false,
origin: "http://app.example.com:8080",
});
});

it("is case-insensitive about the hostname", () => {
const decision = decide({
origin: "http://LocalHost:13773",
host: "localhost:13773",
});
assert.deepEqual(decision, { allowed: true, reason: "same-origin" });
});
});

describe("explicitly allowed origins", () => {
// In dev the document is served by Vite on another port and proxied here,
// so the browser's Origin is the Vite origin and Host is the backend's.
it("allows the configured dev server origin against a different Host", () => {
const decision = decide({
origin: "http://localhost:5733",
host: "127.0.0.1:13773",
allowedOrigins: ["http://localhost:5733"],
});
assert.deepEqual(decision, { allowed: true, reason: "allow-listed" });
});

// The Electron renderer loads a custom scheme, which matches no Host by
// construction and only ever compares equal as an exact string.
it("allows the desktop renderer custom-scheme origins", () => {
for (const origin of ["t3code://app", "t3code-dev://app"]) {
assert.deepEqual(
decide({
origin,
host: "127.0.0.1:13773",
allowedOrigins: ["t3code://app", "t3code-dev://app"],
}),
{ allowed: true, reason: "allow-listed" },
);
}
});

it("does not allow a custom-scheme origin that is merely similar", () => {
const decision = decide({
origin: "t3code://app.evil.example.com",
host: "127.0.0.1:13773",
allowedOrigins: ["t3code://app"],
});
assert.deepEqual(decision, { allowed: false, origin: "t3code://app.evil.example.com" });
});
});

describe("foreign origins", () => {
// The finding this guards: a page served through the preview gateway shares
// a host with the app (cookies are scoped by host, not port), so its
// JavaScript can open /ws and the session cookie rides along. A different
// port is a different origin and must be refused.
it("refuses a same-host origin on a different port", () => {
const decision = decide({
origin: "http://127.0.0.1:3774",
host: "127.0.0.1:13773",
});
assert.deepEqual(decision, { allowed: false, origin: "http://127.0.0.1:3774" });
});

it("refuses an unrelated site", () => {
const decision = decide({
origin: "https://evil.example.com",
host: "example-tailnet.ts.net",
});
assert.deepEqual(decision, { allowed: false, origin: "https://evil.example.com" });
});

// Sandboxed iframes and data: documents report a literal "null" origin —
// exactly the shape hostile embedded content takes.
it("refuses the opaque null origin", () => {
const decision = decide({ origin: "null", host: "127.0.0.1:13773" });
assert.deepEqual(decision, { allowed: false, origin: "null" });
});

// `evil-app.example.com` ends with `app.example.com`, so a naive
// `endsWith` check on the Host would admit an attacker-registered domain.
it("refuses a hostname that merely has the Host as a suffix", () => {
const decision = decide({
origin: "https://evil-app.example.com",
host: "app.example.com",
});
assert.deepEqual(decision, {
allowed: false,
origin: "https://evil-app.example.com",
});
});

it("refuses an unparseable Origin", () => {
const decision = decide({ origin: "not a url", host: "127.0.0.1:13773" });
assert.deepEqual(decision, { allowed: false, origin: "not a url" });
});

// Without a Host there is nothing to compare against, so a present Origin
// cannot be shown to be same-origin and must not be given the benefit of
// the doubt.
it("refuses a present Origin when the Host header is missing", () => {
const decision = decide({ origin: "http://127.0.0.1:13773" });
assert.deepEqual(decision, { allowed: false, origin: "http://127.0.0.1:13773" });
});
});
});
Loading
Loading