From 4a1be9825421d066a8e768d627ef4dfb75e6b37d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 2 Mar 2026 05:07:04 +0000 Subject: [PATCH] fix(diffs): harden viewer security and docs --- docs/tools/diffs.md | 257 ++++++++++++++++++++++---- extensions/diffs/README.md | 16 +- extensions/diffs/index.test.ts | 11 +- extensions/diffs/index.ts | 15 +- extensions/diffs/openclaw.plugin.json | 14 ++ extensions/diffs/src/browser.ts | 25 ++- extensions/diffs/src/config.test.ts | 19 +- extensions/diffs/src/config.ts | 36 ++++ extensions/diffs/src/http.test.ts | 105 ++++++++++- extensions/diffs/src/http.ts | 124 +++++++++++++ extensions/diffs/src/render.test.ts | 26 +++ extensions/diffs/src/render.ts | 13 ++ extensions/diffs/src/store.test.ts | 29 +++ extensions/diffs/src/store.ts | 41 +++- extensions/diffs/src/tool.test.ts | 34 ++++ extensions/diffs/src/tool.ts | 69 ++++++- extensions/diffs/src/url.test.ts | 55 ++++++ extensions/diffs/src/url.ts | 100 ++-------- 18 files changed, 837 insertions(+), 152 deletions(-) create mode 100644 extensions/diffs/src/url.test.ts diff --git a/docs/tools/diffs.md b/docs/tools/diffs.md index a1c97746e76..1534c227b0e 100644 --- a/docs/tools/diffs.md +++ b/docs/tools/diffs.md @@ -1,24 +1,34 @@ --- title: "Diffs" 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: - You want agents to show code or markdown edits as diffs - You want a canvas-ready viewer URL or a rendered diff PNG + - You need controlled, temporary diff artifacts with secure defaults --- # 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 -- a unified patch +It accepts either: -The tool can produce: +- `before` and `after` text +- a unified `patch` -- a gateway-hosted viewer URL for canvas use -- a PNG image for message delivery -- both outputs together +It can return: + +- 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 @@ -34,20 +44,18 @@ The tool can produce: } ``` -## What agents get back +## Typical agent workflow -- `mode: "view"` returns `details.viewerUrl` and `details.viewerPath` -- `mode: "image"` returns `details.imagePath` only -- `mode: "both"` returns the viewer details plus `details.imagePath` +1. Agent calls `diffs`. +2. Agent reads `details` fields. +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` -- send `details.imagePath` with the `message` tool using `path` or `filePath` - -## Tool inputs - -Before/after input: +Before and after: ```json { @@ -58,7 +66,7 @@ Before/after input: } ``` -Patch input: +Patch: ```json { @@ -67,16 +75,59 @@ Patch input: } ``` -Useful options: +## Tool input reference -- `mode`: `view`, `image`, or `both` -- `layout`: `unified` or `split` -- `theme`: `light` or `dark` -- `expandUnchanged`: expand unchanged sections instead of collapsing them -- `path`: display name for before/after input -- `title`: explicit diff title -- `ttlSeconds`: viewer artifact lifetime -- `baseUrl`: override the gateway base URL used in the returned viewer link +All fields are optional unless noted: + +- `before` (`string`): original text. Required with `after` when `patch` is omitted. +- `after` (`string`): updated text. Required with `before` when `patch` is omitted. +- `patch` (`string`): unified diff text. Mutually exclusive with `before` and `after`. +- `path` (`string`): display filename for before and after mode. +- `lang` (`string`): language override hint for before and after mode. +- `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 @@ -121,15 +172,149 @@ Supported defaults: - `theme` - `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/...`. -- Viewer artifacts are ephemeral and stored locally. -- `mode: "image"` uses a faster image-only render path and does not create a viewer URL. -- 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). +- `security.allowRemoteViewer` (`boolean`, default `false`) + - `false`: non-loopback requests to viewer routes are denied. + - `true`: remote viewers are allowed if tokenized path is valid. + +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 diff --git a/extensions/diffs/README.md b/extensions/diffs/README.md index 5224155d2a6..f6c5b154c8d 100644 --- a/extensions/diffs/README.md +++ b/extensions/diffs/README.md @@ -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). diff --git a/extensions/diffs/index.test.ts b/extensions/diffs/index.test.ts index 02305c5d8b8..b6c8ad96ab2 100644 --- a/extensions/diffs/index.test.ts +++ b/extensions/diffs/index.test.ts @@ -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; +} diff --git a/extensions/diffs/index.ts b/extensions/diffs/index.ts index 0cfd2eaf7f7..7cc66938a3a 100644 --- a/extensions/diffs/index.ts +++ b/extensions/diffs/index.ts @@ -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, })); diff --git a/extensions/diffs/openclaw.plugin.json b/extensions/diffs/openclaw.plugin.json index 1e06d2a8be3..44791385cec 100644 --- a/extensions/diffs/openclaw.plugin.json +++ b/extensions/diffs/openclaw.plugin.json @@ -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 + } + } } } } diff --git a/extensions/diffs/src/browser.ts b/extensions/diffs/src/browser.ts index c5a8b38c17b..9538d51c9c7 100644 --- a/extensions/diffs/src/browser.ts +++ b/extensions/diffs/src/browser.ts @@ -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; diff --git a/extensions/diffs/src/config.test.ts b/extensions/diffs/src/config.test.ts index 7f82b9faac3..d8e0b08c096 100644 --- a/extensions/diffs/src/config.test.ts +++ b/extensions/diffs/src/config.test.ts @@ -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, + }); + }); +}); diff --git a/extensions/diffs/src/config.ts b/extensions/diffs/src/config.ts index 11c31a0aa09..1f2b363e2b1 100644 --- a/extensions/diffs/src/config.ts +++ b/extensions/diffs/src/config.ts @@ -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, diff --git a/extensions/diffs/src/http.test.ts b/extensions/diffs/src/http.test.ts index 53b179c2a7b..b9a0fee6e59 100644 --- a/extensions/diffs/src/http.test.ts +++ b/extensions/diffs/src/http.test.ts @@ -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: "viewer", + 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: "viewer", + 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("viewer"); + }); + + 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; +} diff --git a/extensions/diffs/src/http.ts b/extensions/diffs/src/http.ts index 98ff6ddaff1..f2cb4433ed2 100644 --- a/extensions/diffs/src/http.ts +++ b/extensions/diffs/src/http.ts @@ -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 => { 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(); + + 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(); + } + } +} diff --git a/extensions/diffs/src/render.test.ts b/extensions/diffs/src/render.test.ts index 1ca6c266a73..6ab7de73d2a 100644 --- a/extensions/diffs/src/render.test.ts +++ b/extensions/diffs/src/render.test.ts @@ -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"); + }); }); diff --git a/extensions/diffs/src/render.ts b/extensions/diffs/src/render.ts index 0de4f5ad111..7bf53a5939b 100644 --- a/extensions/diffs/src/render.ts +++ b/extensions/diffs/src/render.ts @@ -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)); diff --git a/extensions/diffs/src/store.test.ts b/extensions/diffs/src/store.test.ts index d94bc286c7a..1e4a65209b7 100644 --- a/extensions/diffs/src/store.test.ts +++ b/extensions/diffs/src/store.test.ts @@ -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: "demo", + 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: "demo", + 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$/); diff --git a/extensions/diffs/src/store.ts b/extensions/diffs/src/store.ts index b70223c2972..ce6e391f5a6 100644 --- a/extensions/diffs/src/store.ts +++ b/extensions/diffs/src/store.ts @@ -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 { @@ -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 { 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 { 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 { diff --git a/extensions/diffs/src/tool.test.ts b/extensions/diffs/src/tool.test.ts index c8c3751936f..593a277dba5 100644 --- a/extensions/diffs/src/tool.test.ts +++ b/extensions/diffs/src/tool.test.ts @@ -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).imagePath).toBeDefined(); expect((result?.details as Record).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(), diff --git a/extensions/diffs/src/tool.ts b/extensions/diffs/src/tool.ts index 13779741614..064f36640c5 100644 --- a/extensions/diffs/src/tool.ts +++ b/extensions/diffs/src/tool.ts @@ -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(values: T, description: string) { return Type.Unsafe({ 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).`); +} diff --git a/extensions/diffs/src/url.test.ts b/extensions/diffs/src/url.test.ts new file mode 100644 index 00000000000..4511faaa270 --- /dev/null +++ b/extensions/diffs/src/url.test.ts @@ -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", + ); + }); +}); diff --git a/extensions/diffs/src/url.ts b/extensions/diffs/src/url.ts index 7c3eebbe2a1..43dca97ff72 100644 --- a/extensions/diffs/src/url.ts +++ b/extensions/diffs/src/url.ts @@ -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; -}