Skip to content
Open
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
35 changes: 32 additions & 3 deletions ts/packages/agents/browser/src/agent/browserActionHandler.mts
Original file line number Diff line number Diff line change
Expand Up @@ -592,8 +592,31 @@ async function updateBrowserContext(
}

if (!context.agentContext.viewProcess) {
context.agentContext.viewProcess =
await createViewServiceHost(context);
const viewProcess = await createViewServiceHost(context);
if (viewProcess) {
context.agentContext.viewProcess = viewProcess;
// Defensive cleanup if the child crashes mid-session.
// The dispatcher's PortRegistrar leaves stale entries
// bounded to "until respawn or session end", but a
// crashed child should release its registration eagerly
// so the entry doesn't shadow a fresh bind. The
// identity guard prevents a late-firing `exit` event on
// a previously-replaced process from clobbering a newer
// registration; the explicit disable path (which also
// releases) is naturally idempotent under `?.release()`.
viewProcess.once("exit", () => {
if (context.agentContext.viewProcess !== viewProcess) {
return;
}
context.agentContext.viewPortRegistration?.release();
context.agentContext.viewPortRegistration = undefined;
context.agentContext.viewProcess = undefined;
// Reset cached port so respawn forks with arg "0"
// (OS-assigned) instead of trying to re-bind the
// stale port — mirrors the disable/close paths.
context.agentContext.localHostPort = 0;
});
}
}

