Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Adolf is a fork/vendored clone of github.com/openclaw/openclaw (v2026.6.11), free to diverge. Tree copied sans upstream .git; upstream remote added for future syncs. Node pinned to 24 (.nvmrc); engines already require >=22.19. Preserves docs/ARCHITECTURE.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LeqyaxJF2nbRXJtae2kNB2
326 lines
9.4 KiB
TypeScript
326 lines
9.4 KiB
TypeScript
// Diffs plugin module implements http behavior.
|
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
|
|
import type { PluginLogger } from "../api.js";
|
|
import { resolveRequestClientIp } from "../runtime-api.js";
|
|
import type { DiffArtifactStore } from "./store.js";
|
|
import { DIFF_ARTIFACT_ID_PATTERN, DIFF_ARTIFACT_TOKEN_PATTERN } from "./types.js";
|
|
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'",
|
|
"style-src 'unsafe-inline'",
|
|
"img-src 'self' data:",
|
|
"font-src 'self' data:",
|
|
"connect-src 'none'",
|
|
"base-uri 'none'",
|
|
"frame-ancestors 'self'",
|
|
"object-src 'none'",
|
|
].join("; ");
|
|
|
|
export function createDiffsHttpHandler(params: {
|
|
store: DiffArtifactStore;
|
|
logger?: PluginLogger;
|
|
allowRemoteViewer?: boolean;
|
|
trustedProxies?: readonly string[];
|
|
allowRealIpFallback?: boolean;
|
|
resolveAccessConfig?: () => {
|
|
allowRemoteViewer?: boolean;
|
|
trustedProxies?: readonly string[];
|
|
allowRealIpFallback?: boolean;
|
|
};
|
|
}) {
|
|
const viewerFailureLimiter = new ViewerFailureLimiter();
|
|
|
|
return async (req: IncomingMessage, res: ServerResponse): Promise<boolean> => {
|
|
const parsed = parseRequestUrl(req.url);
|
|
if (!parsed) {
|
|
return false;
|
|
}
|
|
|
|
if (parsed.pathname.startsWith(VIEWER_ASSET_PREFIX)) {
|
|
return await serveAsset(req, res, parsed.pathname, params.logger);
|
|
}
|
|
|
|
if (!parsed.pathname.startsWith(VIEW_PREFIX)) {
|
|
return false;
|
|
}
|
|
|
|
const accessConfig = params.resolveAccessConfig?.() ?? {
|
|
allowRemoteViewer: params.allowRemoteViewer,
|
|
trustedProxies: params.trustedProxies,
|
|
allowRealIpFallback: params.allowRealIpFallback,
|
|
};
|
|
const access = resolveViewerAccess(req, {
|
|
trustedProxies: accessConfig.trustedProxies,
|
|
allowRealIpFallback: accessConfig.allowRealIpFallback,
|
|
});
|
|
if (!access.localRequest && accessConfig.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 (!access.localRequest) {
|
|
const throttled = viewerFailureLimiter.check(access.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];
|
|
if (
|
|
!id ||
|
|
!token ||
|
|
!DIFF_ARTIFACT_ID_PATTERN.test(id) ||
|
|
!DIFF_ARTIFACT_TOKEN_PATTERN.test(token)
|
|
) {
|
|
recordRemoteFailure(viewerFailureLimiter, access);
|
|
respondText(res, 404, "Diff not found");
|
|
return true;
|
|
}
|
|
|
|
const artifact = await params.store.getArtifact(id, token);
|
|
if (!artifact) {
|
|
recordRemoteFailure(viewerFailureLimiter, access);
|
|
respondText(res, 404, "Diff not found or expired");
|
|
return true;
|
|
}
|
|
|
|
try {
|
|
const html = await params.store.readHtml(id);
|
|
resetRemoteFailures(viewerFailureLimiter, access);
|
|
res.statusCode = 200;
|
|
setSharedHeaders(res, "text/html; charset=utf-8");
|
|
res.setHeader("content-security-policy", VIEWER_CONTENT_SECURITY_POLICY);
|
|
if (req.method === "HEAD") {
|
|
res.end();
|
|
} else {
|
|
res.end(html);
|
|
}
|
|
return true;
|
|
} catch (error) {
|
|
recordRemoteFailure(viewerFailureLimiter, access);
|
|
params.logger?.warn(`Failed to serve diff artifact ${id}: ${String(error)}`);
|
|
respondText(res, 500, "Failed to load diff");
|
|
return true;
|
|
}
|
|
};
|
|
}
|
|
|
|
function parseRequestUrl(rawUrl?: string): URL | null {
|
|
if (!rawUrl) {
|
|
return null;
|
|
}
|
|
try {
|
|
return new URL(rawUrl, "http://127.0.0.1");
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async function serveAsset(
|
|
req: IncomingMessage,
|
|
res: ServerResponse,
|
|
pathname: string,
|
|
logger?: PluginLogger,
|
|
): Promise<boolean> {
|
|
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
respondText(res, 405, "Method not allowed");
|
|
return true;
|
|
}
|
|
|
|
try {
|
|
const asset = await getServedViewerAsset(pathname);
|
|
if (!asset) {
|
|
respondText(res, 404, "Asset not found");
|
|
return true;
|
|
}
|
|
|
|
res.statusCode = 200;
|
|
setSharedHeaders(res, asset.contentType);
|
|
if (req.method === "HEAD") {
|
|
res.end();
|
|
} else {
|
|
res.end(asset.body);
|
|
}
|
|
return true;
|
|
} catch (error) {
|
|
logger?.warn(`Failed to serve diffs asset ${pathname}: ${String(error)}`);
|
|
respondText(res, 500, "Failed to load asset");
|
|
return true;
|
|
}
|
|
}
|
|
|
|
function respondText(res: ServerResponse, statusCode: number, body: string): void {
|
|
res.statusCode = statusCode;
|
|
setSharedHeaders(res, "text/plain; charset=utf-8");
|
|
res.end(body);
|
|
}
|
|
|
|
function setSharedHeaders(res: ServerResponse, contentType: string): void {
|
|
res.setHeader("cache-control", "no-store, max-age=0");
|
|
res.setHeader("content-type", contentType);
|
|
res.setHeader("x-content-type-options", "nosniff");
|
|
res.setHeader("referrer-policy", "no-referrer");
|
|
}
|
|
|
|
function normalizeRemoteClientKey(remoteAddress: string | undefined): string {
|
|
const normalized = normalizeLowercaseStringOrEmpty(remoteAddress);
|
|
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";
|
|
}
|
|
|
|
function hasProxyForwardingHints(req: IncomingMessage): boolean {
|
|
const headers = req.headers ?? {};
|
|
return Boolean(
|
|
headers["x-forwarded-for"] ||
|
|
headers["x-real-ip"] ||
|
|
headers.forwarded ||
|
|
headers["x-forwarded-host"] ||
|
|
headers["x-forwarded-proto"],
|
|
);
|
|
}
|
|
|
|
function resolveViewerAccess(
|
|
req: IncomingMessage,
|
|
params: {
|
|
trustedProxies?: readonly string[];
|
|
allowRealIpFallback?: boolean;
|
|
},
|
|
): {
|
|
remoteKey: string;
|
|
localRequest: boolean;
|
|
} {
|
|
const proxyHintsPresent = hasProxyForwardingHints(req);
|
|
const clientIp =
|
|
proxyHintsPresent || (params.trustedProxies?.length ?? 0) > 0
|
|
? // Reuse gateway proxy trust rules and fail closed when a trusted proxy hop
|
|
// does not provide usable client-origin headers.
|
|
resolveRequestClientIp(
|
|
req,
|
|
params.trustedProxies ? [...params.trustedProxies] : undefined,
|
|
params.allowRealIpFallback === true,
|
|
)
|
|
: req.socket?.remoteAddress;
|
|
const remoteKey = normalizeRemoteClientKey(clientIp ?? req.socket?.remoteAddress);
|
|
const localRequest =
|
|
!proxyHintsPresent && typeof clientIp === "string" && isLoopbackClientIp(remoteKey);
|
|
return { remoteKey, localRequest };
|
|
}
|
|
|
|
function recordRemoteFailure(
|
|
limiter: ViewerFailureLimiter,
|
|
access: { remoteKey: string; localRequest: boolean },
|
|
): void {
|
|
if (!access.localRequest) {
|
|
limiter.recordFailure(access.remoteKey);
|
|
}
|
|
}
|
|
|
|
function resetRemoteFailures(
|
|
limiter: ViewerFailureLimiter,
|
|
access: { remoteKey: string; localRequest: boolean },
|
|
): void {
|
|
if (!access.localRequest) {
|
|
limiter.reset(access.remoteKey);
|
|
}
|
|
}
|
|
|
|
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();
|
|
}
|
|
}
|
|
}
|