mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
fix(diffs): harden viewer security and docs
This commit is contained in:
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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$/);
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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).`);
|
||||||
|
}
|
||||||
|
|||||||
55
extensions/diffs/src/url.test.ts
Normal file
55
extensions/diffs/src/url.test.ts
Normal 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",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user