if (context.agentContext.browserSchemaEnabled) {
Expand Down Expand Up @@ -783,6 +806,8 @@ async function updateBrowserContext(
if (context.agentContext.viewProcess) {
context.agentContext.viewProcess.kill();
context.agentContext.viewProcess = undefined;
context.agentContext.viewPortRegistration?.release();
context.agentContext.viewPortRegistration = undefined;
// Reset to OS-assigned so a subsequent re-enable forks
// server.mjs with arg "0" instead of the stale port. The
// killed child may still hold the old port for a brief
Expand All @@ -806,6 +831,8 @@ async function closeBrowserContext(
if (context.agentContext.viewProcess) {
context.agentContext.viewProcess.kill();
context.agentContext.viewProcess = undefined;
context.agentContext.viewPortRegistration?.release();
context.agentContext.viewPortRegistration = undefined;
context.agentContext.localHostPort = 0;
}
}
Expand Down Expand Up @@ -2471,7 +2498,9 @@ async function createViewServiceHost(
childProcess.on("message", function (message: any) {
if (message?.type === "Success") {
context.agentContext.localHostPort = message.port;
context.setLocalHostPort(message.port);
context.agentContext.viewPortRegistration?.release();
context.agentContext.viewPortRegistration =
context.registerPort("view", message.port);
resolve(childProcess);
Comment thread
TalZaccai marked this conversation as resolved.
} else if (message === "Failure") {
resolve(undefined);
Expand Down
4 changes: 4 additions & 0 deletions ts/packages/agents/browser/src/agent/browserActions.mts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,10 @@ export type BrowserActionContext = {
index: IndexData | undefined;
viewProcess?: ChildProcess | undefined;
localHostPort: number;
// Handle returned by sessionContext.registerPort for the views
// server (PDF viewer / per-session HTML views). Released on
// updateBrowserContext(false, ...) or closeAgentContext.
viewPortRegistration?: { release: () => void } | undefined;
webFlowStore?: WebFlowStore | undefined;
choiceManager?: ChoiceManager | undefined;
lastInferredActions?: any[] | undefined;
Expand Down
17 changes: 17 additions & 0 deletions ts/packages/agents/browser/src/views/server/core/baseServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import path from "path";
import { fileURLToPath } from "url";
import { FeatureConfig, ServerConfig, SSEManager } from "./types.js";
import { SSEManagerImpl } from "./sseManager.js";
import { isAllowedViewOrigin } from "./originAllowlist.js";
import registerDebug from "debug";

const debug = registerDebug("typeagent:views:server:core");
Expand Down Expand Up @@ -37,6 +38,22 @@ export class BaseServer {
* Setup core middleware
*/
private setupMiddleware(): void {
// Origin allowlist — runs before everything else so non-loopback
// requests get HTTP 403 without consuming rate-limit budget or
// touching feature routes. The bind is loopback-only, but a
// malicious local web page in any browser tab can still hit
// `http://localhost:<port>` via fetch/XHR/iframe; the gate stops
// that.
this.app.use((req, res, next) => {
const origin = req.headers.origin;
Comment thread
TalZaccai marked this conversation as resolved.
if (isAllowedViewOrigin(origin)) {
next();
return;
}
debug(`Rejecting request from origin ${origin}`);
res.status(403).send("Origin not allowed");
});

// Rate limiting
const limiter = rateLimit({
windowMs: this.config.rateLimitWindow || 60000,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

import { createAgentOriginAllowlist } from "websocket-utils/originAllowlist";

/**
* Origin allowlist for the browser views server (PDF viewer + other
* per-session HTML views forked as a child process by the browser
* agent).
*
* Distinct from `agents/browser/src/agent/originAllowlist.mts`, which
* gates the WS bridge used by the typeagent Chrome/Edge extension. The
* views server is consumed by:
* - the Electron shell's inline browser (origin
* `http(s)://localhost(:port)` / `127.0.0.1` / `[::1]`),
* - external loopback browser tabs the user opens manually,
* - same-origin XHR/fetch from the served HTML (Origin absent or the
* server's own loopback origin).
*
* No browser-extension scheme is accepted here — this listener does not
* back any extension UI. See {@link createAgentOriginAllowlist} for the
* shared loopback + no-Origin baseline.
*
* `Origin: "null"` (sent by `file://` pages and sandboxed iframes) is
* rejected — only regular browser tabs and same-origin fetches are
* legitimate clients, so an opaque-origin caller is necessarily
* something we do not want to honor.
*
* Anything else is rejected with HTTP 403 before the route handler runs.
*/
export const isAllowedViewOrigin = createAgentOriginAllowlist({
allowNullOrigin: false,
});
67 changes: 67 additions & 0 deletions ts/packages/agents/browser/test/unit/agentWebSocketServer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ jest.mock("debug", () => {

import { AgentWebSocketServer } from "../../src/agent/agentWebSocketServer.mjs";
import { isAllowedAgentOrigin } from "../../src/agent/originAllowlist.mjs";
import { createAgentOriginAllowlist } from "websocket-utils/originAllowlist";

function makeMockSocket() {
const handlers: Record<string, Function> = {};
Expand Down Expand Up @@ -425,3 +426,69 @@ describe("isAllowedAgentOrigin", () => {
expect(isAllowedAgentOrigin("not a url")).toBe(false);
});
});

// Direct tests of the shared factory cover the policy knobs we expose
// (`allowNullOrigin`, the `string[]` header normalization). The
// agent-side wrapper above tests the default `allowNullOrigin: true`
// branch as a side effect; these tests add explicit coverage for the
// view-server posture where `"null"` must be rejected.
describe("createAgentOriginAllowlist", () => {
describe("allowNullOrigin option", () => {
it('rejects Origin: "null" when allowNullOrigin is false', () => {
const allow = createAgentOriginAllowlist({
allowNullOrigin: false,
});
expect(allow("null")).toBe(false);
});
it("still allows missing/empty Origin when allowNullOrigin is false", () => {
const allow = createAgentOriginAllowlist({
allowNullOrigin: false,
});
expect(allow(undefined)).toBe(true);
expect(allow("")).toBe(true);
});
it("still allows loopback origins when allowNullOrigin is false", () => {
const allow = createAgentOriginAllowlist({
allowNullOrigin: false,
});
expect(allow("http://localhost:1234")).toBe(true);
expect(allow("http://127.0.0.1")).toBe(true);
expect(allow("http://[::1]:5173")).toBe(true);
});
it('accepts Origin: "null" by default (backwards-compat)', () => {
const allow = createAgentOriginAllowlist();
expect(allow("null")).toBe(true);
});
it('accepts Origin: "null" when allowNullOrigin is explicitly true', () => {
const allow = createAgentOriginAllowlist({
allowNullOrigin: true,
});
expect(allow("null")).toBe(true);
});
});
describe("string[] header normalization", () => {
// Node's TS header types claim repeated headers may surface as
// `string[]`. At runtime the parser joins repeated Origin
// headers into a single comma-separated string, so the array
// form is not expected — but if it ever does arrive, anything
// other than a single entry is inherently ambiguous and must be
// rejected.
it("rejects an empty array", () => {
const allow = createAgentOriginAllowlist();
expect(allow([])).toBe(false);
});
it("rejects an array with more than one entry", () => {
const allow = createAgentOriginAllowlist();
expect(
allow(["http://localhost", "https://evil.example.com"]),
).toBe(false);
// Even two loopback entries are ambiguous and rejected.
expect(allow(["http://localhost", "http://127.0.0.1"])).toBe(false);
});
it("normalizes a single-element array to that entry", () => {
const allow = createAgentOriginAllowlist();
expect(allow(["http://localhost:1234"])).toBe(true);
expect(allow(["https://evil.example.com"])).toBe(false);
});
});
});
1 change: 1 addition & 0 deletions ts/packages/agents/markdown/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@
"telemetry": "workspace:*",
"typechat": "^0.1.1",
"unist-util-visit": "^4.1.2",
"websocket-utils": "workspace:*",
"ws": "^8.20.1",
"y-prosemirror": "^1.2.3",
"y-protocols": "^1.0.5",
Expand Down
27 changes: 25 additions & 2 deletions ts/packages/agents/markdown/src/agent/markdownActionHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,10 @@ type MarkdownActionContext = {
currentFileName?: string | undefined;
viewProcess?: ChildProcess | undefined;
localHostPort: number;
// Handle returned by sessionContext.registerPort for the markdown
// preview / Yjs WebSocket server. Released on
// updateMarkdownContext(false, ...).
viewPortRegistration?: { release: () => void } | undefined;
};

async function handleUICommand(
Expand Down Expand Up @@ -302,9 +306,26 @@ async function updateMarkdownContext(
context.agentContext.localHostPort,
);
if (result) {
context.agentContext.viewProcess = result.process;
const viewProcess = result.process;
context.agentContext.viewProcess = viewProcess;
context.agentContext.localHostPort = result.port;
context.setLocalHostPort(result.port);
context.agentContext.viewPortRegistration?.release();
context.agentContext.viewPortRegistration =
context.registerPort("view", result.port);
Comment thread
TalZaccai marked this conversation as resolved.
// Defensive cleanup if the child crashes mid-session.
// The identity guard prevents a late-firing `exit`
// event on a previously-replaced process from
// clobbering a newer registration; the explicit
// disable path (which also releases) is naturally
// idempotent under `?.release()`.
viewProcess.once("exit", () => {
if (context.agentContext.viewProcess !== viewProcess) {
return;
}
context.agentContext.viewPortRegistration?.release();
context.agentContext.viewPortRegistration = undefined;
context.agentContext.viewProcess = undefined;
});
}
}
}
Expand All @@ -314,6 +335,8 @@ async function updateMarkdownContext(
if (context.agentContext.viewProcess) {
context.agentContext.viewProcess.kill();
context.agentContext.viewProcess = undefined;
context.agentContext.viewPortRegistration?.release();
context.agentContext.viewPortRegistration = undefined;
}
}
}
Expand Down
26 changes: 26 additions & 0 deletions ts/packages/agents/markdown/src/view/route/originAllowlist.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

import { createAgentOriginAllowlist } from "websocket-utils/originAllowlist";

/**
* Origin allowlist for the markdown view server (HTTP preview + Yjs
* collaboration WebSocket).
*
* Accepts only the shared loopback + no-Origin baseline documented on
* {@link createAgentOriginAllowlist} (loopback browser tabs, the
* Electron shell's inline browser, same-origin XHR/fetch from the
* preview page, and Node `ws`/HTTP clients). The server binds to
* localhost, so this is loopback-restricted at the OS level.
*
* `Origin: "null"` (sent by `file://` pages and sandboxed iframes) is
* rejected — the preview server only ever serves regular browser tabs
* and same-origin fetches, so an opaque-origin caller is necessarily
* something we do not want to honor.
*
* Anything else is rejected with HTTP 403 (HTTP routes) or 403 on the
* upgrade response (WebSocket).
*/
export const isAllowedViewOrigin = createAgentOriginAllowlist({
allowNullOrigin: false,
});
36 changes: 36 additions & 0 deletions ts/packages/agents/markdown/src/view/route/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import * as encoding from "lib0/encoding";
import * as decoding from "lib0/decoding";
import registerDebug from "debug";
import sanitizeFilename from "sanitize-filename";
import { isAllowedViewOrigin } from "./originAllowlist.js";

const debug = registerDebug("typeagent:markdown:service");

Expand All @@ -26,6 +27,23 @@ const port = parseInt(process.argv[2]);
if (isNaN(port)) {
throw new Error("Port must be a number");
}

// Origin allowlist — runs before everything else so non-loopback
// requests get HTTP 403 without consuming rate-limit budget or hitting
// the route handlers. The server binds to localhost, but any local
// browser tab can still hit `http://localhost:<port>` via fetch/XHR;
// the gate stops cross-origin reads of the live document and uploaded
// files. The Y.js WebSocket upgrade is gated separately in
// createYjsWSServer.
app.use((req, res, next) => {
const origin = req.headers.origin;
Comment thread
TalZaccai marked this conversation as resolved.
if (isAllowedViewOrigin(origin)) {
next();
return;
}
debug(`Rejecting request from origin ${origin}`);
res.status(403).send("Origin not allowed");
});
const limiter = rateLimit({
windowMs: 60000,
max: 100, // limit each IP to 100 requests per windowMs
Expand Down Expand Up @@ -2211,6 +2229,24 @@ function createYjsWSServer(server: http.Server): WebSocketServer {

server.on("upgrade", (request, socket, head) => {
try {
// Origin gate — same allowlist as the HTTP middleware. The
// Yjs WS carries the full document content + edit stream,
// so loopback bind alone isn't enough; any local web page
// could otherwise open a WS to localhost:<port> and read /
// mutate the live doc.
const origin = request.headers.origin as string | undefined;
Comment thread
TalZaccai marked this conversation as resolved.
if (!isAllowedViewOrigin(origin)) {
debug(`Rejecting WS upgrade from origin ${origin}`);
socket.write(
"HTTP/1.1 403 Forbidden\r\n" +
"Connection: close\r\n" +
"Content-Length: 0\r\n" +
"\r\n",
);
socket.destroy();
return;
}

// Extract room name from URL path
const url = new URL(
request.url || "/",
Expand Down
1 change: 1 addition & 0 deletions ts/packages/agents/montage/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
"sharp": "^0.33.5",
"typeagent": "workspace:*",
"typechat": "^0.1.1",
"websocket-utils": "workspace:*",
"winreg": "^1.2.5"
},
"devDependencies": {
Expand Down
Loading
Loading