fix(diffs): harden viewer security and docs

This commit is contained in:
Peter Steinberger
2026-03-02 05:07:04 +00:00
parent 0ab2c82624
commit 4a1be98254
18 changed files with 837 additions and 152 deletions

View File

@@ -1,24 +1,34 @@
--- ---
title: "Diffs" title: "Diffs"
summary: "Read-only diff viewer and PNG renderer for agents (optional plugin tool)" summary: "Read-only diff viewer and PNG renderer for agents (optional plugin tool)"
description: "Use the optional Diffs plugin to render before/after text or unified patches as a gateway-hosted diff view or a PNG." description: "Use the optional Diffs plugin to render before or after text or unified patches as a gateway-hosted diff view, a PNG image, or both."
read_when: read_when:
- You want agents to show code or markdown edits as diffs - You want agents to show code or markdown edits as diffs
- You want a canvas-ready viewer URL or a rendered diff PNG - You want a canvas-ready viewer URL or a rendered diff PNG
- You need controlled, temporary diff artifacts with secure defaults
--- ---
# Diffs # Diffs
`diffs` is an **optional plugin tool** that renders a read-only diff from either: `diffs` is an optional plugin tool that turns change content into a read-only diff artifact for agents.
- arbitrary `before` / `after` text It accepts either:
- a unified patch
The tool can produce: - `before` and `after` text
- a unified `patch`
- a gateway-hosted viewer URL for canvas use It can return:
- a PNG image for message delivery
- both outputs together - a gateway viewer URL for canvas presentation
- a rendered PNG path for message delivery
- both outputs in one call
## Quick start
1. Enable the plugin.
2. Call `diffs` with `mode: "view"` for canvas-first flows.
3. Call `diffs` with `mode: "image"` for chat/image-first flows.
4. Call `diffs` with `mode: "both"` when you need both artifacts.
## Enable the plugin ## Enable the plugin
@@ -34,20 +44,18 @@ The tool can produce:
} }
``` ```
## What agents get back ## Typical agent workflow
- `mode: "view"` returns `details.viewerUrl` and `details.viewerPath` 1. Agent calls `diffs`.
- `mode: "image"` returns `details.imagePath` only 2. Agent reads `details` fields.
- `mode: "both"` returns the viewer details plus `details.imagePath` 3. Agent either:
- opens `details.viewerUrl` with `canvas present`
- sends `details.imagePath` with `message` using `path` or `filePath`
- does both
Typical agent patterns: ## Input examples
- open `details.viewerUrl` in canvas with `canvas present` Before and after:
- send `details.imagePath` with the `message` tool using `path` or `filePath`
## Tool inputs
Before/after input:
```json ```json
{ {
@@ -58,7 +66,7 @@ Before/after input:
} }
``` ```
Patch input: Patch:
```json ```json
{ {
@@ -67,16 +75,59 @@ Patch input:
} }
``` ```
Useful options: ## Tool input reference
- `mode`: `view`, `image`, or `both` All fields are optional unless noted:
- `layout`: `unified` or `split`
- `theme`: `light` or `dark` - `before` (`string`): original text. Required with `after` when `patch` is omitted.
- `expandUnchanged`: expand unchanged sections instead of collapsing them - `after` (`string`): updated text. Required with `before` when `patch` is omitted.
- `path`: display name for before/after input - `patch` (`string`): unified diff text. Mutually exclusive with `before` and `after`.
- `title`: explicit diff title - `path` (`string`): display filename for before and after mode.
- `ttlSeconds`: viewer artifact lifetime - `lang` (`string`): language override hint for before and after mode.
- `baseUrl`: override the gateway base URL used in the returned viewer link - `title` (`string`): viewer title override.
- `mode` (`"view" | "image" | "both"`): output mode. Defaults to plugin default `defaults.mode`.
- `theme` (`"light" | "dark"`): viewer theme. Defaults to plugin default `defaults.theme`.
- `layout` (`"unified" | "split"`): diff layout. Defaults to plugin default `defaults.layout`.
- `expandUnchanged` (`boolean`): expand unchanged sections.
- `ttlSeconds` (`number`): viewer artifact TTL in seconds. Default 1800, max 21600.
- `baseUrl` (`string`): viewer URL origin override. Must be `http` or `https`, no query/hash.
Validation and limits:
- `before` and `after` each max 512 KiB.
- `patch` max 2 MiB.
- `path` max 2048 bytes.
- `lang` max 128 bytes.
- `title` max 1024 bytes.
- Patch complexity cap: max 128 files and 120000 total lines.
- `patch` and `before` or `after` together are rejected.
## Output details contract
The tool returns structured metadata under `details`.
Shared fields for modes that create a viewer:
- `artifactId`
- `viewerUrl`
- `viewerPath`
- `title`
- `expiresAt`
- `inputKind`
- `fileCount`
- `mode`
Image fields when PNG is rendered:
- `imagePath`
- `path` (same value as `imagePath`, for message tool compatibility)
- `imageBytes`
Mode behavior summary:
- `mode: "view"`: viewer fields only.
- `mode: "image"`: image fields only, no viewer artifact.
- `mode: "both"`: viewer fields plus image fields. If screenshot fails, viewer still returns with `imageError`.
## Plugin defaults ## Plugin defaults
@@ -121,15 +172,149 @@ Supported defaults:
- `theme` - `theme`
- `mode` - `mode`
Explicit tool parameters override the plugin defaults. Explicit tool parameters override these defaults.
## Notes ## Security config
- Viewer pages are hosted locally by the gateway under `/plugins/diffs/...`. - `security.allowRemoteViewer` (`boolean`, default `false`)
- Viewer artifacts are ephemeral and stored locally. - `false`: non-loopback requests to viewer routes are denied.
- `mode: "image"` uses a faster image-only render path and does not create a viewer URL. - `true`: remote viewers are allowed if tokenized path is valid.
- PNG rendering requires a Chromium-compatible browser. If auto-detection is not enough, set `browser.executablePath`.
- Diff rendering is powered by [Diffs](https://diffs.com). Example:
```json5
{
plugins: {
entries: {
diffs: {
enabled: true,
config: {
security: {
allowRemoteViewer: false,
},
},
},
},
},
}
```
## Artifact lifecycle and storage
- Artifacts are stored under the temp subfolder: `$TMPDIR/openclaw-diffs`.
- Viewer artifact metadata contains:
- random artifact ID (20 hex chars)
- random token (48 hex chars)
- `createdAt` and `expiresAt`
- stored `viewer.html` path
- Default viewer TTL is 30 minutes when not specified.
- Maximum accepted viewer TTL is 6 hours.
- Cleanup runs opportunistically after artifact creation.
- Expired artifacts are deleted.
- Fallback cleanup removes stale folders older than 24 hours when metadata is missing.
## Viewer URL and network behavior
Viewer route:
- `/plugins/diffs/view/{artifactId}/{token}`
Viewer assets:
- `/plugins/diffs/assets/viewer.js`
- `/plugins/diffs/assets/viewer-runtime.js`
URL construction behavior:
- If `baseUrl` is provided, it is used after strict validation.
- Without `baseUrl`, viewer URL defaults to loopback `127.0.0.1`.
- If gateway bind mode is `custom` and `gateway.customBindHost` is set, that host is used.
`baseUrl` rules:
- Must be `http://` or `https://`.
- Query and hash are rejected.
- Origin plus optional base path is allowed.
## Security model
Viewer hardening:
- Loopback-only by default.
- Tokenized viewer paths with strict ID and token validation.
- Viewer response CSP:
- `default-src 'none'`
- scripts and assets only from self
- no outbound `connect-src`
- Remote miss throttling when remote access is enabled:
- 40 failures per 60 seconds
- 60 second lockout (`429 Too Many Requests`)
Image rendering hardening:
- Screenshot browser request routing is deny-by-default.
- Only local viewer assets from `http://127.0.0.1/plugins/diffs/assets/*` are allowed.
- External network requests are blocked.
## Browser requirements for image mode
`mode: "image"` and `mode: "both"` need a Chromium-compatible browser.
Resolution order:
1. `browser.executablePath` in OpenClaw config.
2. Environment variables:
- `OPENCLAW_BROWSER_EXECUTABLE_PATH`
- `BROWSER_EXECUTABLE_PATH`
- `PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH`
3. Platform command/path discovery fallback.
Common failure text:
- `Diff image rendering requires a Chromium-compatible browser...`
Fix by installing Chrome, Chromium, Edge, or Brave, or setting one of the executable path options above.
## Troubleshooting
Input validation errors:
- `Provide patch or both before and after text.`
- Include both `before` and `after`, or provide `patch`.
- `Provide either patch or before/after input, not both.`
- Do not mix input modes.
- `Invalid baseUrl: ...`
- Use `http(s)` origin with optional path, no query/hash.
- `{field} exceeds maximum size (...)`
- Reduce payload size.
- Large patch rejection
- Reduce patch file count or total lines.
Viewer accessibility issues:
- Viewer URL resolves to `127.0.0.1` by default.
- For remote access scenarios, either:
- pass `baseUrl` per tool call, or
- use `gateway.bind=custom` and `gateway.customBindHost`
- Enable `security.allowRemoteViewer` only when you intend external viewer access.
Artifact not found:
- Artifact expired due TTL.
- Token or path changed.
- Cleanup removed stale data.
## Operational guidance
- Prefer `mode: "view"` for local interactive reviews in canvas.
- Prefer `mode: "image"` for outbound chat channels that need an attachment.
- Keep `allowRemoteViewer` disabled unless your deployment requires remote viewer URLs.
- Set explicit short `ttlSeconds` for sensitive diffs.
- Avoid sending secrets in diff input when not required.
Diff rendering engine:
- Powered by [Diffs](https://diffs.com).
## Related docs ## Related docs

View File

@@ -52,7 +52,13 @@ Useful options:
- `path`: display name for before/after input - `path`: display name for before/after input
- `title`: explicit viewer title - `title`: explicit viewer title
- `ttlSeconds`: artifact lifetime - `ttlSeconds`: artifact lifetime
- `baseUrl`: override the gateway base URL used in the returned viewer link - `baseUrl`: override the gateway base URL used in the returned viewer link (origin or origin+base path only; no query/hash)
Input safety limits:
- `before` / `after`: max 512 KiB each
- `patch`: max 2 MiB
- patch rendering cap: max 128 files / 120,000 lines
## Plugin Defaults ## Plugin Defaults
@@ -86,6 +92,10 @@ Set plugin-wide defaults in `~/.openclaw/openclaw.json`:
Explicit tool parameters still win over these defaults. Explicit tool parameters still win over these defaults.
Security options:
- `security.allowRemoteViewer` (default `false`): allows non-loopback access to `/plugins/diffs/view/...` token URLs
## Example Agent Prompts ## Example Agent Prompts
Open in canvas: Open in canvas:
@@ -152,6 +162,8 @@ diff --git a/src/example.ts b/src/example.ts
## Notes ## Notes
- The viewer is hosted locally through the gateway under `/plugins/diffs/...`. - The viewer is hosted locally through the gateway under `/plugins/diffs/...`.
- Artifacts are ephemeral and stored in the local temp directory. - Artifacts are ephemeral and stored in the plugin temp subfolder (`$TMPDIR/openclaw-diffs`).
- Default viewer URLs use loopback (`127.0.0.1`) unless you set `baseUrl` (or use `gateway.bind=custom` + `gateway.customBindHost`).
- Remote viewer misses are throttled to reduce token-guess abuse.
- PNG rendering requires a Chromium-compatible browser. Set `browser.executablePath` if auto-detection is not enough. - PNG rendering requires a Chromium-compatible browser. Set `browser.executablePath` if auto-detection is not enough.
- Diff rendering is powered by [Diffs](https://diffs.com). - Diff rendering is powered by [Diffs](https://diffs.com).

View File

@@ -110,10 +110,10 @@ describe("diffs plugin registration", () => {
); );
const res = createMockServerResponse(); const res = createMockServerResponse();
const handled = await registeredHttpHandler?.( const handled = await registeredHttpHandler?.(
{ localReq({
method: "GET", method: "GET",
url: viewerPath, url: viewerPath,
} as IncomingMessage, }),
res, res,
); );
@@ -127,3 +127,10 @@ describe("diffs plugin registration", () => {
expect(String(res.body)).toContain("--diffs-line-height: 30px;"); expect(String(res.body)).toContain("--diffs-line-height: 30px;");
}); });
}); });
function localReq(input: { method: string; url: string }): IncomingMessage {
return {
...input,
socket: { remoteAddress: "127.0.0.1" },
} as unknown as IncomingMessage;
}

