mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 05:40:44 +00:00
fix(gateway): clamp unbound websocket auth scopes [AI] (#77413)
* fix: clamp unapproved trusted proxy websocket scopes * addressing claude review * addressing claude review * addressing ci * addressing ci * docs: add changelog entry for PR merge
This commit is contained in:
committed by
GitHub
parent
c240e718e9
commit
0e702f1063
@@ -46,6 +46,7 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
### Fixes
|
||||
|
||||
- fix(gateway): clamp unbound websocket auth scopes [AI]. (#77413) Thanks @pgondhi987.
|
||||
- Gate zalouser startup name matching [AI]. (#77411) Thanks @pgondhi987.
|
||||
- fix(device-pair): require pairing scope for pair command [AI]. (#76377) Thanks @pgondhi987.
|
||||
- fix(qqbot): keep private commands off framework surface [AI]. (#77212) Thanks @pgondhi987.
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
} from "../../infra/restart-sentinel.js";
|
||||
import { scheduleGatewaySigusr1Restart } from "../../infra/restart.js";
|
||||
import { getActiveSecretsRuntimeSnapshot } from "../../secrets/runtime.js";
|
||||
import { resolveEffectiveSharedGatewayAuth } from "../auth.js";
|
||||
import { resolveEffectiveSharedGatewayAuth, resolveGatewayAuth } from "../auth.js";
|
||||
import { buildGatewayReloadPlan } from "../config-reload-plan.js";
|
||||
import { resolveGatewayReloadSettings } from "../config-reload-settings.js";
|
||||
import { formatControlPlaneActor, type ControlPlaneActor } from "../control-plane-audit.js";
|
||||
@@ -31,7 +31,51 @@ export function resolveGatewayConfigPath(snapshot?: Pick<ConfigWriteSnapshot, "p
|
||||
return snapshot?.path ?? createConfigIO().configPath;
|
||||
}
|
||||
|
||||
function normalizeStringListForAuthCompare(items: readonly string[] | undefined): string[] {
|
||||
return [...(items ?? [])].toSorted();
|
||||
}
|
||||
|
||||
function normalizeTrustedProxyAuthForCompare(auth: ReturnType<typeof resolveGatewayAuth>): {
|
||||
userHeader: string | undefined;
|
||||
requiredHeaders: string[];
|
||||
allowUsers: string[];
|
||||
allowLoopback: boolean | undefined;
|
||||
} {
|
||||
return {
|
||||
userHeader: auth.trustedProxy?.userHeader,
|
||||
requiredHeaders: normalizeStringListForAuthCompare(auth.trustedProxy?.requiredHeaders),
|
||||
allowUsers: normalizeStringListForAuthCompare(auth.trustedProxy?.allowUsers),
|
||||
allowLoopback: auth.trustedProxy?.allowLoopback,
|
||||
};
|
||||
}
|
||||
|
||||
export function didSharedGatewayAuthChange(prev: OpenClawConfig, next: OpenClawConfig): boolean {
|
||||
const prevResolvedAuth = resolveGatewayAuth({
|
||||
authConfig: prev.gateway?.auth,
|
||||
env: process.env,
|
||||
tailscaleMode: prev.gateway?.tailscale?.mode,
|
||||
});
|
||||
const nextResolvedAuth = resolveGatewayAuth({
|
||||
authConfig: next.gateway?.auth,
|
||||
env: process.env,
|
||||
tailscaleMode: next.gateway?.tailscale?.mode,
|
||||
});
|
||||
if (prevResolvedAuth.mode === "trusted-proxy" || nextResolvedAuth.mode === "trusted-proxy") {
|
||||
if (prevResolvedAuth.mode !== nextResolvedAuth.mode) {
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
!isDeepStrictEqual(
|
||||
normalizeTrustedProxyAuthForCompare(prevResolvedAuth),
|
||||
normalizeTrustedProxyAuthForCompare(nextResolvedAuth),
|
||||
) ||
|
||||
!isDeepStrictEqual(
|
||||
normalizeStringListForAuthCompare(prev.gateway?.trustedProxies),
|
||||
normalizeStringListForAuthCompare(next.gateway?.trustedProxies),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const prevAuth = resolveEffectiveSharedGatewayAuth({
|
||||
authConfig: prev.gateway?.auth,
|
||||
env: process.env,
|
||||
|
||||
@@ -168,6 +168,123 @@ describe("config shared auth disconnects", () => {
|
||||
expect(disconnectClientsUsingSharedGatewayAuth).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("disconnects gateway-auth clients when active trusted-proxy policy changes", async () => {
|
||||
const prevConfig: OpenClawConfig = {
|
||||
gateway: {
|
||||
auth: {
|
||||
mode: "trusted-proxy",
|
||||
trustedProxy: {
|
||||
userHeader: "x-forwarded-user",
|
||||
allowUsers: ["alice@example.com"],
|
||||
},
|
||||
},
|
||||
trustedProxies: ["127.0.0.1"],
|
||||
},
|
||||
};
|
||||
readConfigFileSnapshotForWriteMock.mockResolvedValue(createConfigWriteSnapshot(prevConfig));
|
||||
|
||||
const { options, disconnectClientsUsingSharedGatewayAuth } = createConfigHandlerHarness({
|
||||
method: "config.patch",
|
||||
params: {
|
||||
baseHash: "base-hash",
|
||||
raw: JSON.stringify({
|
||||
gateway: {
|
||||
auth: {
|
||||
trustedProxy: {
|
||||
userHeader: "x-forwarded-user",
|
||||
allowUsers: ["bob@example.com"],
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
restartDelayMs: 1_000,
|
||||
},
|
||||
});
|
||||
|
||||
await configHandlers["config.patch"](options);
|
||||
await flushConfigHandlerMicrotasks();
|
||||
|
||||
expect(scheduleGatewaySigusr1RestartMock).not.toHaveBeenCalled();
|
||||
expect(disconnectClientsUsingSharedGatewayAuth).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("disconnects gateway-auth clients when trusted-proxy source list changes", async () => {
|
||||
const prevConfig: OpenClawConfig = {
|
||||
gateway: {
|
||||
auth: {
|
||||
mode: "trusted-proxy",
|
||||
trustedProxy: {
|
||||
userHeader: "x-forwarded-user",
|
||||
},
|
||||
},
|
||||
trustedProxies: ["127.0.0.1"],
|
||||
},
|
||||
};
|
||||
readConfigFileSnapshotForWriteMock.mockResolvedValue(createConfigWriteSnapshot(prevConfig));
|
||||
|
||||
const { options, disconnectClientsUsingSharedGatewayAuth } = createConfigHandlerHarness({
|
||||
method: "config.patch",
|
||||
params: {
|
||||
baseHash: "base-hash",
|
||||
raw: JSON.stringify({
|
||||
gateway: {
|
||||
trustedProxies: ["10.0.0.10"],
|
||||
},
|
||||
}),
|
||||
restartDelayMs: 1_000,
|
||||
},
|
||||
});
|
||||
|
||||
await configHandlers["config.patch"](options);
|
||||
await flushConfigHandlerMicrotasks();
|
||||
|
||||
expect(scheduleGatewaySigusr1RestartMock).not.toHaveBeenCalled();
|
||||
expect(disconnectClientsUsingSharedGatewayAuth).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not disconnect gateway-auth clients when trusted-proxy lists are reordered", async () => {
|
||||
const prevConfig: OpenClawConfig = {
|
||||
gateway: {
|
||||
auth: {
|
||||
mode: "trusted-proxy",
|
||||
trustedProxy: {
|
||||
userHeader: "x-forwarded-user",
|
||||
requiredHeaders: ["x-forwarded-proto", "x-forwarded-host"],
|
||||
allowUsers: ["alice@example.com", "bob@example.com"],
|
||||
},
|
||||
},
|
||||
trustedProxies: ["127.0.0.1", "10.0.0.10"],
|
||||
},
|
||||
};
|
||||
readConfigFileSnapshotForWriteMock.mockResolvedValue(createConfigWriteSnapshot(prevConfig));
|
||||
|
||||
const { options, disconnectClientsUsingSharedGatewayAuth } = createConfigHandlerHarness({
|
||||
method: "config.patch",
|
||||
params: {
|
||||
baseHash: "base-hash",
|
||||
raw: JSON.stringify({
|
||||
gateway: {
|
||||
auth: {
|
||||
trustedProxy: {
|
||||
userHeader: "x-forwarded-user",
|
||||
requiredHeaders: ["x-forwarded-host", "x-forwarded-proto"],
|
||||
allowUsers: ["bob@example.com", "alice@example.com"],
|
||||
},
|
||||
},
|
||||
trustedProxies: ["10.0.0.10", "127.0.0.1"],
|
||||
},
|
||||
}),
|
||||
restartDelayMs: 1_000,
|
||||
},
|
||||
});
|
||||
|
||||
await configHandlers["config.patch"](options);
|
||||
await flushConfigHandlerMicrotasks();
|
||||
|
||||
expect(scheduleGatewaySigusr1RestartMock).not.toHaveBeenCalled();
|
||||
expect(disconnectClientsUsingSharedGatewayAuth).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("still schedules a direct restart for hot mode when the reloader cannot apply the change", async () => {
|
||||
const prevConfig: OpenClawConfig = {
|
||||
gateway: {
|
||||
|
||||
@@ -6,7 +6,9 @@ import {
|
||||
connectReq,
|
||||
CONTROL_UI_CLIENT,
|
||||
ConnectErrorDetailCodes,
|
||||
createSignedDevice,
|
||||
getFreePort,
|
||||
readConnectChallengeNonce,
|
||||
openWs,
|
||||
originForPort,
|
||||
rpcReq,
|
||||
@@ -312,7 +314,7 @@ describe("gateway auth compatibility baseline", () => {
|
||||
testState.gatewayAuth = { mode: "none" };
|
||||
delete process.env.OPENCLAW_GATEWAY_TOKEN;
|
||||
port = await getFreePort();
|
||||
server = await startGatewayServer(port);
|
||||
server = await startGatewayServer(port, { controlUiEnabled: true });
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
@@ -329,5 +331,89 @@ describe("gateway auth compatibility baseline", () => {
|
||||
ws.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps auth-none control ui first-connect token absence unchanged", async () => {
|
||||
const ws = await openWs(port, { origin: originForPort(port) });
|
||||
try {
|
||||
const deviceIdentityPath = path.join(
|
||||
os.tmpdir(),
|
||||
`openclaw-auth-none-control-ui-first-${process.pid}-${port}.json`,
|
||||
);
|
||||
const res = await connectReq(ws, {
|
||||
skipDefaultAuth: true,
|
||||
client: { ...CONTROL_UI_CLIENT },
|
||||
scopes: ["operator.read"],
|
||||
deviceIdentityPath,
|
||||
});
|
||||
expect(res.ok).toBe(true);
|
||||
const helloOk = res.payload as
|
||||
| {
|
||||
auth?: {
|
||||
deviceToken?: unknown;
|
||||
};
|
||||
}
|
||||
| undefined;
|
||||
expect(helloOk?.auth?.deviceToken).toBeUndefined();
|
||||
} finally {
|
||||
ws.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps auth-none control ui stale-key token handoff unchanged", async () => {
|
||||
const ws = await openWs(port, { origin: originForPort(port) });
|
||||
try {
|
||||
const { loadOrCreateDeviceIdentity, publicKeyRawBase64UrlFromPem } =
|
||||
await import("../infra/device-identity.js");
|
||||
const { approveDevicePairing, requestDevicePairing } =
|
||||
await import("../infra/device-pairing.js");
|
||||
const nonce = await readConnectChallengeNonce(ws);
|
||||
const identityPath = path.join(
|
||||
os.tmpdir(),
|
||||
`openclaw-auth-none-control-ui-${process.pid}-${port}.json`,
|
||||
);
|
||||
const staleIdentityPath = path.join(
|
||||
os.tmpdir(),
|
||||
`openclaw-auth-none-control-ui-stale-${process.pid}-${port}.json`,
|
||||
);
|
||||
const { identity, device } = await createSignedDevice({
|
||||
token: null,
|
||||
scopes: ["operator.read"],
|
||||
clientId: CONTROL_UI_CLIENT.id,
|
||||
clientMode: CONTROL_UI_CLIENT.mode,
|
||||
identityPath,
|
||||
nonce,
|
||||
});
|
||||
const staleIdentity = loadOrCreateDeviceIdentity(staleIdentityPath);
|
||||
const pending = await requestDevicePairing({
|
||||
deviceId: identity.deviceId,
|
||||
publicKey: publicKeyRawBase64UrlFromPem(staleIdentity.publicKeyPem),
|
||||
clientId: CONTROL_UI_CLIENT.id,
|
||||
clientMode: CONTROL_UI_CLIENT.mode,
|
||||
role: "operator",
|
||||
scopes: ["operator.read"],
|
||||
});
|
||||
await approveDevicePairing(pending.request.requestId, {
|
||||
callerScopes: ["operator.admin"],
|
||||
});
|
||||
|
||||
const res = await connectReq(ws, {
|
||||
skipDefaultAuth: true,
|
||||
client: { ...CONTROL_UI_CLIENT },
|
||||
scopes: ["operator.read"],
|
||||
device,
|
||||
});
|
||||
expect(res.ok).toBe(true);
|
||||
const helloOk = res.payload as
|
||||
| {
|
||||
auth?: {
|
||||
deviceToken?: unknown;
|
||||
};
|
||||
}
|
||||
| undefined;
|
||||
expect(typeof helloOk?.auth?.deviceToken).toBe("string");
|
||||
} finally {
|
||||
ws.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -314,6 +314,68 @@ export function registerControlUiAndPairingSuite(): void {
|
||||
});
|
||||
});
|
||||
|
||||
test("clamps trusted-proxy control ui scopes for unpaired device identity", async () => {
|
||||
const { replaceConfigFile } = await import("../config/config.js");
|
||||
testState.gatewayAuth = undefined;
|
||||
testState.gatewayControlUi = {
|
||||
...testState.gatewayControlUi,
|
||||
allowedOrigins: ["https://localhost"],
|
||||
};
|
||||
await replaceConfigFile({
|
||||
nextConfig: {
|
||||
gateway: {
|
||||
auth: {
|
||||
mode: "trusted-proxy",
|
||||
trustedProxy: {
|
||||
userHeader: "x-forwarded-user",
|
||||
requiredHeaders: ["x-forwarded-proto"],
|
||||
allowLoopback: true,
|
||||
},
|
||||
},
|
||||
trustedProxies: ["127.0.0.1"],
|
||||
controlUi: {
|
||||
allowedOrigins: ["https://localhost"],
|
||||
},
|
||||
},
|
||||
},
|
||||
afterWrite: { mode: "auto" },
|
||||
});
|
||||
await withControlUiGatewayServer(async ({ port }) => {
|
||||
const ws = await openWs(port, TRUSTED_PROXY_CONTROL_UI_HEADERS);
|
||||
try {
|
||||
const challengeNonce = await readConnectChallengeNonce(ws);
|
||||
const { device } = await createSignedDevice({
|
||||
token: null,
|
||||
role: "operator",
|
||||
scopes: ["operator.admin"],
|
||||
clientId: CONTROL_UI_CLIENT.id,
|
||||
clientMode: CONTROL_UI_CLIENT.mode,
|
||||
nonce: challengeNonce,
|
||||
});
|
||||
const res = await connectReq(ws, {
|
||||
skipDefaultAuth: true,
|
||||
scopes: ["operator.admin"],
|
||||
device,
|
||||
client: { ...CONTROL_UI_CLIENT },
|
||||
});
|
||||
expect(res.ok).toBe(true);
|
||||
const payload = res.payload as
|
||||
| {
|
||||
auth?: { scopes?: string[]; deviceToken?: string };
|
||||
}
|
||||
| undefined;
|
||||
expect(payload?.auth?.scopes).toEqual([]);
|
||||
expect(payload?.auth?.deviceToken).toBeUndefined();
|
||||
|
||||
const admin = await rpcReq(ws, "set-heartbeats", { enabled: false });
|
||||
expect(admin.ok).toBe(false);
|
||||
expect(admin.error?.message ?? "").toContain("missing scope");
|
||||
} finally {
|
||||
ws.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test("allows localhost ui clients without device identity when insecure auth is enabled", async () => {
|
||||
testState.gatewayControlUi = { allowInsecureAuth: true };
|
||||
const { server, ws, port, prevToken } = await startControlUiServerWithClient("secret", {
|
||||
|
||||
@@ -735,9 +735,13 @@ export async function startGatewayServer(
|
||||
env: process.env,
|
||||
tailscaleMode,
|
||||
}),
|
||||
config.gateway?.trustedProxies,
|
||||
);
|
||||
const resolveCurrentSharedGatewaySessionGeneration = () =>
|
||||
resolveSharedGatewaySessionGeneration(getResolvedAuth());
|
||||
resolveSharedGatewaySessionGeneration(
|
||||
getResolvedAuth(),
|
||||
getRuntimeConfig().gateway?.trustedProxies,
|
||||
);
|
||||
const resolveSharedGatewaySessionGenerationForRuntimeSnapshot = () =>
|
||||
resolveSharedGatewaySessionGeneration(
|
||||
resolveGatewayAuth({
|
||||
@@ -746,6 +750,7 @@ export async function startGatewayServer(
|
||||
env: process.env,
|
||||
tailscaleMode,
|
||||
}),
|
||||
getRuntimeConfig().gateway?.trustedProxies,
|
||||
);
|
||||
const sharedGatewaySessionGenerationState: SharedGatewaySessionGenerationState = {
|
||||
current: resolveCurrentSharedGatewaySessionGeneration(),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { Socket } from "node:net";
|
||||
import type { RawData, WebSocket, WebSocketServer } from "ws";
|
||||
import { getRuntimeConfig } from "../../config/io.js";
|
||||
import { resolveCanvasHostUrl } from "../../infra/canvas-host-url.js";
|
||||
import { removeRemoteNodeInfo } from "../../infra/skills-remote.js";
|
||||
import { upsertPresence } from "../../infra/system-presence.js";
|
||||
@@ -205,7 +206,10 @@ export function attachGatewayWsConnectionHandler(params: AttachGatewayWsConnecti
|
||||
resolvedAuth,
|
||||
getResolvedAuth = () => resolvedAuth,
|
||||
getRequiredSharedGatewaySessionGeneration = () =>
|
||||
resolveSharedGatewaySessionGeneration(getResolvedAuth()),
|
||||
resolveSharedGatewaySessionGeneration(
|
||||
getResolvedAuth(),
|
||||
getRuntimeConfig().gateway?.trustedProxies,
|
||||
),
|
||||
rateLimiter,
|
||||
browserRateLimiter,
|
||||
isStartupPending,
|
||||
|
||||
@@ -837,9 +837,11 @@ export function attachGatewayWsMessageHandler(params: GatewayWsMessageHandlerPar
|
||||
rejectUnauthorized(authResult);
|
||||
return;
|
||||
}
|
||||
if (authMethod === "token" || authMethod === "password") {
|
||||
const sharedGatewaySessionGeneration =
|
||||
resolveSharedGatewaySessionGeneration(resolvedAuth);
|
||||
if (authMethod === "token" || authMethod === "password" || authMethod === "trusted-proxy") {
|
||||
const sharedGatewaySessionGeneration = resolveSharedGatewaySessionGeneration(
|
||||
resolvedAuth,
|
||||
trustedProxies,
|
||||
);
|
||||
const requiredSharedGatewaySessionGeneration =
|
||||
getRequiredSharedGatewaySessionGeneration?.();
|
||||
if (
|
||||
@@ -874,6 +876,7 @@ export function attachGatewayWsMessageHandler(params: GatewayWsMessageHandlerPar
|
||||
resolvedAuth.mode,
|
||||
authMethod,
|
||||
);
|
||||
let hasServerApprovedDeviceTokenBaseline = false;
|
||||
if (device && devicePublicKey) {
|
||||
const formatAuditList = (items: string[] | undefined): string => {
|
||||
if (!items || items.length === 0) {
|
||||
@@ -1133,8 +1136,17 @@ export function attachGatewayWsMessageHandler(params: GatewayWsMessageHandlerPar
|
||||
if (!ok) {
|
||||
return;
|
||||
}
|
||||
hasServerApprovedDeviceTokenBaseline = true;
|
||||
} else if (trustedProxyAuthOk) {
|
||||
clearUnboundScopes();
|
||||
} else if (
|
||||
skipControlUiPairingForDevice ||
|
||||
(skipLocalBackendSelfPairing && authMethod !== "device-token")
|
||||
) {
|
||||
hasServerApprovedDeviceTokenBaseline = true;
|
||||
}
|
||||
} else {
|
||||
hasServerApprovedDeviceTokenBaseline = true;
|
||||
const claimedPlatform = connectParams.client.platform;
|
||||
const pairedPlatform = paired.platform;
|
||||
const claimedDeviceFamily = connectParams.client.deviceFamily;
|
||||
@@ -1222,9 +1234,10 @@ export function attachGatewayWsMessageHandler(params: GatewayWsMessageHandlerPar
|
||||
}
|
||||
}
|
||||
|
||||
const deviceToken = device
|
||||
? await ensureDeviceToken({ deviceId: device.id, role, scopes })
|
||||
: null;
|
||||
const deviceToken =
|
||||
device && hasServerApprovedDeviceTokenBaseline
|
||||
? await ensureDeviceToken({ deviceId: device.id, role, scopes })
|
||||
: null;
|
||||
const bootstrapDeviceTokens: Array<{
|
||||
deviceToken: string;
|
||||
role: string;
|
||||
@@ -1303,9 +1316,10 @@ export function attachGatewayWsMessageHandler(params: GatewayWsMessageHandlerPar
|
||||
const canvasCapabilityExpiresAtMs = canvasCapability
|
||||
? Date.now() + CANVAS_CAPABILITY_TTL_MS
|
||||
: undefined;
|
||||
const usesSharedGatewayAuth = authMethod === "token" || authMethod === "password";
|
||||
const usesSharedGatewayAuth =
|
||||
authMethod === "token" || authMethod === "password" || authMethod === "trusted-proxy";
|
||||
const sharedGatewaySessionGeneration = usesSharedGatewayAuth
|
||||
? resolveSharedGatewaySessionGeneration(resolvedAuth)
|
||||
? resolveSharedGatewaySessionGeneration(resolvedAuth, trustedProxies)
|
||||
: undefined;
|
||||
const scopedCanvasHostUrl =
|
||||
canvasHostUrl && canvasCapability
|
||||
|
||||
57
src/gateway/server/ws-shared-generation.test.ts
Normal file
57
src/gateway/server/ws-shared-generation.test.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveSharedGatewaySessionGeneration } from "./ws-shared-generation.js";
|
||||
|
||||
describe("resolveSharedGatewaySessionGeneration", () => {
|
||||
it("tracks trusted-proxy policy inputs", () => {
|
||||
const baseAuth = {
|
||||
mode: "trusted-proxy" as const,
|
||||
allowTailscale: false,
|
||||
trustedProxy: {
|
||||
userHeader: "x-forwarded-user",
|
||||
requiredHeaders: ["x-forwarded-proto", "x-forwarded-host"],
|
||||
allowUsers: ["alice@example.com", "bob@example.com"],
|
||||
},
|
||||
};
|
||||
|
||||
const base = resolveSharedGatewaySessionGeneration(baseAuth, ["127.0.0.1", "10.0.0.10"]);
|
||||
expect(base).toBeDefined();
|
||||
expect(
|
||||
resolveSharedGatewaySessionGeneration(
|
||||
{
|
||||
...baseAuth,
|
||||
trustedProxy: {
|
||||
...baseAuth.trustedProxy,
|
||||
requiredHeaders: ["x-forwarded-host", "x-forwarded-proto"],
|
||||
allowUsers: ["bob@example.com", "alice@example.com"],
|
||||
},
|
||||
},
|
||||
["10.0.0.10", "127.0.0.1"],
|
||||
),
|
||||
).toBe(base);
|
||||
expect(
|
||||
resolveSharedGatewaySessionGeneration(
|
||||
{
|
||||
...baseAuth,
|
||||
trustedProxy: {
|
||||
...baseAuth.trustedProxy,
|
||||
allowUsers: ["carol@example.com"],
|
||||
},
|
||||
},
|
||||
["127.0.0.1", "10.0.0.10"],
|
||||
),
|
||||
).not.toBe(base);
|
||||
expect(resolveSharedGatewaySessionGeneration(baseAuth, ["10.0.0.11"])).not.toBe(base);
|
||||
});
|
||||
|
||||
it("keeps shared-secret generations independent from proxy allowlists", () => {
|
||||
const auth = {
|
||||
mode: "token" as const,
|
||||
allowTailscale: false,
|
||||
token: "shared-token",
|
||||
};
|
||||
|
||||
expect(resolveSharedGatewaySessionGeneration(auth, ["127.0.0.1"])).toBe(
|
||||
resolveSharedGatewaySessionGeneration(auth, ["10.0.0.10"]),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import type { GatewayTrustedProxyConfig } from "../../config/types.gateway.js";
|
||||
import type { ResolvedGatewayAuth } from "../auth.js";
|
||||
|
||||
function resolveSharedSecret(
|
||||
@@ -18,14 +19,41 @@ function resolveSharedSecret(
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeTrustedProxyConfig(trustedProxy: GatewayTrustedProxyConfig | undefined): {
|
||||
userHeader: string | undefined;
|
||||
requiredHeaders: string[];
|
||||
allowUsers: string[];
|
||||
allowLoopback: boolean | undefined;
|
||||
} {
|
||||
return {
|
||||
userHeader: trustedProxy?.userHeader,
|
||||
requiredHeaders: [...(trustedProxy?.requiredHeaders ?? [])].toSorted(),
|
||||
allowUsers: [...(trustedProxy?.allowUsers ?? [])].toSorted(),
|
||||
allowLoopback: trustedProxy?.allowLoopback,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveSharedGatewaySessionGeneration(
|
||||
auth: ResolvedGatewayAuth,
|
||||
trustedProxies?: readonly string[],
|
||||
): string | undefined {
|
||||
const shared = resolveSharedSecret(auth);
|
||||
if (!shared) {
|
||||
return undefined;
|
||||
if (shared) {
|
||||
return createHash("sha256")
|
||||
.update(`${shared.mode}\u0000${shared.secret}`, "utf8")
|
||||
.digest("base64url");
|
||||
}
|
||||
return createHash("sha256")
|
||||
.update(`${shared.mode}\u0000${shared.secret}`, "utf8")
|
||||
.digest("base64url");
|
||||
if (auth.mode === "trusted-proxy") {
|
||||
return createHash("sha256")
|
||||
.update(
|
||||
JSON.stringify({
|
||||
mode: auth.mode,
|
||||
trustedProxy: normalizeTrustedProxyConfig(auth.trustedProxy),
|
||||
trustedProxies: [...(trustedProxies ?? [])].toSorted(),
|
||||
}),
|
||||
"utf8",
|
||||
)
|
||||
.digest("base64url");
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user