mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
fix(gateway): harden canvas auth with session capabilities
This commit is contained in:
@@ -18,6 +18,7 @@ Docs: https://docs.openclaw.ai
|
|||||||
### Fixes
|
### Fixes
|
||||||
|
|
||||||
- Security/Voice Call: harden `voice-call` telephony TTS override merging by blocking unsafe deep-merge keys (`__proto__`, `prototype`, `constructor`) and add regression coverage for top-level and nested prototype-pollution payloads.
|
- Security/Voice Call: harden `voice-call` telephony TTS override merging by blocking unsafe deep-merge keys (`__proto__`, `prototype`, `constructor`) and add regression coverage for top-level and nested prototype-pollution payloads.
|
||||||
|
- Security/Gateway/Canvas: replace shared-IP fallback auth with node-scoped session capability URLs for `/__openclaw__/canvas/*` and `/__openclaw__/a2ui/*`, fail closed when trusted-proxy requests omit forwarded client headers, and add IPv6/proxy-header regression coverage. This ships in the next npm release. Thanks @aether-ai-agent for reporting.
|
||||||
- Security/Net: enforce strict dotted-decimal IPv4 literals in SSRF checks and fail closed on unsupported legacy forms (octal/hex/short/packed, for example `0177.0.0.1`, `127.1`, `2130706433`) before DNS lookup.
|
- Security/Net: enforce strict dotted-decimal IPv4 literals in SSRF checks and fail closed on unsupported legacy forms (octal/hex/short/packed, for example `0177.0.0.1`, `127.1`, `2130706433`) before DNS lookup.
|
||||||
- Security/Discord: enforce trusted-sender guild permission checks for moderation actions (`timeout`, `kick`, `ban`) and ignore untrusted `senderUserId` params to prevent privilege escalation in tool-driven flows. Thanks @aether-ai-agent for reporting.
|
- Security/Discord: enforce trusted-sender guild permission checks for moderation actions (`timeout`, `kick`, `ban`) and ignore untrusted `senderUserId` params to prevent privilege escalation in tool-driven flows. Thanks @aether-ai-agent for reporting.
|
||||||
- Security/ACP+Exec: add `openclaw acp --token-file/--password-file` secret-file support (with inline secret flag warnings), redact ACP working-directory prefixes to `~` home-relative paths, constrain exec script preflight file inspection to the effective `workdir` boundary, and add security-audit warnings when `tools.exec.host="sandbox"` is configured while sandbox mode is off.
|
- Security/ACP+Exec: add `openclaw acp --token-file/--password-file` secret-file support (with inline secret flag warnings), redact ACP working-directory prefixes to `~` home-relative paths, constrain exec script preflight file inspection to the effective `workdir` boundary, and add security-audit warnings when `tools.exec.host="sandbox"` is configured while sandbox mode is off.
|
||||||
|
|||||||
@@ -2169,7 +2169,8 @@ Auth: `Authorization: Bearer <token>` or `x-openclaw-token: <token>`.
|
|||||||
- `http://<gateway-host>:<gateway.port>/__openclaw__/a2ui/`
|
- `http://<gateway-host>:<gateway.port>/__openclaw__/a2ui/`
|
||||||
- Local-only: keep `gateway.bind: "loopback"` (default).
|
- Local-only: keep `gateway.bind: "loopback"` (default).
|
||||||
- Non-loopback binds: canvas routes require Gateway auth (token/password/trusted-proxy), same as other Gateway HTTP surfaces.
|
- Non-loopback binds: canvas routes require Gateway auth (token/password/trusted-proxy), same as other Gateway HTTP surfaces.
|
||||||
- Node WebViews typically don't send auth headers; after a node is paired and connected, the Gateway allows a private-IP fallback so the node can load canvas/A2UI without leaking secrets into URLs.
|
- Node WebViews typically don't send auth headers; after a node is paired and connected, the Gateway advertises node-scoped capability URLs for canvas/A2UI access.
|
||||||
|
- Capability URLs are bound to the active node WS session and expire quickly. IP-based fallback is not used.
|
||||||
- Injects live-reload client into served HTML.
|
- Injects live-reload client into served HTML.
|
||||||
- Auto-creates starter `index.html` when empty.
|
- Auto-creates starter `index.html` when empty.
|
||||||
- Also serves A2UI at `/__openclaw__/a2ui/`.
|
- Also serves A2UI at `/__openclaw__/a2ui/`.
|
||||||
|
|||||||
@@ -16,5 +16,5 @@ process that owns channel connections and the WebSocket control plane.
|
|||||||
- Canvas host is served by the Gateway HTTP server on the **same port** as the Gateway (default `18789`):
|
- Canvas host is served by the Gateway HTTP server on the **same port** as the Gateway (default `18789`):
|
||||||
- `/__openclaw__/canvas/`
|
- `/__openclaw__/canvas/`
|
||||||
- `/__openclaw__/a2ui/`
|
- `/__openclaw__/a2ui/`
|
||||||
When `gateway.auth` is configured and the Gateway binds beyond loopback, these routes are protected by Gateway auth (loopback requests are exempt). See [Gateway configuration](/gateway/configuration) (`canvasHost`, `gateway`).
|
When `gateway.auth` is configured and the Gateway binds beyond loopback, these routes are protected by Gateway auth. Node clients use node-scoped capability URLs tied to their active WS session. See [Gateway configuration](/gateway/configuration) (`canvasHost`, `gateway`).
|
||||||
- Remote use is typically SSH tunnel or tailnet VPN. See [Remote access](/gateway/remote) and [Discovery](/gateway/discovery).
|
- Remote use is typically SSH tunnel or tailnet VPN. See [Remote access](/gateway/remote) and [Discovery](/gateway/discovery).
|
||||||
|
|||||||
@@ -120,8 +120,10 @@ export function injectCanvasLiveReload(html: string): string {
|
|||||||
globalThis.openclawSendUserAction = sendUserAction;
|
globalThis.openclawSendUserAction = sendUserAction;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const cap = new URLSearchParams(location.search).get("oc_cap");
|
||||||
const proto = location.protocol === "https:" ? "wss" : "ws";
|
const proto = location.protocol === "https:" ? "wss" : "ws";
|
||||||
const ws = new WebSocket(proto + "://" + location.host + ${JSON.stringify(CANVAS_WS_PATH)});
|
const capQuery = cap ? "?oc_cap=" + encodeURIComponent(cap) : "";
|
||||||
|
const ws = new WebSocket(proto + "://" + location.host + ${JSON.stringify(CANVAS_WS_PATH)} + capQuery);
|
||||||
ws.onmessage = (ev) => {
|
ws.onmessage = (ev) => {
|
||||||
if (String(ev.data || "") === "reload") location.reload();
|
if (String(ev.data || "") === "reload") location.reload();
|
||||||
};
|
};
|
||||||
|
|||||||
87
src/gateway/canvas-capability.ts
Normal file
87
src/gateway/canvas-capability.ts
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import { randomBytes } from "node:crypto";
|
||||||
|
|
||||||
|
export const CANVAS_CAPABILITY_PATH_PREFIX = "/__openclaw__/cap";
|
||||||
|
export const CANVAS_CAPABILITY_QUERY_PARAM = "oc_cap";
|
||||||
|
export const CANVAS_CAPABILITY_TTL_MS = 10 * 60_000;
|
||||||
|
|
||||||
|
export type NormalizedCanvasScopedUrl = {
|
||||||
|
pathname: string;
|
||||||
|
capability?: string;
|
||||||
|
rewrittenUrl?: string;
|
||||||
|
scopedPath: boolean;
|
||||||
|
malformedScopedPath: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizeCapability(raw: string | null | undefined): string | undefined {
|
||||||
|
const trimmed = raw?.trim();
|
||||||
|
return trimmed ? trimmed : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mintCanvasCapabilityToken(): string {
|
||||||
|
return randomBytes(18).toString("base64url");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildCanvasScopedHostUrl(baseUrl: string, capability: string): string | undefined {
|
||||||
|
const normalizedCapability = normalizeCapability(capability);
|
||||||
|
if (!normalizedCapability) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const url = new URL(baseUrl);
|
||||||
|
const trimmedPath = url.pathname.replace(/\/+$/, "");
|
||||||
|
const prefix = `${CANVAS_CAPABILITY_PATH_PREFIX}/${encodeURIComponent(normalizedCapability)}`;
|
||||||
|
url.pathname = `${trimmedPath}${prefix}`;
|
||||||
|
url.search = "";
|
||||||
|
url.hash = "";
|
||||||
|
return url.toString().replace(/\/$/, "");
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeCanvasScopedUrl(rawUrl: string): NormalizedCanvasScopedUrl {
|
||||||
|
const url = new URL(rawUrl, "http://localhost");
|
||||||
|
const prefix = `${CANVAS_CAPABILITY_PATH_PREFIX}/`;
|
||||||
|
let scopedPath = false;
|
||||||
|
let malformedScopedPath = false;
|
||||||
|
let capabilityFromPath: string | undefined;
|
||||||
|
let rewrittenUrl: string | undefined;
|
||||||
|
|
||||||
|
if (url.pathname.startsWith(prefix)) {
|
||||||
|
scopedPath = true;
|
||||||
|
const remainder = url.pathname.slice(prefix.length);
|
||||||
|
const slashIndex = remainder.indexOf("/");
|
||||||
|
if (slashIndex <= 0) {
|
||||||
|
malformedScopedPath = true;
|
||||||
|
} else {
|
||||||
|
const encodedCapability = remainder.slice(0, slashIndex);
|
||||||
|
const canonicalPath = remainder.slice(slashIndex) || "/";
|
||||||
|
let decoded: string | undefined;
|
||||||
|
try {
|
||||||
|
decoded = decodeURIComponent(encodedCapability);
|
||||||
|
} catch {
|
||||||
|
malformedScopedPath = true;
|
||||||
|
}
|
||||||
|
capabilityFromPath = normalizeCapability(decoded);
|
||||||
|
if (!capabilityFromPath || !canonicalPath.startsWith("/")) {
|
||||||
|
malformedScopedPath = true;
|
||||||
|
} else {
|
||||||
|
url.pathname = canonicalPath;
|
||||||
|
if (!url.searchParams.has(CANVAS_CAPABILITY_QUERY_PARAM)) {
|
||||||
|
url.searchParams.set(CANVAS_CAPABILITY_QUERY_PARAM, capabilityFromPath);
|
||||||
|
}
|
||||||
|
rewrittenUrl = `${url.pathname}${url.search}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const capability =
|
||||||
|
capabilityFromPath ?? normalizeCapability(url.searchParams.get(CANVAS_CAPABILITY_QUERY_PARAM));
|
||||||
|
return {
|
||||||
|
pathname: url.pathname,
|
||||||
|
capability,
|
||||||
|
rewrittenUrl,
|
||||||
|
scopedPath,
|
||||||
|
malformedScopedPath,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
isSecureWebSocketUrl,
|
isSecureWebSocketUrl,
|
||||||
isTrustedProxyAddress,
|
isTrustedProxyAddress,
|
||||||
pickPrimaryLanIPv4,
|
pickPrimaryLanIPv4,
|
||||||
|
resolveGatewayClientIp,
|
||||||
resolveGatewayListenHosts,
|
resolveGatewayListenHosts,
|
||||||
resolveHostName,
|
resolveHostName,
|
||||||
} from "./net.js";
|
} from "./net.js";
|
||||||
@@ -131,6 +132,43 @@ describe("isTrustedProxyAddress", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("resolveGatewayClientIp", () => {
|
||||||
|
it("returns remote IP when the remote is not a trusted proxy", () => {
|
||||||
|
const ip = resolveGatewayClientIp({
|
||||||
|
remoteAddr: "203.0.113.10",
|
||||||
|
forwardedFor: "10.0.0.2",
|
||||||
|
trustedProxies: ["127.0.0.1"],
|
||||||
|
});
|
||||||
|
expect(ip).toBe("203.0.113.10");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns forwarded client IP when the remote is a trusted proxy", () => {
|
||||||
|
const ip = resolveGatewayClientIp({
|
||||||
|
remoteAddr: "127.0.0.1",
|
||||||
|
forwardedFor: "10.0.0.2, 127.0.0.1",
|
||||||
|
trustedProxies: ["127.0.0.1"],
|
||||||
|
});
|
||||||
|
expect(ip).toBe("10.0.0.2");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails closed when trusted proxy headers are missing", () => {
|
||||||
|
const ip = resolveGatewayClientIp({
|
||||||
|
remoteAddr: "127.0.0.1",
|
||||||
|
trustedProxies: ["127.0.0.1"],
|
||||||
|
});
|
||||||
|
expect(ip).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("supports IPv6 client IP forwarded by a trusted proxy", () => {
|
||||||
|
const ip = resolveGatewayClientIp({
|
||||||
|
remoteAddr: "127.0.0.1",
|
||||||
|
realIp: "[2001:db8::5]",
|
||||||
|
trustedProxies: ["127.0.0.1"],
|
||||||
|
});
|
||||||
|
expect(ip).toBe("2001:db8::5");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("resolveGatewayListenHosts", () => {
|
describe("resolveGatewayListenHosts", () => {
|
||||||
it("returns the input host when not loopback", async () => {
|
it("returns the input host when not loopback", async () => {
|
||||||
const hosts = await resolveGatewayListenHosts("0.0.0.0", {
|
const hosts = await resolveGatewayListenHosts("0.0.0.0", {
|
||||||
|
|||||||
@@ -240,7 +240,10 @@ export function resolveGatewayClientIp(params: {
|
|||||||
if (!isTrustedProxyAddress(remote, params.trustedProxies)) {
|
if (!isTrustedProxyAddress(remote, params.trustedProxies)) {
|
||||||
return remote;
|
return remote;
|
||||||
}
|
}
|
||||||
return parseForwardedForClientIp(params.forwardedFor) ?? parseRealIp(params.realIp) ?? remote;
|
// Fail closed when traffic comes from a trusted proxy but client-origin headers
|
||||||
|
// are missing or invalid. Falling back to the proxy's own IP can accidentally
|
||||||
|
// treat unrelated requests as local/trusted.
|
||||||
|
return parseForwardedForClientIp(params.forwardedFor) ?? parseRealIp(params.realIp);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isLocalGatewayAddress(ip: string | undefined): boolean {
|
export function isLocalGatewayAddress(ip: string | undefined): boolean {
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import {
|
|||||||
type GatewayAuthResult,
|
type GatewayAuthResult,
|
||||||
type ResolvedGatewayAuth,
|
type ResolvedGatewayAuth,
|
||||||
} from "./auth.js";
|
} from "./auth.js";
|
||||||
|
import { CANVAS_CAPABILITY_TTL_MS, normalizeCanvasScopedUrl } from "./canvas-capability.js";
|
||||||
import {
|
import {
|
||||||
handleControlUiAvatarRequest,
|
handleControlUiAvatarRequest,
|
||||||
handleControlUiHttpRequest,
|
handleControlUiHttpRequest,
|
||||||
@@ -49,12 +50,7 @@ import {
|
|||||||
resolveHookDeliver,
|
resolveHookDeliver,
|
||||||
} from "./hooks.js";
|
} from "./hooks.js";
|
||||||
import { sendGatewayAuthFailure, setDefaultSecurityHeaders } from "./http-common.js";
|
import { sendGatewayAuthFailure, setDefaultSecurityHeaders } from "./http-common.js";
|
||||||
import { getBearerToken, getHeader } from "./http-utils.js";
|
import { getBearerToken } from "./http-utils.js";
|
||||||
import {
|
|
||||||
isPrivateOrLoopbackAddress,
|
|
||||||
isTrustedProxyAddress,
|
|
||||||
resolveGatewayClientIp,
|
|
||||||
} from "./net.js";
|
|
||||||
import { handleOpenAiHttpRequest } from "./openai-http.js";
|
import { handleOpenAiHttpRequest } from "./openai-http.js";
|
||||||
import { handleOpenResponsesHttpRequest } from "./openresponses-http.js";
|
import { handleOpenResponsesHttpRequest } from "./openresponses-http.js";
|
||||||
import { GATEWAY_CLIENT_MODES, normalizeGatewayClientMode } from "./protocol/client-info.js";
|
import { GATEWAY_CLIENT_MODES, normalizeGatewayClientMode } from "./protocol/client-info.js";
|
||||||
@@ -109,9 +105,24 @@ function isNodeWsClient(client: GatewayWsClient): boolean {
|
|||||||
return normalizeGatewayClientMode(client.connect.client.mode) === GATEWAY_CLIENT_MODES.NODE;
|
return normalizeGatewayClientMode(client.connect.client.mode) === GATEWAY_CLIENT_MODES.NODE;
|
||||||
}
|
}
|
||||||
|
|
||||||
function hasAuthorizedNodeWsClientForIp(clients: Set<GatewayWsClient>, clientIp: string): boolean {
|
function hasAuthorizedNodeWsClientForCanvasCapability(
|
||||||
|
clients: Set<GatewayWsClient>,
|
||||||
|
capability: string,
|
||||||
|
): boolean {
|
||||||
|
const nowMs = Date.now();
|
||||||
for (const client of clients) {
|
for (const client of clients) {
|
||||||
if (client.clientIp && client.clientIp === clientIp && isNodeWsClient(client)) {
|
if (!isNodeWsClient(client)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!client.canvasCapability || !client.canvasCapabilityExpiresAtMs) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (client.canvasCapabilityExpiresAtMs <= nowMs) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (safeEqualSecret(client.canvasCapability, capability)) {
|
||||||
|
// Sliding expiration while the connected node keeps using canvas.
|
||||||
|
client.canvasCapabilityExpiresAtMs = nowMs + CANVAS_CAPABILITY_TTL_MS;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -123,16 +134,19 @@ async function authorizeCanvasRequest(params: {
|
|||||||
auth: ResolvedGatewayAuth;
|
auth: ResolvedGatewayAuth;
|
||||||
trustedProxies: string[];
|
trustedProxies: string[];
|
||||||
clients: Set<GatewayWsClient>;
|
clients: Set<GatewayWsClient>;
|
||||||
|
canvasCapability?: string;
|
||||||
|
malformedScopedPath?: boolean;
|
||||||
rateLimiter?: AuthRateLimiter;
|
rateLimiter?: AuthRateLimiter;
|
||||||
}): Promise<GatewayAuthResult> {
|
}): Promise<GatewayAuthResult> {
|
||||||
const { req, auth, trustedProxies, clients, rateLimiter } = params;
|
const { req, auth, trustedProxies, clients, canvasCapability, malformedScopedPath, rateLimiter } =
|
||||||
|
params;
|
||||||
|
if (malformedScopedPath) {
|
||||||
|
return { ok: false, reason: "unauthorized" };
|
||||||
|
}
|
||||||
if (isLocalDirectRequest(req, trustedProxies)) {
|
if (isLocalDirectRequest(req, trustedProxies)) {
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasProxyHeaders = Boolean(getHeader(req, "x-forwarded-for") || getHeader(req, "x-real-ip"));
|
|
||||||
const remoteIsTrustedProxy = isTrustedProxyAddress(req.socket?.remoteAddress, trustedProxies);
|
|
||||||
|
|
||||||
let lastAuthFailure: GatewayAuthResult | null = null;
|
let lastAuthFailure: GatewayAuthResult | null = null;
|
||||||
const token = getBearerToken(req);
|
const token = getBearerToken(req);
|
||||||
if (token) {
|
if (token) {
|
||||||
@@ -149,27 +163,7 @@ async function authorizeCanvasRequest(params: {
|
|||||||
lastAuthFailure = authResult;
|
lastAuthFailure = authResult;
|
||||||
}
|
}
|
||||||
|
|
||||||
const clientIp = resolveGatewayClientIp({
|
if (canvasCapability && hasAuthorizedNodeWsClientForCanvasCapability(clients, canvasCapability)) {
|
||||||
remoteAddr: req.socket?.remoteAddress ?? "",
|
|
||||||
forwardedFor: getHeader(req, "x-forwarded-for"),
|
|
||||||
realIp: getHeader(req, "x-real-ip"),
|
|
||||||
trustedProxies,
|
|
||||||
});
|
|
||||||
if (!clientIp) {
|
|
||||||
return lastAuthFailure ?? { ok: false, reason: "unauthorized" };
|
|
||||||
}
|
|
||||||
|
|
||||||
// IP-based fallback is only safe for machine-scoped addresses.
|
|
||||||
// Only allow IP-based fallback for private/loopback addresses to prevent
|
|
||||||
// cross-session access in shared-IP environments (corporate NAT, cloud).
|
|
||||||
if (!isPrivateOrLoopbackAddress(clientIp)) {
|
|
||||||
return lastAuthFailure ?? { ok: false, reason: "unauthorized" };
|
|
||||||
}
|
|
||||||
// Ignore IP fallback when proxy headers come from an untrusted source.
|
|
||||||
if (hasProxyHeaders && !remoteIsTrustedProxy) {
|
|
||||||
return lastAuthFailure ?? { ok: false, reason: "unauthorized" };
|
|
||||||
}
|
|
||||||
if (hasAuthorizedNodeWsClientForIp(clients, clientIp)) {
|
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
}
|
}
|
||||||
return lastAuthFailure ?? { ok: false, reason: "unauthorized" };
|
return lastAuthFailure ?? { ok: false, reason: "unauthorized" };
|
||||||
@@ -503,6 +497,14 @@ export function createGatewayHttpServer(opts: {
|
|||||||
try {
|
try {
|
||||||
const configSnapshot = loadConfig();
|
const configSnapshot = loadConfig();
|
||||||
const trustedProxies = configSnapshot.gateway?.trustedProxies ?? [];
|
const trustedProxies = configSnapshot.gateway?.trustedProxies ?? [];
|
||||||
|
const scopedCanvas = normalizeCanvasScopedUrl(req.url ?? "/");
|
||||||
|
if (scopedCanvas.malformedScopedPath) {
|
||||||
|
sendGatewayAuthFailure(res, { ok: false, reason: "unauthorized" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (scopedCanvas.rewrittenUrl) {
|
||||||
|
req.url = scopedCanvas.rewrittenUrl;
|
||||||
|
}
|
||||||
const requestPath = new URL(req.url ?? "/", "http://localhost").pathname;
|
const requestPath = new URL(req.url ?? "/", "http://localhost").pathname;
|
||||||
if (await handleHooksRequest(req, res)) {
|
if (await handleHooksRequest(req, res)) {
|
||||||
return;
|
return;
|
||||||
@@ -571,6 +573,8 @@ export function createGatewayHttpServer(opts: {
|
|||||||
auth: resolvedAuth,
|
auth: resolvedAuth,
|
||||||
trustedProxies,
|
trustedProxies,
|
||||||
clients,
|
clients,
|
||||||
|
canvasCapability: scopedCanvas.capability,
|
||||||
|
malformedScopedPath: scopedCanvas.malformedScopedPath,
|
||||||
rateLimiter,
|
rateLimiter,
|
||||||
});
|
});
|
||||||
if (!ok.ok) {
|
if (!ok.ok) {
|
||||||
@@ -630,6 +634,15 @@ export function attachGatewayUpgradeHandler(opts: {
|
|||||||
const { httpServer, wss, canvasHost, clients, resolvedAuth, rateLimiter } = opts;
|
const { httpServer, wss, canvasHost, clients, resolvedAuth, rateLimiter } = opts;
|
||||||
httpServer.on("upgrade", (req, socket, head) => {
|
httpServer.on("upgrade", (req, socket, head) => {
|
||||||
void (async () => {
|
void (async () => {
|
||||||
|
const scopedCanvas = normalizeCanvasScopedUrl(req.url ?? "/");
|
||||||
|
if (scopedCanvas.malformedScopedPath) {
|
||||||
|
writeUpgradeAuthFailure(socket, { ok: false, reason: "unauthorized" });
|
||||||
|
socket.destroy();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (scopedCanvas.rewrittenUrl) {
|
||||||
|
req.url = scopedCanvas.rewrittenUrl;
|
||||||
|
}
|
||||||
if (canvasHost) {
|
if (canvasHost) {
|
||||||
const url = new URL(req.url ?? "/", "http://localhost");
|
const url = new URL(req.url ?? "/", "http://localhost");
|
||||||
if (url.pathname === CANVAS_WS_PATH) {
|
if (url.pathname === CANVAS_WS_PATH) {
|
||||||
@@ -640,6 +653,8 @@ export function attachGatewayUpgradeHandler(opts: {
|
|||||||
auth: resolvedAuth,
|
auth: resolvedAuth,
|
||||||
trustedProxies,
|
trustedProxies,
|
||||||
clients,
|
clients,
|
||||||
|
canvasCapability: scopedCanvas.capability,
|
||||||
|
malformedScopedPath: scopedCanvas.malformedScopedPath,
|
||||||
rateLimiter,
|
rateLimiter,
|
||||||
});
|
});
|
||||||
if (!ok.ok) {
|
if (!ok.ok) {
|
||||||
|
|||||||
@@ -4,18 +4,24 @@ import { A2UI_PATH, CANVAS_HOST_PATH, CANVAS_WS_PATH } from "../canvas-host/a2ui
|
|||||||
import type { CanvasHostHandler } from "../canvas-host/server.js";
|
import type { CanvasHostHandler } from "../canvas-host/server.js";
|
||||||
import { createAuthRateLimiter } from "./auth-rate-limit.js";
|
import { createAuthRateLimiter } from "./auth-rate-limit.js";
|
||||||
import type { ResolvedGatewayAuth } from "./auth.js";
|
import type { ResolvedGatewayAuth } from "./auth.js";
|
||||||
|
import { CANVAS_CAPABILITY_PATH_PREFIX } from "./canvas-capability.js";
|
||||||
import { attachGatewayUpgradeHandler, createGatewayHttpServer } from "./server-http.js";
|
import { attachGatewayUpgradeHandler, createGatewayHttpServer } from "./server-http.js";
|
||||||
import type { GatewayWsClient } from "./server/ws-types.js";
|
import type { GatewayWsClient } from "./server/ws-types.js";
|
||||||
import { withTempConfig } from "./test-temp-config.js";
|
import { withTempConfig } from "./test-temp-config.js";
|
||||||
|
|
||||||
async function listen(server: ReturnType<typeof createGatewayHttpServer>): Promise<{
|
async function listen(
|
||||||
|
server: ReturnType<typeof createGatewayHttpServer>,
|
||||||
|
host = "127.0.0.1",
|
||||||
|
): Promise<{
|
||||||
|
host: string;
|
||||||
port: number;
|
port: number;
|
||||||
close: () => Promise<void>;
|
close: () => Promise<void>;
|
||||||
}> {
|
}> {
|
||||||
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
await new Promise<void>((resolve) => server.listen(0, host, resolve));
|
||||||
const addr = server.address();
|
const addr = server.address();
|
||||||
const port = typeof addr === "object" && addr ? addr.port : 0;
|
const port = typeof addr === "object" && addr ? addr.port : 0;
|
||||||
return {
|
return {
|
||||||
|
host,
|
||||||
port,
|
port,
|
||||||
close: async () => {
|
close: async () => {
|
||||||
await new Promise<void>((resolve, reject) =>
|
await new Promise<void>((resolve, reject) =>
|
||||||
@@ -55,6 +61,8 @@ function makeWsClient(params: {
|
|||||||
clientIp: string;
|
clientIp: string;
|
||||||
role: "node" | "operator";
|
role: "node" | "operator";
|
||||||
mode: "node" | "backend";
|
mode: "node" | "backend";
|
||||||
|
canvasCapability?: string;
|
||||||
|
canvasCapabilityExpiresAtMs?: number;
|
||||||
}): GatewayWsClient {
|
}): GatewayWsClient {
|
||||||
return {
|
return {
|
||||||
socket: {} as unknown as WebSocket,
|
socket: {} as unknown as WebSocket,
|
||||||
@@ -66,11 +74,18 @@ function makeWsClient(params: {
|
|||||||
} as GatewayWsClient["connect"],
|
} as GatewayWsClient["connect"],
|
||||||
connId: params.connId,
|
connId: params.connId,
|
||||||
clientIp: params.clientIp,
|
clientIp: params.clientIp,
|
||||||
|
canvasCapability: params.canvasCapability,
|
||||||
|
canvasCapabilityExpiresAtMs: params.canvasCapabilityExpiresAtMs,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function scopedCanvasPath(capability: string, path: string): string {
|
||||||
|
return `${CANVAS_CAPABILITY_PATH_PREFIX}/${encodeURIComponent(capability)}${path}`;
|
||||||
|
}
|
||||||
|
|
||||||
async function withCanvasGatewayHarness(params: {
|
async function withCanvasGatewayHarness(params: {
|
||||||
resolvedAuth: ResolvedGatewayAuth;
|
resolvedAuth: ResolvedGatewayAuth;
|
||||||
|
listenHost?: string;
|
||||||
rateLimiter?: ReturnType<typeof createAuthRateLimiter>;
|
rateLimiter?: ReturnType<typeof createAuthRateLimiter>;
|
||||||
handleHttpRequest: CanvasHostHandler["handleHttpRequest"];
|
handleHttpRequest: CanvasHostHandler["handleHttpRequest"];
|
||||||
run: (ctx: {
|
run: (ctx: {
|
||||||
@@ -117,7 +132,7 @@ async function withCanvasGatewayHarness(params: {
|
|||||||
rateLimiter: params.rateLimiter,
|
rateLimiter: params.rateLimiter,
|
||||||
});
|
});
|
||||||
|
|
||||||
const listener = await listen(httpServer);
|
const listener = await listen(httpServer, params.listenHost);
|
||||||
try {
|
try {
|
||||||
await params.run({ listener, clients });
|
await params.run({ listener, clients });
|
||||||
} finally {
|
} finally {
|
||||||
@@ -129,7 +144,7 @@ async function withCanvasGatewayHarness(params: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("gateway canvas host auth", () => {
|
describe("gateway canvas host auth", () => {
|
||||||
test("allows canvas IP fallback for private/CGNAT addresses and denies public fallback", async () => {
|
test("authorizes canvas HTTP/WS via node-scoped capability and rejects misuse", async () => {
|
||||||
const resolvedAuth: ResolvedGatewayAuth = {
|
const resolvedAuth: ResolvedGatewayAuth = {
|
||||||
mode: "token",
|
mode: "token",
|
||||||
token: "test-token",
|
token: "test-token",
|
||||||
@@ -161,110 +176,74 @@ describe("gateway canvas host auth", () => {
|
|||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
run: async ({ listener, clients }) => {
|
run: async ({ listener, clients }) => {
|
||||||
const privateIpA = "192.168.1.10";
|
const host = "127.0.0.1";
|
||||||
const privateIpB = "192.168.1.11";
|
const operatorOnlyCapability = "operator-only";
|
||||||
const publicIp = "203.0.113.10";
|
const expiredNodeCapability = "expired-node";
|
||||||
const cgnatIp = "100.100.100.100";
|
const activeNodeCapability = "active-node";
|
||||||
|
const activeCanvasPath = scopedCanvasPath(activeNodeCapability, `${CANVAS_HOST_PATH}/`);
|
||||||
|
const activeWsPath = scopedCanvasPath(activeNodeCapability, CANVAS_WS_PATH);
|
||||||
|
|
||||||
const unauthCanvas = await fetch(
|
const unauthCanvas = await fetch(`http://${host}:${listener.port}${CANVAS_HOST_PATH}/`);
|
||||||
`http://127.0.0.1:${listener.port}${CANVAS_HOST_PATH}/`,
|
|
||||||
{
|
|
||||||
headers: { "x-forwarded-for": privateIpA },
|
|
||||||
},
|
|
||||||
);
|
|
||||||
expect(unauthCanvas.status).toBe(401);
|
expect(unauthCanvas.status).toBe(401);
|
||||||
|
|
||||||
const unauthA2ui = await fetch(`http://127.0.0.1:${listener.port}${A2UI_PATH}/`, {
|
const malformedScoped = await fetch(
|
||||||
headers: { "x-forwarded-for": privateIpA },
|
`http://${host}:${listener.port}${CANVAS_CAPABILITY_PATH_PREFIX}/broken`,
|
||||||
});
|
);
|
||||||
expect(unauthA2ui.status).toBe(401);
|
expect(malformedScoped.status).toBe(401);
|
||||||
|
|
||||||
await expectWsRejected(`ws://127.0.0.1:${listener.port}${CANVAS_WS_PATH}`, {
|
|
||||||
"x-forwarded-for": privateIpA,
|
|
||||||
});
|
|
||||||
|
|
||||||
clients.add(
|
clients.add(
|
||||||
makeWsClient({
|
makeWsClient({
|
||||||
connId: "c-operator",
|
connId: "c-operator",
|
||||||
clientIp: privateIpA,
|
clientIp: "192.168.1.10",
|
||||||
role: "operator",
|
role: "operator",
|
||||||
mode: "backend",
|
mode: "backend",
|
||||||
|
canvasCapability: operatorOnlyCapability,
|
||||||
|
canvasCapabilityExpiresAtMs: Date.now() + 60_000,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const operatorCanvasStillBlocked = await fetch(
|
const operatorCapabilityBlocked = await fetch(
|
||||||
`http://127.0.0.1:${listener.port}${CANVAS_HOST_PATH}/`,
|
`http://${host}:${listener.port}${scopedCanvasPath(operatorOnlyCapability, `${CANVAS_HOST_PATH}/`)}`,
|
||||||
{
|
|
||||||
headers: { "x-forwarded-for": privateIpA },
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
expect(operatorCanvasStillBlocked.status).toBe(401);
|
expect(operatorCapabilityBlocked.status).toBe(401);
|
||||||
|
|
||||||
clients.add(
|
clients.add(
|
||||||
makeWsClient({
|
makeWsClient({
|
||||||
connId: "c-node",
|
connId: "c-expired-node",
|
||||||
clientIp: privateIpA,
|
clientIp: "192.168.1.20",
|
||||||
role: "node",
|
role: "node",
|
||||||
mode: "node",
|
mode: "node",
|
||||||
|
canvasCapability: expiredNodeCapability,
|
||||||
|
canvasCapabilityExpiresAtMs: Date.now() - 1,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const authCanvas = await fetch(
|
const expiredCapabilityBlocked = await fetch(
|
||||||
`http://127.0.0.1:${listener.port}${CANVAS_HOST_PATH}/`,
|
`http://${host}:${listener.port}${scopedCanvasPath(expiredNodeCapability, `${CANVAS_HOST_PATH}/`)}`,
|
||||||
{
|
|
||||||
headers: { "x-forwarded-for": privateIpA },
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
expect(authCanvas.status).toBe(200);
|
expect(expiredCapabilityBlocked.status).toBe(401);
|
||||||
expect(await authCanvas.text()).toBe("ok");
|
|
||||||
|
|
||||||
const otherIpStillBlocked = await fetch(
|
const activeNodeClient = makeWsClient({
|
||||||
`http://127.0.0.1:${listener.port}${CANVAS_HOST_PATH}/`,
|
connId: "c-active-node",
|
||||||
{
|
clientIp: "192.168.1.30",
|
||||||
headers: { "x-forwarded-for": privateIpB },
|
role: "node",
|
||||||
},
|
mode: "node",
|
||||||
);
|
canvasCapability: activeNodeCapability,
|
||||||
expect(otherIpStillBlocked.status).toBe(401);
|
canvasCapabilityExpiresAtMs: Date.now() + 60_000,
|
||||||
|
|
||||||
clients.add(
|
|
||||||
makeWsClient({
|
|
||||||
connId: "c-public",
|
|
||||||
clientIp: publicIp,
|
|
||||||
role: "node",
|
|
||||||
mode: "node",
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
const publicIpStillBlocked = await fetch(
|
|
||||||
`http://127.0.0.1:${listener.port}${CANVAS_HOST_PATH}/`,
|
|
||||||
{
|
|
||||||
headers: { "x-forwarded-for": publicIp },
|
|
||||||
},
|
|
||||||
);
|
|
||||||
expect(publicIpStillBlocked.status).toBe(401);
|
|
||||||
await expectWsRejected(`ws://127.0.0.1:${listener.port}${CANVAS_WS_PATH}`, {
|
|
||||||
"x-forwarded-for": publicIp,
|
|
||||||
});
|
});
|
||||||
|
clients.add(activeNodeClient);
|
||||||
|
|
||||||
clients.add(
|
const scopedCanvas = await fetch(`http://${host}:${listener.port}${activeCanvasPath}`);
|
||||||
makeWsClient({
|
expect(scopedCanvas.status).toBe(200);
|
||||||
connId: "c-cgnat",
|
expect(await scopedCanvas.text()).toBe("ok");
|
||||||
clientIp: cgnatIp,
|
|
||||||
role: "node",
|
const scopedA2ui = await fetch(
|
||||||
mode: "node",
|
`http://${host}:${listener.port}${scopedCanvasPath(activeNodeCapability, `${A2UI_PATH}/`)}`,
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
const cgnatAllowed = await fetch(
|
expect(scopedA2ui.status).toBe(200);
|
||||||
`http://127.0.0.1:${listener.port}${CANVAS_HOST_PATH}/`,
|
|
||||||
{
|
|
||||||
headers: { "x-forwarded-for": cgnatIp },
|
|
||||||
},
|
|
||||||
);
|
|
||||||
expect(cgnatAllowed.status).toBe(200);
|
|
||||||
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
await new Promise<void>((resolve, reject) => {
|
||||||
const ws = new WebSocket(`ws://127.0.0.1:${listener.port}${CANVAS_WS_PATH}`, {
|
const ws = new WebSocket(`ws://${host}:${listener.port}${activeWsPath}`);
|
||||||
headers: { "x-forwarded-for": privateIpA },
|
|
||||||
});
|
|
||||||
const timer = setTimeout(() => reject(new Error("timeout")), 10_000);
|
const timer = setTimeout(() => reject(new Error("timeout")), 10_000);
|
||||||
ws.once("open", () => {
|
ws.once("open", () => {
|
||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
@@ -277,13 +256,21 @@ describe("gateway canvas host auth", () => {
|
|||||||
});
|
});
|
||||||
ws.once("error", reject);
|
ws.once("error", reject);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
clients.delete(activeNodeClient);
|
||||||
|
|
||||||
|
const disconnectedNodeBlocked = await fetch(
|
||||||
|
`http://${host}:${listener.port}${activeCanvasPath}`,
|
||||||
|
);
|
||||||
|
expect(disconnectedNodeBlocked.status).toBe(401);
|
||||||
|
await expectWsRejected(`ws://${host}:${listener.port}${activeWsPath}`, {});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}, 60_000);
|
}, 60_000);
|
||||||
|
|
||||||
test("denies canvas IP fallback when proxy headers come from untrusted source", async () => {
|
test("denies canvas auth when trusted proxy omits forwarded client headers", async () => {
|
||||||
const resolvedAuth: ResolvedGatewayAuth = {
|
const resolvedAuth: ResolvedGatewayAuth = {
|
||||||
mode: "token",
|
mode: "token",
|
||||||
token: "test-token",
|
token: "test-token",
|
||||||
@@ -294,7 +281,7 @@ describe("gateway canvas host auth", () => {
|
|||||||
await withTempConfig({
|
await withTempConfig({
|
||||||
cfg: {
|
cfg: {
|
||||||
gateway: {
|
gateway: {
|
||||||
trustedProxies: [],
|
trustedProxies: ["127.0.0.1"],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
run: async () => {
|
run: async () => {
|
||||||
@@ -320,23 +307,98 @@ describe("gateway canvas host auth", () => {
|
|||||||
clientIp: "127.0.0.1",
|
clientIp: "127.0.0.1",
|
||||||
role: "node",
|
role: "node",
|
||||||
mode: "node",
|
mode: "node",
|
||||||
|
canvasCapability: "unused",
|
||||||
|
canvasCapabilityExpiresAtMs: Date.now() + 60_000,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const res = await fetch(`http://127.0.0.1:${listener.port}${CANVAS_HOST_PATH}/`, {
|
const res = await fetch(`http://127.0.0.1:${listener.port}${CANVAS_HOST_PATH}/`);
|
||||||
headers: { "x-forwarded-for": "192.168.1.10" },
|
|
||||||
});
|
|
||||||
expect(res.status).toBe(401);
|
expect(res.status).toBe(401);
|
||||||
|
|
||||||
await expectWsRejected(`ws://127.0.0.1:${listener.port}${CANVAS_WS_PATH}`, {
|
await expectWsRejected(`ws://127.0.0.1:${listener.port}${CANVAS_WS_PATH}`, {});
|
||||||
"x-forwarded-for": "192.168.1.10",
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}, 60_000);
|
}, 60_000);
|
||||||
|
|
||||||
|
test("accepts capability-scoped paths over IPv6 loopback", async () => {
|
||||||
|
const resolvedAuth: ResolvedGatewayAuth = {
|
||||||
|
mode: "token",
|
||||||
|
token: "test-token",
|
||||||
|
password: undefined,
|
||||||
|
allowTailscale: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
await withTempConfig({
|
||||||
|
cfg: {
|
||||||
|
gateway: {
|
||||||
|
trustedProxies: ["::1"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
run: async () => {
|
||||||
|
try {
|
||||||
|
await withCanvasGatewayHarness({
|
||||||
|
resolvedAuth,
|
||||||
|
listenHost: "::1",
|
||||||
|
handleHttpRequest: async (req, res) => {
|
||||||
|
const url = new URL(req.url ?? "/", "http://localhost");
|
||||||
|
if (
|
||||||
|
url.pathname !== CANVAS_HOST_PATH &&
|
||||||
|
!url.pathname.startsWith(`${CANVAS_HOST_PATH}/`)
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
res.statusCode = 200;
|
||||||
|
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
||||||
|
res.end("ok");
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
run: async ({ listener, clients }) => {
|
||||||
|
const capability = "ipv6-node";
|
||||||
|
clients.add(
|
||||||
|
makeWsClient({
|
||||||
|
connId: "c-ipv6-node",
|
||||||
|
clientIp: "fd12:3456:789a::2",
|
||||||
|
role: "node",
|
||||||
|
mode: "node",
|
||||||
|
canvasCapability: capability,
|
||||||
|
canvasCapabilityExpiresAtMs: Date.now() + 60_000,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const canvasPath = scopedCanvasPath(capability, `${CANVAS_HOST_PATH}/`);
|
||||||
|
const wsPath = scopedCanvasPath(capability, CANVAS_WS_PATH);
|
||||||
|
const scopedCanvas = await fetch(`http://[::1]:${listener.port}${canvasPath}`);
|
||||||
|
expect(scopedCanvas.status).toBe(200);
|
||||||
|
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
const ws = new WebSocket(`ws://[::1]:${listener.port}${wsPath}`);
|
||||||
|
const timer = setTimeout(() => reject(new Error("timeout")), 10_000);
|
||||||
|
ws.once("open", () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
ws.terminate();
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
ws.once("unexpected-response", (_req, res) => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
reject(new Error(`unexpected response ${res.statusCode}`));
|
||||||
|
});
|
||||||
|
ws.once("error", reject);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
const message = String(err);
|
||||||
|
if (message.includes("EAFNOSUPPORT") || message.includes("EADDRNOTAVAIL")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, 60_000);
|
||||||
|
|
||||||
test("returns 429 for repeated failed canvas auth attempts (HTTP + WS upgrade)", async () => {
|
test("returns 429 for repeated failed canvas auth attempts (HTTP + WS upgrade)", async () => {
|
||||||
const resolvedAuth: ResolvedGatewayAuth = {
|
const resolvedAuth: ResolvedGatewayAuth = {
|
||||||
mode: "token",
|
mode: "token",
|
||||||
|
|||||||
@@ -30,6 +30,11 @@ import {
|
|||||||
} from "../../auth-rate-limit.js";
|
} from "../../auth-rate-limit.js";
|
||||||
import type { GatewayAuthResult, ResolvedGatewayAuth } from "../../auth.js";
|
import type { GatewayAuthResult, ResolvedGatewayAuth } from "../../auth.js";
|
||||||
import { authorizeGatewayConnect, isLocalDirectRequest } from "../../auth.js";
|
import { authorizeGatewayConnect, isLocalDirectRequest } from "../../auth.js";
|
||||||
|
import {
|
||||||
|
buildCanvasScopedHostUrl,
|
||||||
|
CANVAS_CAPABILITY_TTL_MS,
|
||||||
|
mintCanvasCapabilityToken,
|
||||||
|
} from "../../canvas-capability.js";
|
||||||
import { buildDeviceAuthPayload } from "../../device-auth.js";
|
import { buildDeviceAuthPayload } from "../../device-auth.js";
|
||||||
import { isLoopbackAddress, isTrustedProxyAddress, resolveGatewayClientIp } from "../../net.js";
|
import { isLoopbackAddress, isTrustedProxyAddress, resolveGatewayClientIp } from "../../net.js";
|
||||||
import { resolveHostName } from "../../net.js";
|
import { resolveHostName } from "../../net.js";
|
||||||
@@ -822,6 +827,15 @@ export function attachGatewayWsMessageHandler(params: {
|
|||||||
snapshot.health = cachedHealth;
|
snapshot.health = cachedHealth;
|
||||||
snapshot.stateVersion.health = getHealthVersion();
|
snapshot.stateVersion.health = getHealthVersion();
|
||||||
}
|
}
|
||||||
|
const canvasCapability =
|
||||||
|
role === "node" && canvasHostUrl ? mintCanvasCapabilityToken() : undefined;
|
||||||
|
const canvasCapabilityExpiresAtMs = canvasCapability
|
||||||
|
? Date.now() + CANVAS_CAPABILITY_TTL_MS
|
||||||
|
: undefined;
|
||||||
|
const scopedCanvasHostUrl =
|
||||||
|
canvasHostUrl && canvasCapability
|
||||||
|
? (buildCanvasScopedHostUrl(canvasHostUrl, canvasCapability) ?? canvasHostUrl)
|
||||||
|
: canvasHostUrl;
|
||||||
const helloOk = {
|
const helloOk = {
|
||||||
type: "hello-ok",
|
type: "hello-ok",
|
||||||
protocol: PROTOCOL_VERSION,
|
protocol: PROTOCOL_VERSION,
|
||||||
@@ -833,7 +847,7 @@ export function attachGatewayWsMessageHandler(params: {
|
|||||||
},
|
},
|
||||||
features: { methods: gatewayMethods, events },
|
features: { methods: gatewayMethods, events },
|
||||||
snapshot,
|
snapshot,
|
||||||
canvasHostUrl,
|
canvasHostUrl: scopedCanvasHostUrl,
|
||||||
auth: deviceToken
|
auth: deviceToken
|
||||||
? {
|
? {
|
||||||
deviceToken: deviceToken.token,
|
deviceToken: deviceToken.token,
|
||||||
@@ -856,6 +870,8 @@ export function attachGatewayWsMessageHandler(params: {
|
|||||||
connId,
|
connId,
|
||||||
presenceKey,
|
presenceKey,
|
||||||
clientIp: reportedClientIp,
|
clientIp: reportedClientIp,
|
||||||
|
canvasCapability,
|
||||||
|
canvasCapabilityExpiresAtMs,
|
||||||
};
|
};
|
||||||
setClient(nextClient);
|
setClient(nextClient);
|
||||||
setHandshakeState("connected");
|
setHandshakeState("connected");
|
||||||
|
|||||||
@@ -7,4 +7,6 @@ export type GatewayWsClient = {
|
|||||||
connId: string;
|
connId: string;
|
||||||
presenceKey?: string;
|
presenceKey?: string;
|
||||||
clientIp?: string;
|
clientIp?: string;
|
||||||
|
canvasCapability?: string;
|
||||||
|
canvasCapabilityExpiresAtMs?: number;
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user