View File

@@ -1,7 +1,11 @@
import path from "node:path"; import path from "node:path";
import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk"; import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk";
import { diffsPluginConfigSchema, resolveDiffsPluginDefaults } from "./src/config.js"; import {
diffsPluginConfigSchema,
resolveDiffsPluginDefaults,
resolveDiffsPluginSecurity,
} from "./src/config.js";
import { createDiffsHttpHandler } from "./src/http.js"; import { createDiffsHttpHandler } from "./src/http.js";
import { DIFFS_AGENT_GUIDANCE } from "./src/prompt-guidance.js"; import { DIFFS_AGENT_GUIDANCE } from "./src/prompt-guidance.js";
import { DiffArtifactStore } from "./src/store.js"; import { DiffArtifactStore } from "./src/store.js";
@@ -14,13 +18,20 @@ const plugin = {
configSchema: diffsPluginConfigSchema, configSchema: diffsPluginConfigSchema,
register(api: OpenClawPluginApi) { register(api: OpenClawPluginApi) {
const defaults = resolveDiffsPluginDefaults(api.pluginConfig); const defaults = resolveDiffsPluginDefaults(api.pluginConfig);
const security = resolveDiffsPluginSecurity(api.pluginConfig);
const store = new DiffArtifactStore({ const store = new DiffArtifactStore({
rootDir: path.join(resolvePreferredOpenClawTmpDir(), "openclaw-diffs"), rootDir: path.join(resolvePreferredOpenClawTmpDir(), "openclaw-diffs"),
logger: api.logger, logger: api.logger,
}); });
api.registerTool(createDiffsTool({ api, store, defaults })); api.registerTool(createDiffsTool({ api, store, defaults }));
api.registerHttpHandler(createDiffsHttpHandler({ store, logger: api.logger })); api.registerHttpHandler(
createDiffsHttpHandler({
store,
logger: api.logger,
allowRemoteViewer: security.allowRemoteViewer,
}),
);
api.on("before_prompt_build", async () => ({ api.on("before_prompt_build", async () => ({
prependContext: DIFFS_AGENT_GUIDANCE, prependContext: DIFFS_AGENT_GUIDANCE,
})); }));

View File

@@ -42,6 +42,10 @@
"defaults.mode": { "defaults.mode": {
"label": "Default Output Mode", "label": "Default Output Mode",
"help": "Tool default when mode is omitted. Use view for canvas/gateway viewer, image for PNG, or both." "help": "Tool default when mode is omitted. Use view for canvas/gateway viewer, image for PNG, or both."
},
"security.allowRemoteViewer": {
"label": "Allow Remote Viewer",
"help": "Allow non-loopback access to diff viewer URLs when the token path is known."
} }
}, },
"configSchema": { "configSchema": {
@@ -101,6 +105,16 @@
"default": "both" "default": "both"
} }
} }
},
"security": {
"type": "object",
"additionalProperties": false,
"properties": {
"allowRemoteViewer": {
"type": "boolean",
"default": false
}
}
} }
} }
} }

