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

@@ -52,7 +52,13 @@ Useful options:
- `path`: display name for before/after input
- `title`: explicit viewer title
- `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
@@ -86,6 +92,10 @@ Set plugin-wide defaults in `~/.openclaw/openclaw.json`:
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
Open in canvas:
@@ -152,6 +162,8 @@ diff --git a/src/example.ts b/src/example.ts
## Notes
- 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.
- Diff rendering is powered by [Diffs](https://diffs.com).

View File

@@ -110,10 +110,10 @@ describe("diffs plugin registration", () => {
);
const res = createMockServerResponse();
const handled = await registeredHttpHandler?.(
{
localReq({
method: "GET",
url: viewerPath,
} as IncomingMessage,
}),
res,
);
@@ -127,3 +127,10 @@ describe("diffs plugin registration", () => {
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 type { OpenClawPluginApi } 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 { DIFFS_AGENT_GUIDANCE } from "./src/prompt-guidance.js";
import { DiffArtifactStore } from "./src/store.js";
@@ -14,13 +18,20 @@ const plugin = {
configSchema: diffsPluginConfigSchema,
register(api: OpenClawPluginApi) {
const defaults = resolveDiffsPluginDefaults(api.pluginConfig);
const security = resolveDiffsPluginSecurity(api.pluginConfig);
const store = new DiffArtifactStore({
rootDir: path.join(resolvePreferredOpenClawTmpDir(), "openclaw-diffs"),
logger: api.logger,
});
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 () => ({
prependContext: DIFFS_AGENT_GUIDANCE,
}));

View File

@@ -42,6 +42,10 @@
"defaults.mode": {
"label": "Default Output Mode",
"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": {
@@ -101,6 +105,16 @@
"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,
colorScheme: params.theme,
});
await page.route(`http://127.0.0.1${VIEWER_ASSET_PREFIX}*`, async (route) => {
const pathname = new URL(route.request().url()).pathname;
const asset = await getServedViewerAsset(pathname);
await page.route("**/*", async (route) => {
const requestUrl = route.request().url();
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) {
await route.abort();
return;

View File

@@ -1,5 +1,10 @@
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", () => {
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;
mode?: DiffMode;
};
security?: {
allowRemoteViewer?: boolean;
};
};
export const DEFAULT_DIFFS_TOOL_DEFAULTS: DiffToolDefaults = {
@@ -40,6 +43,14 @@ export const DEFAULT_DIFFS_TOOL_DEFAULTS: DiffToolDefaults = {
mode: "both",
};
export type DiffsPluginSecurityConfig = {
allowRemoteViewer: boolean;
};
export const DEFAULT_DIFFS_PLUGIN_SECURITY: DiffsPluginSecurityConfig = {
allowRemoteViewer: false,
};
const DIFFS_PLUGIN_CONFIG_JSON_SCHEMA = {
type: "object",
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;
@@ -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 {
const {
fontFamily,

View File

@@ -31,10 +31,10 @@ describe("createDiffsHttpHandler", () => {
const handler = createDiffsHttpHandler({ store });
const res = createMockServerResponse();
const handled = await handler(
{
localReq({
method: "GET",
url: artifact.viewerPath,
} as IncomingMessage,
}),
res,
);
@@ -55,10 +55,10 @@ describe("createDiffsHttpHandler", () => {
const handler = createDiffsHttpHandler({ store });
const res = createMockServerResponse();
const handled = await handler(
{
localReq({
method: "GET",
url: artifact.viewerPath.replace(artifact.token, "bad-token"),
} as IncomingMessage,
}),
res,
);
@@ -70,10 +70,10 @@ describe("createDiffsHttpHandler", () => {
const handler = createDiffsHttpHandler({ store });
const res = createMockServerResponse();
const handled = await handler(
{
localReq({
method: "GET",
url: "/plugins/diffs/view/not-a-real-id/not-a-real-token",
} as IncomingMessage,
}),
res,
);
@@ -85,10 +85,10 @@ describe("createDiffsHttpHandler", () => {
const handler = createDiffsHttpHandler({ store });
const res = createMockServerResponse();
const handled = await handler(
{
localReq({
method: "GET",
url: "/plugins/diffs/assets/viewer.js",
} as IncomingMessage,
}),
res,
);
@@ -101,10 +101,10 @@ describe("createDiffsHttpHandler", () => {
const handler = createDiffsHttpHandler({ store });
const res = createMockServerResponse();
const handled = await handler(
{
localReq({
method: "GET",
url: "/plugins/diffs/assets/viewer-runtime.js",
} as IncomingMessage,
}),
res,
);
@@ -112,4 +112,89 @@ describe("createDiffsHttpHandler", () => {
expect(res.statusCode).toBe(200);
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";
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 = [
"default-src 'none'",
"script-src 'self'",
@@ -20,7 +24,10 @@ const VIEWER_CONTENT_SECURITY_POLICY = [
export function createDiffsHttpHandler(params: {
store: DiffArtifactStore;
logger?: PluginLogger;
allowRemoteViewer?: boolean;
}) {
const viewerFailureLimiter = new ViewerFailureLimiter();
return async (req: IncomingMessage, res: ServerResponse): Promise<boolean> => {
const parsed = parseRequestUrl(req.url);
if (!parsed) {
@@ -35,11 +42,29 @@ export function createDiffsHttpHandler(params: {
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") {
respondText(res, 405, "Method not allowed");
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 id = pathParts[3];
const token = pathParts[4];
@@ -49,18 +74,27 @@ export function createDiffsHttpHandler(params: {
!DIFF_ARTIFACT_ID_PATTERN.test(id) ||
!DIFF_ARTIFACT_TOKEN_PATTERN.test(token)
) {
if (!localRequest) {
viewerFailureLimiter.recordFailure(remoteKey);
}
respondText(res, 404, "Diff not found");
return true;
}
const artifact = await params.store.getArtifact(id, token);
if (!artifact) {
if (!localRequest) {
viewerFailureLimiter.recordFailure(remoteKey);
}
respondText(res, 404, "Diff not found or expired");
return true;
}
try {
const html = await params.store.readHtml(id);
if (!localRequest) {
viewerFailureLimiter.reset(remoteKey);
}
res.statusCode = 200;
setSharedHeaders(res, "text/html; charset=utf-8");
res.setHeader("content-security-policy", VIEWER_CONTENT_SECURITY_POLICY);
@@ -71,6 +105,9 @@ export function createDiffsHttpHandler(params: {
}
return true;
} catch (error) {
if (!localRequest) {
viewerFailureLimiter.recordFailure(remoteKey);
}
params.logger?.warn(`Failed to serve diff artifact ${id}: ${String(error)}`);
respondText(res, 500, "Failed to load diff");
return true;
@@ -134,3 +171,90 @@ function setSharedHeaders(res: ServerResponse, contentType: string): void {
res.setHeader("x-content-type-options", "nosniff");
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.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";
const DEFAULT_FILE_NAME = "diff.txt";
const MAX_PATCH_FILE_COUNT = 128;
const MAX_PATCH_TOTAL_LINES = 120_000;
function escapeCssString(value: string): string {
return value.replaceAll("\\", "\\\\").replaceAll('"', '\\"');
@@ -344,6 +346,17 @@ async function renderPatchDiff(
if (files.length === 0) {
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 imagePayloadOptions = buildDiffOptions(buildImageRenderOptions(options));

View File

@@ -62,6 +62,35 @@ describe("DiffArtifactStore", () => {
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 () => {
const imagePath = store.allocateStandaloneImagePath();
expect(imagePath).toMatch(/preview\.png$/);

View File

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

View File

@@ -40,6 +40,7 @@ describe("diffs tool", () => {
});
it("returns an image artifact in image mode", async () => {
const cleanupSpy = vi.spyOn(store, "scheduleCleanup");
const screenshotter = {
screenshotHtml: vi.fn(async ({ html, outputPath }: { html: string; outputPath: string }) => {
expect(html).not.toContain("/plugins/diffs/assets/viewer.js");
@@ -68,6 +69,7 @@ describe("diffs tool", () => {
expect(result?.content).toHaveLength(1);
expect((result?.details as Record<string, unknown>).imagePath).toBeDefined();
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 () => {
@@ -110,6 +112,38 @@ describe("diffs tool", () => {
).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 () => {
const tool = createDiffsTool({
api: createApi(),

View File

@@ -16,6 +16,12 @@ import {
} from "./types.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) {
return Type.Unsafe<T[number]>({
type: "string",
@@ -28,12 +34,30 @@ const DiffsToolSchema = Type.Object(
{
before: Type.Optional(Type.String({ description: "Original text content." })),
after: Type.Optional(Type.String({ description: "Updated text content." })),
patch: Type.Optional(Type.String({ description: "Unified diff or patch text." })),
path: Type.Optional(Type.String({ description: "Display path for before/after input." })),
lang: Type.Optional(
Type.String({ description: "Optional language override for before/after input." }),
patch: Type.Optional(
Type.String({
description: "Unified diff or patch text.",
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(
stringEnum(DIFF_MODES, "Output mode: view, image, or both. Default: both."),
),
@@ -102,6 +126,7 @@ export function createDiffsTool(params: {
theme,
});
const imageStats = await fs.stat(imagePath);
params.store.scheduleCleanup();
return {
content: [
@@ -217,27 +242,46 @@ function normalizeDiffInput(params: DiffsToolParams): DiffInput {
const after = params.after;
if (patch) {
assertMaxBytes(patch, "patch", MAX_PATCH_BYTES);
if (before !== undefined || after !== undefined) {
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 {
kind: "patch",
patch,
title: params.title?.trim() || undefined,
title,
};
}
if (before === undefined || after === undefined) {
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 {
kind: "before_after",
before,
after,
path: params.path?.trim() || undefined,
lang: params.lang?.trim() || undefined,
title: params.title?.trim() || undefined,
path,
lang,
title,
};
}
@@ -278,3 +322,10 @@ class PluginToolInputError extends Error {
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";
const DEFAULT_GATEWAY_PORT = 18789;
@@ -10,10 +9,15 @@ export function buildViewerUrl(params: {
}): string {
const baseUrl = params.baseUrl?.trim() || resolveGatewayBaseUrl(params.config);
const normalizedBase = normalizeViewerBaseUrl(baseUrl);
const normalizedPath = params.viewerPath.startsWith("/")
const viewerPath = params.viewerPath.startsWith("/")
? 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 {
@@ -26,6 +30,12 @@ export function normalizeViewerBaseUrl(raw: string): string {
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
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(/\/+$/, "");
return withoutTrailingSlash;
}
@@ -34,87 +44,13 @@ function resolveGatewayBaseUrl(config: OpenClawConfig): string {
const scheme = config.gateway?.tls?.enabled ? "https" : "http";
const 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()) {
return `${scheme}://${config.gateway.customBindHost.trim()}:${port}`;
}
if (bind === "lan") {
return `${scheme}://${pickPrimaryLanIPv4() ?? "127.0.0.1"}:${port}`;
}
if (bind === "tailnet") {
return `${scheme}://${pickPrimaryTailnetIPv4() ?? "127.0.0.1"}:${port}`;
if (config.gateway?.bind === "custom" && customHost) {
return `${scheme}://${customHost}:${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}`;
}
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;
}