View File

@@ -63,9 +63,28 @@ export class PlaywrightDiffScreenshotter implements DiffScreenshotter {
deviceScaleFactor: 2, deviceScaleFactor: 2,
colorScheme: params.theme, colorScheme: params.theme,
}); });
await page.route(`http://127.0.0.1${VIEWER_ASSET_PREFIX}*`, async (route) => { await page.route("**/*", async (route) => {
const pathname = new URL(route.request().url()).pathname; const requestUrl = route.request().url();
const asset = await getServedViewerAsset(pathname); if (requestUrl === "about:blank" || requestUrl.startsWith("data:")) {
await route.continue();
return;
}
let parsed: URL;
try {
parsed = new URL(requestUrl);
} catch {
await route.abort();
return;
}
if (parsed.protocol !== "http:" || parsed.hostname !== "127.0.0.1") {
await route.abort();
return;
}
if (!parsed.pathname.startsWith(VIEWER_ASSET_PREFIX)) {
await route.abort();
return;
}
const asset = await getServedViewerAsset(parsed.pathname);
if (!asset) { if (!asset) {
await route.abort(); await route.abort();
return; return;

View File

@@ -1,5 +1,10 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { DEFAULT_DIFFS_TOOL_DEFAULTS, resolveDiffsPluginDefaults } from "./config.js"; import {
DEFAULT_DIFFS_PLUGIN_SECURITY,
DEFAULT_DIFFS_TOOL_DEFAULTS,
resolveDiffsPluginDefaults,
resolveDiffsPluginSecurity,
} from "./config.js";
describe("resolveDiffsPluginDefaults", () => { describe("resolveDiffsPluginDefaults", () => {
it("returns built-in defaults when config is missing", () => { it("returns built-in defaults when config is missing", () => {
@@ -70,3 +75,15 @@ describe("resolveDiffsPluginDefaults", () => {
}); });
}); });
}); });
describe("resolveDiffsPluginSecurity", () => {
it("defaults to local-only viewer access", () => {
expect(resolveDiffsPluginSecurity(undefined)).toEqual(DEFAULT_DIFFS_PLUGIN_SECURITY);
});
it("allows opt-in remote viewer access", () => {
expect(resolveDiffsPluginSecurity({ security: { allowRemoteViewer: true } })).toEqual({
allowRemoteViewer: true,
});
});
});

View File

@@ -25,6 +25,9 @@ type DiffsPluginConfig = {
theme?: DiffTheme; theme?: DiffTheme;
mode?: DiffMode; mode?: DiffMode;
}; };
security?: {
allowRemoteViewer?: boolean;
};
}; };
export const DEFAULT_DIFFS_TOOL_DEFAULTS: DiffToolDefaults = { export const DEFAULT_DIFFS_TOOL_DEFAULTS: DiffToolDefaults = {
@@ -40,6 +43,14 @@ export const DEFAULT_DIFFS_TOOL_DEFAULTS: DiffToolDefaults = {
mode: "both", mode: "both",
}; };
export type DiffsPluginSecurityConfig = {
allowRemoteViewer: boolean;
};
export const DEFAULT_DIFFS_PLUGIN_SECURITY: DiffsPluginSecurityConfig = {
allowRemoteViewer: false,
};
const DIFFS_PLUGIN_CONFIG_JSON_SCHEMA = { const DIFFS_PLUGIN_CONFIG_JSON_SCHEMA = {
type: "object", type: "object",
additionalProperties: false, additionalProperties: false,
@@ -89,6 +100,16 @@ const DIFFS_PLUGIN_CONFIG_JSON_SCHEMA = {
}, },
}, },
}, },
security: {
type: "object",
additionalProperties: false,
properties: {
allowRemoteViewer: {
type: "boolean",
default: DEFAULT_DIFFS_PLUGIN_SECURITY.allowRemoteViewer,
},
},
},
}, },
} as const; } as const;
@@ -135,6 +156,21 @@ export function resolveDiffsPluginDefaults(config: unknown): DiffToolDefaults {
}; };
} }
export function resolveDiffsPluginSecurity(config: unknown): DiffsPluginSecurityConfig {
if (!config || typeof config !== "object" || Array.isArray(config)) {
return { ...DEFAULT_DIFFS_PLUGIN_SECURITY };
}
const security = (config as DiffsPluginConfig).security;
if (!security || typeof security !== "object" || Array.isArray(security)) {
return { ...DEFAULT_DIFFS_PLUGIN_SECURITY };
}
return {
allowRemoteViewer: security.allowRemoteViewer === true,
};
}
export function toPresentationDefaults(defaults: DiffToolDefaults): DiffPresentationDefaults { export function toPresentationDefaults(defaults: DiffToolDefaults): DiffPresentationDefaults {
const { const {
fontFamily, fontFamily,

View File

@@ -31,10 +31,10 @@ describe("createDiffsHttpHandler", () => {
const handler = createDiffsHttpHandler({ store }); const handler = createDiffsHttpHandler({ store });
const res = createMockServerResponse(); const res = createMockServerResponse();
const handled = await handler( const handled = await handler(
{ localReq({
method: "GET", method: "GET",
url: artifact.viewerPath, url: artifact.viewerPath,
} as IncomingMessage, }),
res, res,
); );
@@ -55,10 +55,10 @@ describe("createDiffsHttpHandler", () => {
const handler = createDiffsHttpHandler({ store }); const handler = createDiffsHttpHandler({ store });
const res = createMockServerResponse(); const res = createMockServerResponse();
const handled = await handler( const handled = await handler(
{ localReq({
method: "GET", method: "GET",
url: artifact.viewerPath.replace(artifact.token, "bad-token"), url: artifact.viewerPath.replace(artifact.token, "bad-token"),
} as IncomingMessage, }),
res, res,
); );
@@ -70,10 +70,10 @@ describe("createDiffsHttpHandler", () => {
const handler = createDiffsHttpHandler({ store }); const handler = createDiffsHttpHandler({ store });
const res = createMockServerResponse(); const res = createMockServerResponse();
const handled = await handler( const handled = await handler(
{ localReq({
method: "GET", method: "GET",
url: "/plugins/diffs/view/not-a-real-id/not-a-real-token", url: "/plugins/diffs/view/not-a-real-id/not-a-real-token",
} as IncomingMessage, }),
res, res,
); );
@@ -85,10 +85,10 @@ describe("createDiffsHttpHandler", () => {
const handler = createDiffsHttpHandler({ store }); const handler = createDiffsHttpHandler({ store });
const res = createMockServerResponse(); const res = createMockServerResponse();
const handled = await handler( const handled = await handler(
{ localReq({
method: "GET", method: "GET",
url: "/plugins/diffs/assets/viewer.js", url: "/plugins/diffs/assets/viewer.js",
} as IncomingMessage, }),
res, res,
); );
@@ -101,10 +101,10 @@ describe("createDiffsHttpHandler", () => {
const handler = createDiffsHttpHandler({ store }); const handler = createDiffsHttpHandler({ store });
const res = createMockServerResponse(); const res = createMockServerResponse();
const handled = await handler( const handled = await handler(
{ localReq({
method: "GET", method: "GET",
url: "/plugins/diffs/assets/viewer-runtime.js", url: "/plugins/diffs/assets/viewer-runtime.js",
} as IncomingMessage, }),
res, res,
); );
@@ -112,4 +112,89 @@ describe("createDiffsHttpHandler", () => {
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(200);
expect(String(res.body)).toContain("openclawDiffsReady"); expect(String(res.body)).toContain("openclawDiffsReady");
}); });
it("blocks non-loopback viewer access by default", async () => {
const artifact = await store.createArtifact({
html: "<html>viewer</html>",
title: "Demo",
inputKind: "before_after",
fileCount: 1,
});
const handler = createDiffsHttpHandler({ store });
const res = createMockServerResponse();
const handled = await handler(
remoteReq({
method: "GET",
url: artifact.viewerPath,
}),
res,
);
expect(handled).toBe(true);
expect(res.statusCode).toBe(404);
});
it("allows remote access when allowRemoteViewer is enabled", async () => {
const artifact = await store.createArtifact({
html: "<html>viewer</html>",
title: "Demo",
inputKind: "before_after",
fileCount: 1,
});
const handler = createDiffsHttpHandler({ store, allowRemoteViewer: true });
const res = createMockServerResponse();
const handled = await handler(
remoteReq({
method: "GET",
url: artifact.viewerPath,
}),
res,
);
expect(handled).toBe(true);
expect(res.statusCode).toBe(200);
expect(res.body).toBe("<html>viewer</html>");
});
it("rate-limits repeated remote misses", async () => {
const handler = createDiffsHttpHandler({ store, allowRemoteViewer: true });
for (let i = 0; i < 40; i++) {
const miss = createMockServerResponse();
await handler(
remoteReq({
method: "GET",
url: "/plugins/diffs/view/aaaaaaaaaaaaaaaaaaaa/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
}),
miss,
);
expect(miss.statusCode).toBe(404);
}
const limited = createMockServerResponse();
await handler(
remoteReq({
method: "GET",
url: "/plugins/diffs/view/aaaaaaaaaaaaaaaaaaaa/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
}),
limited,
);
expect(limited.statusCode).toBe(429);
});
}); });
function localReq(input: { method: string; url: string }): IncomingMessage {
return {
...input,
socket: { remoteAddress: "127.0.0.1" },
} as unknown as IncomingMessage;
}
function remoteReq(input: { method: string; url: string }): IncomingMessage {
return {
...input,
socket: { remoteAddress: "203.0.113.10" },
} as unknown as IncomingMessage;
}

View File

@@ -5,6 +5,10 @@ import { DIFF_ARTIFACT_ID_PATTERN, DIFF_ARTIFACT_TOKEN_PATTERN } from "./types.j
import { VIEWER_ASSET_PREFIX, getServedViewerAsset } from "./viewer-assets.js"; import { VIEWER_ASSET_PREFIX, getServedViewerAsset } from "./viewer-assets.js";
const VIEW_PREFIX = "/plugins/diffs/view/"; const VIEW_PREFIX = "/plugins/diffs/view/";
const VIEWER_MAX_FAILURES_PER_WINDOW = 40;
const VIEWER_FAILURE_WINDOW_MS = 60_000;
const VIEWER_LOCKOUT_MS = 60_000;
const VIEWER_LIMITER_MAX_KEYS = 2_048;
const VIEWER_CONTENT_SECURITY_POLICY = [ const VIEWER_CONTENT_SECURITY_POLICY = [
"default-src 'none'", "default-src 'none'",
"script-src 'self'", "script-src 'self'",
@@ -20,7 +24,10 @@ const VIEWER_CONTENT_SECURITY_POLICY = [
export function createDiffsHttpHandler(params: { export function createDiffsHttpHandler(params: {
store: DiffArtifactStore; store: DiffArtifactStore;
logger?: PluginLogger; logger?: PluginLogger;
allowRemoteViewer?: boolean;
}) { }) {
const viewerFailureLimiter = new ViewerFailureLimiter();
return async (req: IncomingMessage, res: ServerResponse): Promise<boolean> => { return async (req: IncomingMessage, res: ServerResponse): Promise<boolean> => {
const parsed = parseRequestUrl(req.url); const parsed = parseRequestUrl(req.url);
if (!parsed) { if (!parsed) {
@@ -35,11 +42,29 @@ export function createDiffsHttpHandler(params: {
return false; return false;
} }
const remoteKey = normalizeRemoteClientKey(req.socket?.remoteAddress);
const localRequest = isLoopbackClientIp(remoteKey);
if (!localRequest && params.allowRemoteViewer !== true) {
respondText(res, 404, "Diff not found");
return true;
}
if (req.method !== "GET" && req.method !== "HEAD") { if (req.method !== "GET" && req.method !== "HEAD") {
respondText(res, 405, "Method not allowed"); respondText(res, 405, "Method not allowed");
return true; return true;
} }
if (!localRequest) {
const throttled = viewerFailureLimiter.check(remoteKey);
if (!throttled.allowed) {
res.statusCode = 429;
setSharedHeaders(res, "text/plain; charset=utf-8");
res.setHeader("Retry-After", String(Math.max(1, Math.ceil(throttled.retryAfterMs / 1000))));
res.end("Too Many Requests");
return true;
}
}
const pathParts = parsed.pathname.split("/").filter(Boolean); const pathParts = parsed.pathname.split("/").filter(Boolean);
const id = pathParts[3]; const id = pathParts[3];
const token = pathParts[4]; const token = pathParts[4];
@@ -49,18 +74,27 @@ export function createDiffsHttpHandler(params: {
!DIFF_ARTIFACT_ID_PATTERN.test(id) || !DIFF_ARTIFACT_ID_PATTERN.test(id) ||
!DIFF_ARTIFACT_TOKEN_PATTERN.test(token) !DIFF_ARTIFACT_TOKEN_PATTERN.test(token)
) { ) {
if (!localRequest) {
viewerFailureLimiter.recordFailure(remoteKey);
}
respondText(res, 404, "Diff not found"); respondText(res, 404, "Diff not found");
return true; return true;
} }
const artifact = await params.store.getArtifact(id, token); const artifact = await params.store.getArtifact(id, token);
if (!artifact) { if (!artifact) {
if (!localRequest) {
viewerFailureLimiter.recordFailure(remoteKey);
}
respondText(res, 404, "Diff not found or expired"); respondText(res, 404, "Diff not found or expired");
return true; return true;
} }
try { try {
const html = await params.store.readHtml(id); const html = await params.store.readHtml(id);
if (!localRequest) {
viewerFailureLimiter.reset(remoteKey);
}
res.statusCode = 200; res.statusCode = 200;
setSharedHeaders(res, "text/html; charset=utf-8"); setSharedHeaders(res, "text/html; charset=utf-8");
res.setHeader("content-security-policy", VIEWER_CONTENT_SECURITY_POLICY); res.setHeader("content-security-policy", VIEWER_CONTENT_SECURITY_POLICY);
@@ -71,6 +105,9 @@ export function createDiffsHttpHandler(params: {
} }
return true; return true;
} catch (error) { } catch (error) {
if (!localRequest) {
viewerFailureLimiter.recordFailure(remoteKey);
}
params.logger?.warn(`Failed to serve diff artifact ${id}: ${String(error)}`); params.logger?.warn(`Failed to serve diff artifact ${id}: ${String(error)}`);
respondText(res, 500, "Failed to load diff"); respondText(res, 500, "Failed to load diff");
return true; return true;
@@ -134,3 +171,90 @@ function setSharedHeaders(res: ServerResponse, contentType: string): void {
res.setHeader("x-content-type-options", "nosniff"); res.setHeader("x-content-type-options", "nosniff");
res.setHeader("referrer-policy", "no-referrer"); res.setHeader("referrer-policy", "no-referrer");
} }
function normalizeRemoteClientKey(remoteAddress: string | undefined): string {
const normalized = remoteAddress?.trim().toLowerCase();
if (!normalized) {
return "unknown";
}
return normalized.startsWith("::ffff:") ? normalized.slice("::ffff:".length) : normalized;
}
function isLoopbackClientIp(clientIp: string): boolean {
return clientIp === "127.0.0.1" || clientIp === "::1";
}
type RateLimitCheckResult = {
allowed: boolean;
retryAfterMs: number;
};
type ViewerFailureState = {
windowStartMs: number;
failures: number;
lockUntilMs: number;
};
class ViewerFailureLimiter {
private readonly failures = new Map<string, ViewerFailureState>();
check(key: string): RateLimitCheckResult {
this.prune();
const state = this.failures.get(key);
if (!state) {
return { allowed: true, retryAfterMs: 0 };
}
const now = Date.now();
if (state.lockUntilMs > now) {
return { allowed: false, retryAfterMs: state.lockUntilMs - now };
}
if (now - state.windowStartMs >= VIEWER_FAILURE_WINDOW_MS) {
this.failures.delete(key);
return { allowed: true, retryAfterMs: 0 };
}
return { allowed: true, retryAfterMs: 0 };
}
recordFailure(key: string): void {
this.prune();
const now = Date.now();
const current = this.failures.get(key);
const next =
!current || now - current.windowStartMs >= VIEWER_FAILURE_WINDOW_MS
? {
windowStartMs: now,
failures: 1,
lockUntilMs: 0,
}
: {
...current,
failures: current.failures + 1,
};
if (next.failures >= VIEWER_MAX_FAILURES_PER_WINDOW) {
next.lockUntilMs = now + VIEWER_LOCKOUT_MS;
}
this.failures.set(key, next);
}
reset(key: string): void {
this.failures.delete(key);
}
private prune(): void {
if (this.failures.size < VIEWER_LIMITER_MAX_KEYS) {
return;
}
const now = Date.now();
for (const [key, state] of this.failures) {
if (state.lockUntilMs <= now && now - state.windowStartMs >= VIEWER_FAILURE_WINDOW_MS) {
this.failures.delete(key);
}
if (this.failures.size < VIEWER_LIMITER_MAX_KEYS) {
return;
}
}
if (this.failures.size >= VIEWER_LIMITER_MAX_KEYS) {
this.failures.clear();
}
}
}

View File

@@ -69,4 +69,30 @@ describe("renderDiffDocument", () => {
expect(rendered.fileCount).toBe(2); expect(rendered.fileCount).toBe(2);
expect(rendered.html).toContain("Workspace patch"); expect(rendered.html).toContain("Workspace patch");
}); });
it("rejects patches that exceed file-count limits", async () => {
const patch = Array.from({ length: 129 }, (_, i) => {
return [
`diff --git a/f${i}.ts b/f${i}.ts`,
`--- a/f${i}.ts`,
`+++ b/f${i}.ts`,
"@@ -1 +1 @@",
"-const x = 1;",
"+const x = 2;",
].join("\n");
}).join("\n");
await expect(
renderDiffDocument(
{
kind: "patch",
patch,
},
{
presentation: DEFAULT_DIFFS_TOOL_DEFAULTS,
expandUnchanged: false,
},
),
).rejects.toThrow("too many files");
});
}); });

View File

@@ -11,6 +11,8 @@ import type {
import { VIEWER_LOADER_PATH } from "./viewer-assets.js"; import { VIEWER_LOADER_PATH } from "./viewer-assets.js";
const DEFAULT_FILE_NAME = "diff.txt"; const DEFAULT_FILE_NAME = "diff.txt";
const MAX_PATCH_FILE_COUNT = 128;
const MAX_PATCH_TOTAL_LINES = 120_000;
function escapeCssString(value: string): string { function escapeCssString(value: string): string {
return value.replaceAll("\\", "\\\\").replaceAll('"', '\\"'); return value.replaceAll("\\", "\\\\").replaceAll('"', '\\"');
@@ -344,6 +346,17 @@ async function renderPatchDiff(
if (files.length === 0) { if (files.length === 0) {
throw new Error("Patch input did not contain any file diffs."); throw new Error("Patch input did not contain any file diffs.");
} }
if (files.length > MAX_PATCH_FILE_COUNT) {
throw new Error(`Patch input contains too many files (max ${MAX_PATCH_FILE_COUNT}).`);
}
const totalLines = files.reduce((sum, fileDiff) => {
const splitLines = Number.isFinite(fileDiff.splitLineCount) ? fileDiff.splitLineCount : 0;
const unifiedLines = Number.isFinite(fileDiff.unifiedLineCount) ? fileDiff.unifiedLineCount : 0;
return sum + Math.max(splitLines, unifiedLines, 0);
}, 0);
if (totalLines > MAX_PATCH_TOTAL_LINES) {
throw new Error(`Patch input is too large to render (max ${MAX_PATCH_TOTAL_LINES} lines).`);
}
const viewerPayloadOptions = buildDiffOptions(options); const viewerPayloadOptions = buildDiffOptions(options);
const imagePayloadOptions = buildDiffOptions(buildImageRenderOptions(options)); const imagePayloadOptions = buildDiffOptions(buildImageRenderOptions(options));

View File

@@ -62,6 +62,35 @@ describe("DiffArtifactStore", () => {
expect(updated.imagePath).toBe(imagePath); expect(updated.imagePath).toBe(imagePath);
}); });
it("rejects image paths that escape the store root", async () => {
const artifact = await store.createArtifact({
html: "<html>demo</html>",
title: "Demo",
inputKind: "before_after",
fileCount: 1,
});
await expect(store.updateImagePath(artifact.id, "../outside.png")).rejects.toThrow(
"escapes store root",
);
});
it("rejects tampered html metadata paths outside the store root", async () => {
const artifact = await store.createArtifact({
html: "<html>demo</html>",
title: "Demo",
inputKind: "before_after",
fileCount: 1,
});
const metaPath = path.join(rootDir, artifact.id, "meta.json");
const rawMeta = await fs.readFile(metaPath, "utf8");
const meta = JSON.parse(rawMeta) as { htmlPath: string };
meta.htmlPath = "../outside.html";
await fs.writeFile(metaPath, JSON.stringify(meta), "utf8");
await expect(store.readHtml(artifact.id)).rejects.toThrow("escapes store root");
});
it("allocates standalone image paths outside artifact metadata", async () => { it("allocates standalone image paths outside artifact metadata", async () => {
const imagePath = store.allocateStandaloneImagePath(); const imagePath = store.allocateStandaloneImagePath();
expect(imagePath).toMatch(/preview\.png$/); expect(imagePath).toMatch(/preview\.png$/);

View File

@@ -26,7 +26,7 @@ export class DiffArtifactStore {
private nextCleanupAt = 0; private nextCleanupAt = 0;
constructor(params: { rootDir: string; logger?: PluginLogger; cleanupIntervalMs?: number }) { constructor(params: { rootDir: string; logger?: PluginLogger; cleanupIntervalMs?: number }) {
this.rootDir = params.rootDir; this.rootDir = path.resolve(params.rootDir);
this.logger = params.logger; this.logger = params.logger;
this.cleanupIntervalMs = this.cleanupIntervalMs =
params.cleanupIntervalMs === undefined params.cleanupIntervalMs === undefined
@@ -59,7 +59,7 @@ export class DiffArtifactStore {
await fs.mkdir(artifactDir, { recursive: true }); await fs.mkdir(artifactDir, { recursive: true });
await fs.writeFile(htmlPath, params.html, "utf8"); await fs.writeFile(htmlPath, params.html, "utf8");
await this.writeMeta(meta); await this.writeMeta(meta);
this.maybeCleanupExpired(); this.scheduleCleanup();
return meta; return meta;
} }
@@ -83,7 +83,8 @@ export class DiffArtifactStore {
if (!meta) { if (!meta) {
throw new Error(`Diff artifact not found: ${id}`); throw new Error(`Diff artifact not found: ${id}`);
} }
return await fs.readFile(meta.htmlPath, "utf8"); const htmlPath = this.normalizeStoredPath(meta.htmlPath, "htmlPath");
return await fs.readFile(htmlPath, "utf8");
} }
async updateImagePath(id: string, imagePath: string): Promise<DiffArtifactMeta> { async updateImagePath(id: string, imagePath: string): Promise<DiffArtifactMeta> {
@@ -91,9 +92,10 @@ export class DiffArtifactStore {
if (!meta) { if (!meta) {
throw new Error(`Diff artifact not found: ${id}`); throw new Error(`Diff artifact not found: ${id}`);
} }
const normalizedImagePath = this.normalizeStoredPath(imagePath, "imagePath");
const next: DiffArtifactMeta = { const next: DiffArtifactMeta = {
...meta, ...meta,
imagePath, imagePath: normalizedImagePath,
}; };
await this.writeMeta(next); await this.writeMeta(next);
return next; return next;
@@ -108,6 +110,10 @@ export class DiffArtifactStore {
return path.join(this.artifactDir(id), "preview.png"); return path.join(this.artifactDir(id), "preview.png");
} }
scheduleCleanup(): void {
this.maybeCleanupExpired();
}
async cleanupExpired(): Promise<void> { async cleanupExpired(): Promise<void> {
await this.ensureRoot(); await this.ensureRoot();
const entries = await fs.readdir(this.rootDir, { withFileTypes: true }).catch(() => []); const entries = await fs.readdir(this.rootDir, { withFileTypes: true }).catch(() => []);
@@ -164,7 +170,7 @@ export class DiffArtifactStore {
} }
private artifactDir(id: string): string { private artifactDir(id: string): string {
return path.join(this.rootDir, id); return this.resolveWithinRoot(id);
} }
private metaPath(id: string): string { private metaPath(id: string): string {
@@ -191,6 +197,31 @@ export class DiffArtifactStore {
private async deleteArtifact(id: string): Promise<void> { private async deleteArtifact(id: string): Promise<void> {
await fs.rm(this.artifactDir(id), { recursive: true, force: true }).catch(() => {}); await fs.rm(this.artifactDir(id), { recursive: true, force: true }).catch(() => {});
} }
private resolveWithinRoot(...parts: string[]): string {
const candidate = path.resolve(this.rootDir, ...parts);
this.assertWithinRoot(candidate);
return candidate;
}
private normalizeStoredPath(rawPath: string, label: string): string {
const candidate = path.isAbsolute(rawPath)
? path.resolve(rawPath)
: path.resolve(this.rootDir, rawPath);
this.assertWithinRoot(candidate, label);
return candidate;
}
private assertWithinRoot(candidate: string, label = "path"): void {
const relative = path.relative(this.rootDir, candidate);
if (
relative === "" ||
(!relative.startsWith(`..${path.sep}`) && relative !== ".." && !path.isAbsolute(relative))
) {
return;
}
throw new Error(`Diff artifact ${label} escapes store root: ${candidate}`);
}
} }
function normalizeTtlMs(value?: number): number { function normalizeTtlMs(value?: number): number {

View File

@@ -40,6 +40,7 @@ describe("diffs tool", () => {
}); });
it("returns an image artifact in image mode", async () => { it("returns an image artifact in image mode", async () => {
const cleanupSpy = vi.spyOn(store, "scheduleCleanup");
const screenshotter = { const screenshotter = {
screenshotHtml: vi.fn(async ({ html, outputPath }: { html: string; outputPath: string }) => { screenshotHtml: vi.fn(async ({ html, outputPath }: { html: string; outputPath: string }) => {
expect(html).not.toContain("/plugins/diffs/assets/viewer.js"); expect(html).not.toContain("/plugins/diffs/assets/viewer.js");
@@ -68,6 +69,7 @@ describe("diffs tool", () => {
expect(result?.content).toHaveLength(1); expect(result?.content).toHaveLength(1);
expect((result?.details as Record<string, unknown>).imagePath).toBeDefined(); expect((result?.details as Record<string, unknown>).imagePath).toBeDefined();
expect((result?.details as Record<string, unknown>).viewerUrl).toBeUndefined(); expect((result?.details as Record<string, unknown>).viewerUrl).toBeUndefined();
expect(cleanupSpy).toHaveBeenCalledTimes(1);
}); });
it("falls back to view output when both mode cannot render an image", async () => { it("falls back to view output when both mode cannot render an image", async () => {
@@ -110,6 +112,38 @@ describe("diffs tool", () => {
).rejects.toThrow("Invalid baseUrl"); ).rejects.toThrow("Invalid baseUrl");
}); });
it("rejects oversized before/after payloads", async () => {
const tool = createDiffsTool({
api: createApi(),
store,
defaults: DEFAULT_DIFFS_TOOL_DEFAULTS,
});
const large = "x".repeat(600_000);
await expect(
tool.execute?.("tool-large-before", {
before: large,
after: "ok",
mode: "view",
}),
).rejects.toThrow("before exceeds maximum size");
});
it("rejects oversized patch payloads", async () => {
const tool = createDiffsTool({
api: createApi(),
store,
defaults: DEFAULT_DIFFS_TOOL_DEFAULTS,
});
await expect(
tool.execute?.("tool-large-patch", {
patch: "x".repeat(2_100_000),
mode: "view",
}),
).rejects.toThrow("patch exceeds maximum size");
});
it("uses configured defaults when tool params omit them", async () => { it("uses configured defaults when tool params omit them", async () => {
const tool = createDiffsTool({ const tool = createDiffsTool({
api: createApi(), api: createApi(),

View File

@@ -16,6 +16,12 @@ import {
} from "./types.js"; } from "./types.js";
import { buildViewerUrl, normalizeViewerBaseUrl } from "./url.js"; import { buildViewerUrl, normalizeViewerBaseUrl } from "./url.js";
const MAX_BEFORE_AFTER_BYTES = 512 * 1024;
const MAX_PATCH_BYTES = 2 * 1024 * 1024;
const MAX_TITLE_BYTES = 1_024;
const MAX_PATH_BYTES = 2_048;
const MAX_LANG_BYTES = 128;
function stringEnum<T extends readonly string[]>(values: T, description: string) { function stringEnum<T extends readonly string[]>(values: T, description: string) {
return Type.Unsafe<T[number]>({ return Type.Unsafe<T[number]>({
type: "string", type: "string",
@@ -28,12 +34,30 @@ const DiffsToolSchema = Type.Object(
{ {
before: Type.Optional(Type.String({ description: "Original text content." })), before: Type.Optional(Type.String({ description: "Original text content." })),
after: Type.Optional(Type.String({ description: "Updated text content." })), after: Type.Optional(Type.String({ description: "Updated text content." })),
patch: Type.Optional(Type.String({ description: "Unified diff or patch text." })), patch: Type.Optional(
path: Type.Optional(Type.String({ description: "Display path for before/after input." })), Type.String({
lang: Type.Optional( description: "Unified diff or patch text.",
Type.String({ description: "Optional language override for before/after input." }), maxLength: MAX_PATCH_BYTES,
}),
),
path: Type.Optional(
Type.String({
description: "Display path for before/after input.",
maxLength: MAX_PATH_BYTES,
}),
),
lang: Type.Optional(
Type.String({
description: "Optional language override for before/after input.",
maxLength: MAX_LANG_BYTES,
}),
),
title: Type.Optional(
Type.String({
description: "Optional title for the rendered diff.",
maxLength: MAX_TITLE_BYTES,
}),
), ),
title: Type.Optional(Type.String({ description: "Optional title for the rendered diff." })),
mode: Type.Optional( mode: Type.Optional(
stringEnum(DIFF_MODES, "Output mode: view, image, or both. Default: both."), stringEnum(DIFF_MODES, "Output mode: view, image, or both. Default: both."),
), ),
@@ -102,6 +126,7 @@ export function createDiffsTool(params: {
theme, theme,
}); });
const imageStats = await fs.stat(imagePath); const imageStats = await fs.stat(imagePath);
params.store.scheduleCleanup();
return { return {
content: [ content: [
@@ -217,27 +242,46 @@ function normalizeDiffInput(params: DiffsToolParams): DiffInput {
const after = params.after; const after = params.after;
if (patch) { if (patch) {
assertMaxBytes(patch, "patch", MAX_PATCH_BYTES);
if (before !== undefined || after !== undefined) { if (before !== undefined || after !== undefined) {
throw new PluginToolInputError("Provide either patch or before/after input, not both."); throw new PluginToolInputError("Provide either patch or before/after input, not both.");
} }
const title = params.title?.trim();
if (title) {
assertMaxBytes(title, "title", MAX_TITLE_BYTES);
}
return { return {
kind: "patch", kind: "patch",
patch, patch,
title: params.title?.trim() || undefined, title,
}; };
} }
if (before === undefined || after === undefined) { if (before === undefined || after === undefined) {
throw new PluginToolInputError("Provide patch or both before and after text."); throw new PluginToolInputError("Provide patch or both before and after text.");
} }
assertMaxBytes(before, "before", MAX_BEFORE_AFTER_BYTES);
assertMaxBytes(after, "after", MAX_BEFORE_AFTER_BYTES);
const path = params.path?.trim() || undefined;
const lang = params.lang?.trim() || undefined;
const title = params.title?.trim() || undefined;
if (path) {
assertMaxBytes(path, "path", MAX_PATH_BYTES);
}
if (lang) {
assertMaxBytes(lang, "lang", MAX_LANG_BYTES);
}
if (title) {
assertMaxBytes(title, "title", MAX_TITLE_BYTES);
}
return { return {
kind: "before_after", kind: "before_after",
before, before,
after, after,
path: params.path?.trim() || undefined, path,
lang: params.lang?.trim() || undefined, lang,
title: params.title?.trim() || undefined, title,
}; };
} }
@@ -278,3 +322,10 @@ class PluginToolInputError extends Error {
this.name = "ToolInputError"; this.name = "ToolInputError";
} }
} }
function assertMaxBytes(value: string, label: string, maxBytes: number): void {
if (Buffer.byteLength(value, "utf8") <= maxBytes) {
return;
}
throw new PluginToolInputError(`${label} exceeds maximum size (${maxBytes} bytes).`);
}

View File

@@ -0,0 +1,55 @@
import { describe, expect, it } from "vitest";
import { buildViewerUrl, normalizeViewerBaseUrl } from "./url.js";
describe("diffs viewer URL helpers", () => {
it("defaults to loopback for lan/tailnet bind modes", () => {
expect(
buildViewerUrl({
config: { gateway: { bind: "lan", port: 18789 } },
viewerPath: "/plugins/diffs/view/id/token",
}),
).toBe("http://127.0.0.1:18789/plugins/diffs/view/id/token");
expect(
buildViewerUrl({
config: { gateway: { bind: "tailnet", port: 24444 } },
viewerPath: "/plugins/diffs/view/id/token",
}),
).toBe("http://127.0.0.1:24444/plugins/diffs/view/id/token");
});
it("uses custom bind host when provided", () => {
expect(
buildViewerUrl({
config: {
gateway: {
bind: "custom",
customBindHost: "gateway.example.com",
port: 443,
tls: { enabled: true },
},
},
viewerPath: "/plugins/diffs/view/id/token",
}),
).toBe("https://gateway.example.com/plugins/diffs/view/id/token");
});
it("joins viewer path under baseUrl pathname", () => {
expect(
buildViewerUrl({
config: {},
baseUrl: "https://example.com/openclaw",
viewerPath: "/plugins/diffs/view/id/token",
}),
).toBe("https://example.com/openclaw/plugins/diffs/view/id/token");
});
it("rejects base URLs with query/hash", () => {
expect(() => normalizeViewerBaseUrl("https://example.com?a=1")).toThrow(
"baseUrl must not include query/hash",
);
expect(() => normalizeViewerBaseUrl("https://example.com#frag")).toThrow(
"baseUrl must not include query/hash",
);
});
});

View File

@@ -1,4 +1,3 @@
import os from "node:os";
import type { OpenClawConfig } from "openclaw/plugin-sdk"; import type { OpenClawConfig } from "openclaw/plugin-sdk";
const DEFAULT_GATEWAY_PORT = 18789; const DEFAULT_GATEWAY_PORT = 18789;
@@ -10,10 +9,15 @@ export function buildViewerUrl(params: {
}): string { }): string {
const baseUrl = params.baseUrl?.trim() || resolveGatewayBaseUrl(params.config); const baseUrl = params.baseUrl?.trim() || resolveGatewayBaseUrl(params.config);
const normalizedBase = normalizeViewerBaseUrl(baseUrl); const normalizedBase = normalizeViewerBaseUrl(baseUrl);
const normalizedPath = params.viewerPath.startsWith("/") const viewerPath = params.viewerPath.startsWith("/")
? params.viewerPath ? params.viewerPath
: `/${params.viewerPath}`; : `/${params.viewerPath}`;
return `${normalizedBase}${normalizedPath}`; const parsedBase = new URL(normalizedBase);
const basePath = parsedBase.pathname === "/" ? "" : parsedBase.pathname.replace(/\/+$/, "");
parsedBase.pathname = `${basePath}${viewerPath}`;
parsedBase.search = "";
parsedBase.hash = "";
return parsedBase.toString();
} }
export function normalizeViewerBaseUrl(raw: string): string { export function normalizeViewerBaseUrl(raw: string): string {
@@ -26,6 +30,12 @@ export function normalizeViewerBaseUrl(raw: string): string {
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
throw new Error(`baseUrl must use http or https: ${raw}`); throw new Error(`baseUrl must use http or https: ${raw}`);
} }
if (parsed.search || parsed.hash) {
throw new Error(`baseUrl must not include query/hash: ${raw}`);
}
parsed.search = "";
parsed.hash = "";
parsed.pathname = parsed.pathname.replace(/\/+$/, "");
const withoutTrailingSlash = parsed.toString().replace(/\/+$/, ""); const withoutTrailingSlash = parsed.toString().replace(/\/+$/, "");
return withoutTrailingSlash; return withoutTrailingSlash;
} }
@@ -34,87 +44,13 @@ function resolveGatewayBaseUrl(config: OpenClawConfig): string {
const scheme = config.gateway?.tls?.enabled ? "https" : "http"; const scheme = config.gateway?.tls?.enabled ? "https" : "http";
const port = const port =
typeof config.gateway?.port === "number" ? config.gateway.port : DEFAULT_GATEWAY_PORT; typeof config.gateway?.port === "number" ? config.gateway.port : DEFAULT_GATEWAY_PORT;
const bind = config.gateway?.bind ?? "loopback"; const customHost = config.gateway?.customBindHost?.trim();
if (bind === "custom" && config.gateway?.customBindHost?.trim()) { if (config.gateway?.bind === "custom" && customHost) {
return `${scheme}://${config.gateway.customBindHost.trim()}:${port}`; return `${scheme}://${customHost}:${port}`;
}
if (bind === "lan") {
return `${scheme}://${pickPrimaryLanIPv4() ?? "127.0.0.1"}:${port}`;
}
if (bind === "tailnet") {
return `${scheme}://${pickPrimaryTailnetIPv4() ?? "127.0.0.1"}:${port}`;
} }
// Viewer links are used by local canvas/clients; default to loopback to avoid
// container/bridge interfaces that are often unreachable from the caller.
return `${scheme}://127.0.0.1:${port}`; return `${scheme}://127.0.0.1:${port}`;
} }
function pickPrimaryLanIPv4(): string | undefined {
const nets = os.networkInterfaces();
const preferredNames = ["en0", "eth0"];
for (const name of preferredNames) {
const candidate = pickPrivateAddress(nets[name]);
if (candidate) {
return candidate;
}
}
for (const entries of Object.values(nets)) {
const candidate = pickPrivateAddress(entries);
if (candidate) {
return candidate;
}
}
return undefined;
}
function pickPrimaryTailnetIPv4(): string | undefined {
const nets = os.networkInterfaces();
for (const entries of Object.values(nets)) {
const candidate = entries?.find((entry) => isTailnetIPv4(entry.address) && !entry.internal);
if (candidate?.address) {
return candidate.address;
}
}
return undefined;
}
function pickPrivateAddress(entries: os.NetworkInterfaceInfo[] | undefined): string | undefined {
return entries?.find(
(entry) => entry.family === "IPv4" && !entry.internal && isPrivateIPv4(entry.address),
)?.address;
}
function isPrivateIPv4(address: string): boolean {
const octets = parseIpv4(address);
if (!octets) {
return false;
}
const [a, b] = octets;
return a === 10 || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168);
}
function isTailnetIPv4(address: string): boolean {
const octets = parseIpv4(address);
if (!octets) {
return false;
}
const [a, b] = octets;
return a === 100 && b >= 64 && b <= 127;
}
function parseIpv4(address: string): number[] | null {
const parts = address.split(".");
if (parts.length !== 4) {
return null;
}
const octets = parts.map((part) => Number.parseInt(part, 10));
if (octets.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) {
return null;
}
return octets;
}