Merge c430980f68 into 3a85cb1833
This commit is contained in:
commit
766f0f6f2c
@ -132,6 +132,7 @@ const FIELD_LABELS: Record<string, string> = {
|
|||||||
"gateway.remote.tlsFingerprint": "Remote Gateway TLS Fingerprint",
|
"gateway.remote.tlsFingerprint": "Remote Gateway TLS Fingerprint",
|
||||||
"gateway.auth.token": "Gateway Token",
|
"gateway.auth.token": "Gateway Token",
|
||||||
"gateway.auth.password": "Gateway Password",
|
"gateway.auth.password": "Gateway Password",
|
||||||
|
"gateway.auth.minLength": "Gateway Auth Minimum Length",
|
||||||
"tools.media.image.enabled": "Enable Image Understanding",
|
"tools.media.image.enabled": "Enable Image Understanding",
|
||||||
"tools.media.image.maxBytes": "Image Understanding Max Bytes",
|
"tools.media.image.maxBytes": "Image Understanding Max Bytes",
|
||||||
"tools.media.image.maxChars": "Image Understanding Max Chars",
|
"tools.media.image.maxChars": "Image Understanding Max Chars",
|
||||||
@ -382,6 +383,8 @@ const FIELD_HELP: Record<string, string> = {
|
|||||||
"gateway.auth.token":
|
"gateway.auth.token":
|
||||||
"Required by default for gateway access (unless using Tailscale Serve identity); required for non-loopback binds.",
|
"Required by default for gateway access (unless using Tailscale Serve identity); required for non-loopback binds.",
|
||||||
"gateway.auth.password": "Required for Tailscale funnel.",
|
"gateway.auth.password": "Required for Tailscale funnel.",
|
||||||
|
"gateway.auth.minLength":
|
||||||
|
"Minimum token/password length required for non-loopback binds (default: 24).",
|
||||||
"gateway.controlUi.basePath":
|
"gateway.controlUi.basePath":
|
||||||
"Optional URL prefix where the Control UI is served (e.g. /openclaw).",
|
"Optional URL prefix where the Control UI is served (e.g. /openclaw).",
|
||||||
"gateway.controlUi.allowInsecureAuth":
|
"gateway.controlUi.allowInsecureAuth":
|
||||||
|
|||||||
@ -81,6 +81,8 @@ export type GatewayAuthConfig = {
|
|||||||
token?: string;
|
token?: string;
|
||||||
/** Shared password for password mode (consider env instead). */
|
/** Shared password for password mode (consider env instead). */
|
||||||
password?: string;
|
password?: string;
|
||||||
|
/** Minimum auth token/password length when binding beyond loopback. Default: 24. */
|
||||||
|
minLength?: number;
|
||||||
/** Allow Tailscale identity headers when serve mode is enabled. */
|
/** Allow Tailscale identity headers when serve mode is enabled. */
|
||||||
allowTailscale?: boolean;
|
allowTailscale?: boolean;
|
||||||
};
|
};
|
||||||
|
|||||||
@ -328,6 +328,7 @@ export const OpenClawSchema = z
|
|||||||
mode: z.union([z.literal("token"), z.literal("password")]).optional(),
|
mode: z.union([z.literal("token"), z.literal("password")]).optional(),
|
||||||
token: z.string().optional(),
|
token: z.string().optional(),
|
||||||
password: z.string().optional(),
|
password: z.string().optional(),
|
||||||
|
minLength: z.number().int().min(8).max(128).optional(),
|
||||||
allowTailscale: z.boolean().optional(),
|
allowTailscale: z.boolean().optional(),
|
||||||
})
|
})
|
||||||
.strict()
|
.strict()
|
||||||
|
|||||||
@ -32,6 +32,19 @@ type TailscaleUser = {
|
|||||||
|
|
||||||
type TailscaleWhoisLookup = (ip: string) => Promise<TailscaleWhoisIdentity | null>;
|
type TailscaleWhoisLookup = (ip: string) => Promise<TailscaleWhoisIdentity | null>;
|
||||||
|
|
||||||
|
export const DEFAULT_GATEWAY_AUTH_MIN_LENGTH = 24;
|
||||||
|
|
||||||
|
type AuthFailureState = {
|
||||||
|
count: number;
|
||||||
|
firstSeen: number;
|
||||||
|
blockedUntil?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const AUTH_FAILURE_WINDOW_MS = 60_000;
|
||||||
|
const AUTH_FAILURE_BLOCK_MS = 5 * 60_000;
|
||||||
|
const AUTH_FAILURE_LIMIT = 10;
|
||||||
|
const authFailuresByIp = new Map<string, AuthFailureState>();
|
||||||
|
|
||||||
function safeEqual(a: string, b: string): boolean {
|
function safeEqual(a: string, b: string): boolean {
|
||||||
if (a.length !== b.length) return false;
|
if (a.length !== b.length) return false;
|
||||||
return timingSafeEqual(Buffer.from(a), Buffer.from(b));
|
return timingSafeEqual(Buffer.from(a), Buffer.from(b));
|
||||||
@ -103,6 +116,57 @@ export function isLocalDirectRequest(req?: IncomingMessage, trustedProxies?: str
|
|||||||
return (hostIsLocal || hostIsTailscaleServe) && (!hasForwarded || remoteIsTrustedProxy);
|
return (hostIsLocal || hostIsTailscaleServe) && (!hasForwarded || remoteIsTrustedProxy);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveAuthRateLimitKey(
|
||||||
|
req?: IncomingMessage,
|
||||||
|
trustedProxies?: string[],
|
||||||
|
): string | null {
|
||||||
|
if (!req) return null;
|
||||||
|
if (isLocalDirectRequest(req, trustedProxies)) return null;
|
||||||
|
const clientIp = resolveRequestClientIp(req, trustedProxies);
|
||||||
|
if (!clientIp || isLoopbackAddress(clientIp)) return null;
|
||||||
|
return clientIp;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAuthRateLimited(key: string, now: number): boolean {
|
||||||
|
const state = authFailuresByIp.get(key);
|
||||||
|
if (!state) return false;
|
||||||
|
if (state.blockedUntil && state.blockedUntil > now) return true;
|
||||||
|
if (now - state.firstSeen > AUTH_FAILURE_WINDOW_MS) {
|
||||||
|
authFailuresByIp.delete(key);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function recordAuthFailure(key: string, now: number): void {
|
||||||
|
const state = authFailuresByIp.get(key);
|
||||||
|
if (!state || now - state.firstSeen > AUTH_FAILURE_WINDOW_MS) {
|
||||||
|
authFailuresByIp.set(key, { count: 1, firstSeen: now });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const nextCount = state.count + 1;
|
||||||
|
const blockedUntil =
|
||||||
|
nextCount >= AUTH_FAILURE_LIMIT ? now + AUTH_FAILURE_BLOCK_MS : state.blockedUntil;
|
||||||
|
authFailuresByIp.set(key, { ...state, count: nextCount, blockedUntil });
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearAuthFailures(key: string | null): void {
|
||||||
|
if (!key) return;
|
||||||
|
authFailuresByIp.delete(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldRecordAuthFailure(reason: string | undefined): boolean {
|
||||||
|
return (
|
||||||
|
reason === "token_missing" ||
|
||||||
|
reason === "token_mismatch" ||
|
||||||
|
reason === "password_missing" ||
|
||||||
|
reason === "password_mismatch" ||
|
||||||
|
reason === "tailscale_user_missing" ||
|
||||||
|
reason === "tailscale_proxy_missing" ||
|
||||||
|
reason === "tailscale_whois_failed" ||
|
||||||
|
reason === "tailscale_user_mismatch"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function getTailscaleUser(req?: IncomingMessage): TailscaleUser | null {
|
function getTailscaleUser(req?: IncomingMessage): TailscaleUser | null {
|
||||||
if (!req) return null;
|
if (!req) return null;
|
||||||
const login = req.headers["tailscale-user-login"];
|
const login = req.headers["tailscale-user-login"];
|
||||||
@ -211,6 +275,17 @@ export async function authorizeGatewayConnect(params: {
|
|||||||
const { auth, connectAuth, req, trustedProxies } = params;
|
const { auth, connectAuth, req, trustedProxies } = params;
|
||||||
const tailscaleWhois = params.tailscaleWhois ?? readTailscaleWhoisIdentity;
|
const tailscaleWhois = params.tailscaleWhois ?? readTailscaleWhoisIdentity;
|
||||||
const localDirect = isLocalDirectRequest(req, trustedProxies);
|
const localDirect = isLocalDirectRequest(req, trustedProxies);
|
||||||
|
const now = Date.now();
|
||||||
|
const rateLimitKey = resolveAuthRateLimitKey(req, trustedProxies);
|
||||||
|
if (rateLimitKey && isAuthRateLimited(rateLimitKey, now)) {
|
||||||
|
return { ok: false, reason: "rate_limited" };
|
||||||
|
}
|
||||||
|
const fail = (reason: GatewayAuthResult["reason"]) => {
|
||||||
|
if (rateLimitKey && shouldRecordAuthFailure(reason)) {
|
||||||
|
recordAuthFailure(rateLimitKey, now);
|
||||||
|
}
|
||||||
|
return { ok: false, reason };
|
||||||
|
};
|
||||||
|
|
||||||
if (auth.allowTailscale && !localDirect) {
|
if (auth.allowTailscale && !localDirect) {
|
||||||
const tailscaleCheck = await resolveVerifiedTailscaleUser({
|
const tailscaleCheck = await resolveVerifiedTailscaleUser({
|
||||||
@ -218,40 +293,44 @@ export async function authorizeGatewayConnect(params: {
|
|||||||
tailscaleWhois,
|
tailscaleWhois,
|
||||||
});
|
});
|
||||||
if (tailscaleCheck.ok) {
|
if (tailscaleCheck.ok) {
|
||||||
|
clearAuthFailures(rateLimitKey);
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
method: "tailscale",
|
method: "tailscale",
|
||||||
user: tailscaleCheck.user.login,
|
user: tailscaleCheck.user.login,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
return fail(tailscaleCheck.reason);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (auth.mode === "token") {
|
if (auth.mode === "token") {
|
||||||
if (!auth.token) {
|
if (!auth.token) {
|
||||||
return { ok: false, reason: "token_missing_config" };
|
return fail("token_missing_config");
|
||||||
}
|
}
|
||||||
if (!connectAuth?.token) {
|
if (!connectAuth?.token) {
|
||||||
return { ok: false, reason: "token_missing" };
|
return fail("token_missing");
|
||||||
}
|
}
|
||||||
if (!safeEqual(connectAuth.token, auth.token)) {
|
if (!safeEqual(connectAuth.token, auth.token)) {
|
||||||
return { ok: false, reason: "token_mismatch" };
|
return fail("token_mismatch");
|
||||||
}
|
}
|
||||||
|
clearAuthFailures(rateLimitKey);
|
||||||
return { ok: true, method: "token" };
|
return { ok: true, method: "token" };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (auth.mode === "password") {
|
if (auth.mode === "password") {
|
||||||
const password = connectAuth?.password;
|
const password = connectAuth?.password;
|
||||||
if (!auth.password) {
|
if (!auth.password) {
|
||||||
return { ok: false, reason: "password_missing_config" };
|
return fail("password_missing_config");
|
||||||
}
|
}
|
||||||
if (!password) {
|
if (!password) {
|
||||||
return { ok: false, reason: "password_missing" };
|
return fail("password_missing");
|
||||||
}
|
}
|
||||||
if (!safeEqual(password, auth.password)) {
|
if (!safeEqual(password, auth.password)) {
|
||||||
return { ok: false, reason: "password_mismatch" };
|
return fail("password_mismatch");
|
||||||
}
|
}
|
||||||
|
clearAuthFailures(rateLimitKey);
|
||||||
return { ok: true, method: "password" };
|
return { ok: true, method: "password" };
|
||||||
}
|
}
|
||||||
|
|
||||||
return { ok: false, reason: "unauthorized" };
|
return fail("unauthorized");
|
||||||
}
|
}
|
||||||
|
|||||||
@ -25,6 +25,12 @@ export function sendUnauthorized(res: ServerResponse) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function sendRateLimited(res: ServerResponse) {
|
||||||
|
sendJson(res, 429, {
|
||||||
|
error: { message: "Too many requests", type: "rate_limited" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function sendInvalidRequest(res: ServerResponse, message: string) {
|
export function sendInvalidRequest(res: ServerResponse, message: string) {
|
||||||
sendJson(res, 400, {
|
sendJson(res, 400, {
|
||||||
error: { message, type: "invalid_request_error" },
|
error: { message, type: "invalid_request_error" },
|
||||||
|
|||||||
@ -11,6 +11,7 @@ import {
|
|||||||
readJsonBodyOrError,
|
readJsonBodyOrError,
|
||||||
sendJson,
|
sendJson,
|
||||||
sendMethodNotAllowed,
|
sendMethodNotAllowed,
|
||||||
|
sendRateLimited,
|
||||||
sendUnauthorized,
|
sendUnauthorized,
|
||||||
setSseHeaders,
|
setSseHeaders,
|
||||||
writeDone,
|
writeDone,
|
||||||
@ -172,6 +173,10 @@ export async function handleOpenAiHttpRequest(
|
|||||||
trustedProxies: opts.trustedProxies,
|
trustedProxies: opts.trustedProxies,
|
||||||
});
|
});
|
||||||
if (!authResult.ok) {
|
if (!authResult.ok) {
|
||||||
|
if (authResult.reason === "rate_limited") {
|
||||||
|
sendRateLimited(res);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
sendUnauthorized(res);
|
sendUnauthorized(res);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -20,6 +20,7 @@ import {
|
|||||||
readJsonBodyOrError,
|
readJsonBodyOrError,
|
||||||
sendJson,
|
sendJson,
|
||||||
sendMethodNotAllowed,
|
sendMethodNotAllowed,
|
||||||
|
sendRateLimited,
|
||||||
sendUnauthorized,
|
sendUnauthorized,
|
||||||
setSseHeaders,
|
setSseHeaders,
|
||||||
writeDone,
|
writeDone,
|
||||||
@ -335,6 +336,10 @@ export async function handleOpenResponsesHttpRequest(
|
|||||||
trustedProxies: opts.trustedProxies,
|
trustedProxies: opts.trustedProxies,
|
||||||
});
|
});
|
||||||
if (!authResult.ok) {
|
if (!authResult.ok) {
|
||||||
|
if (authResult.reason === "rate_limited") {
|
||||||
|
sendRateLimited(res);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
sendUnauthorized(res);
|
sendUnauthorized(res);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
47
src/gateway/server-runtime-config.test.ts
Normal file
47
src/gateway/server-runtime-config.test.ts
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import type { MoltbotConfig } from "../config/config.js";
|
||||||
|
import { resolveGatewayRuntimeConfig } from "./server-runtime-config.js";
|
||||||
|
|
||||||
|
describe("resolveGatewayRuntimeConfig", () => {
|
||||||
|
it("rejects weak auth when binding beyond loopback", async () => {
|
||||||
|
const cfg: MoltbotConfig = {
|
||||||
|
gateway: {
|
||||||
|
auth: {
|
||||||
|
mode: "token",
|
||||||
|
token: "short-token",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
resolveGatewayRuntimeConfig({
|
||||||
|
cfg,
|
||||||
|
port: 18789,
|
||||||
|
host: "0.0.0.0",
|
||||||
|
}),
|
||||||
|
).rejects.toThrow("weak shared secret");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows custom auth minimum length", async () => {
|
||||||
|
const cfg: MoltbotConfig = {
|
||||||
|
gateway: {
|
||||||
|
auth: {
|
||||||
|
mode: "token",
|
||||||
|
token: "long-enough-token",
|
||||||
|
minLength: 10,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
resolveGatewayRuntimeConfig({
|
||||||
|
cfg,
|
||||||
|
port: 18789,
|
||||||
|
host: "0.0.0.0",
|
||||||
|
}),
|
||||||
|
).resolves.toMatchObject({
|
||||||
|
bindHost: "0.0.0.0",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -6,6 +6,7 @@ import type {
|
|||||||
} from "../config/config.js";
|
} from "../config/config.js";
|
||||||
import {
|
import {
|
||||||
assertGatewayAuthConfigured,
|
assertGatewayAuthConfigured,
|
||||||
|
DEFAULT_GATEWAY_AUTH_MIN_LENGTH,
|
||||||
type ResolvedGatewayAuth,
|
type ResolvedGatewayAuth,
|
||||||
resolveGatewayAuth,
|
resolveGatewayAuth,
|
||||||
} from "./auth.js";
|
} from "./auth.js";
|
||||||
@ -75,6 +76,12 @@ export async function resolveGatewayRuntimeConfig(params: {
|
|||||||
typeof resolvedAuth.password === "string" && resolvedAuth.password.trim().length > 0;
|
typeof resolvedAuth.password === "string" && resolvedAuth.password.trim().length > 0;
|
||||||
const hasSharedSecret =
|
const hasSharedSecret =
|
||||||
(authMode === "token" && hasToken) || (authMode === "password" && hasPassword);
|
(authMode === "token" && hasToken) || (authMode === "password" && hasPassword);
|
||||||
|
const minAuthLength = params.cfg.gateway?.auth?.minLength ?? DEFAULT_GATEWAY_AUTH_MIN_LENGTH;
|
||||||
|
const tokenLength = hasToken ? resolvedAuth.token?.trim().length ?? 0 : 0;
|
||||||
|
const passwordLength = hasPassword ? resolvedAuth.password?.trim().length ?? 0 : 0;
|
||||||
|
const isWeakSharedSecret =
|
||||||
|
(authMode === "token" && tokenLength > 0 && tokenLength < minAuthLength) ||
|
||||||
|
(authMode === "password" && passwordLength > 0 && passwordLength < minAuthLength);
|
||||||
const hooksConfig = resolveHooksConfig(params.cfg);
|
const hooksConfig = resolveHooksConfig(params.cfg);
|
||||||
const canvasHostEnabled =
|
const canvasHostEnabled =
|
||||||
process.env.OPENCLAW_SKIP_CANVAS_HOST !== "1" && params.cfg.canvasHost?.enabled !== false;
|
process.env.OPENCLAW_SKIP_CANVAS_HOST !== "1" && params.cfg.canvasHost?.enabled !== false;
|
||||||
@ -93,6 +100,11 @@ export async function resolveGatewayRuntimeConfig(params: {
|
|||||||
`refusing to bind gateway to ${bindHost}:${params.port} without auth (set gateway.auth.token/password, or set OPENCLAW_GATEWAY_TOKEN/OPENCLAW_GATEWAY_PASSWORD)`,
|
`refusing to bind gateway to ${bindHost}:${params.port} without auth (set gateway.auth.token/password, or set OPENCLAW_GATEWAY_TOKEN/OPENCLAW_GATEWAY_PASSWORD)`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (!isLoopbackHost(bindHost) && isWeakSharedSecret) {
|
||||||
|
throw new Error(
|
||||||
|
`refusing to bind gateway to ${bindHost}:${params.port} with a weak shared secret (min length ${minAuthLength}; use a long random token/password or set gateway.bind=loopback)`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
bindHost,
|
bindHost,
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../agents/defaults.js";
|
|||||||
import { resolveConfiguredModelRef } from "../agents/model-selection.js";
|
import { resolveConfiguredModelRef } from "../agents/model-selection.js";
|
||||||
import type { loadConfig } from "../config/config.js";
|
import type { loadConfig } from "../config/config.js";
|
||||||
import { getResolvedLoggerSettings } from "../logging.js";
|
import { getResolvedLoggerSettings } from "../logging.js";
|
||||||
|
import { DEFAULT_GATEWAY_AUTH_MIN_LENGTH, resolveGatewayAuth } from "./auth.js";
|
||||||
|
|
||||||
export function logGatewayStartup(params: {
|
export function logGatewayStartup(params: {
|
||||||
cfg: ReturnType<typeof loadConfig>;
|
cfg: ReturnType<typeof loadConfig>;
|
||||||
@ -10,7 +11,7 @@ export function logGatewayStartup(params: {
|
|||||||
bindHosts?: string[];
|
bindHosts?: string[];
|
||||||
port: number;
|
port: number;
|
||||||
tlsEnabled?: boolean;
|
tlsEnabled?: boolean;
|
||||||
log: { info: (msg: string, meta?: Record<string, unknown>) => void };
|
log: { info: (msg: string, meta?: Record<string, unknown>) => void; warn?: (msg: string) => void };
|
||||||
isNixMode: boolean;
|
isNixMode: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { provider: agentProvider, model: agentModel } = resolveConfiguredModelRef({
|
const { provider: agentProvider, model: agentModel } = resolveConfiguredModelRef({
|
||||||
@ -34,6 +35,29 @@ export function logGatewayStartup(params: {
|
|||||||
params.log.info(`listening on ${scheme}://${formatHost(host)}:${params.port}`);
|
params.log.info(`listening on ${scheme}://${formatHost(host)}:${params.port}`);
|
||||||
}
|
}
|
||||||
params.log.info(`log file: ${getResolvedLoggerSettings().file}`);
|
params.log.info(`log file: ${getResolvedLoggerSettings().file}`);
|
||||||
|
const minAuthLength = params.cfg.gateway?.auth?.minLength ?? DEFAULT_GATEWAY_AUTH_MIN_LENGTH;
|
||||||
|
const tailscaleMode = params.cfg.gateway?.tailscale?.mode ?? "off";
|
||||||
|
const resolvedAuth = resolveGatewayAuth({
|
||||||
|
authConfig: params.cfg.gateway?.auth,
|
||||||
|
env: process.env,
|
||||||
|
tailscaleMode,
|
||||||
|
});
|
||||||
|
const tokenLength = resolvedAuth.token?.trim().length ?? 0;
|
||||||
|
const passwordLength = resolvedAuth.password?.trim().length ?? 0;
|
||||||
|
const activeLength = resolvedAuth.mode === "password" ? passwordLength : tokenLength;
|
||||||
|
const label = resolvedAuth.mode === "password" ? "password" : "token";
|
||||||
|
if (activeLength > 0) {
|
||||||
|
const message = `gateway auth: ${label} length ${activeLength} (min ${minAuthLength})`;
|
||||||
|
if (activeLength < minAuthLength) {
|
||||||
|
params.log.warn?.(`${message} - weak`);
|
||||||
|
} else {
|
||||||
|
params.log.info(`${message} - ok`);
|
||||||
|
}
|
||||||
|
} else if (resolvedAuth.allowTailscale && tailscaleMode === "serve") {
|
||||||
|
params.log.info("gateway auth: tailscale serve identity allowed (no shared secret configured)");
|
||||||
|
} else {
|
||||||
|
params.log.warn?.("gateway auth: no shared secret configured");
|
||||||
|
}
|
||||||
if (params.isNixMode) {
|
if (params.isNixMode) {
|
||||||
params.log.info("gateway: running in Nix mode (config managed externally)");
|
params.log.info("gateway: running in Nix mode (config managed externally)");
|
||||||
}
|
}
|
||||||
|
|||||||
@ -115,6 +115,8 @@ function formatGatewayAuthFailureMessage(params: {
|
|||||||
return "unauthorized: tailscale identity check failed (use Tailscale Serve auth or gateway token/password)";
|
return "unauthorized: tailscale identity check failed (use Tailscale Serve auth or gateway token/password)";
|
||||||
case "tailscale_user_mismatch":
|
case "tailscale_user_mismatch":
|
||||||
return "unauthorized: tailscale identity mismatch (use Tailscale Serve auth or gateway token/password)";
|
return "unauthorized: tailscale identity mismatch (use Tailscale Serve auth or gateway token/password)";
|
||||||
|
case "rate_limited":
|
||||||
|
return "unauthorized: too many failed attempts (try again later)";
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -30,6 +30,7 @@ import {
|
|||||||
sendInvalidRequest,
|
sendInvalidRequest,
|
||||||
sendJson,
|
sendJson,
|
||||||
sendMethodNotAllowed,
|
sendMethodNotAllowed,
|
||||||
|
sendRateLimited,
|
||||||
sendUnauthorized,
|
sendUnauthorized,
|
||||||
} from "./http-common.js";
|
} from "./http-common.js";
|
||||||
|
|
||||||
@ -111,6 +112,10 @@ export async function handleToolsInvokeHttpRequest(
|
|||||||
trustedProxies: opts.trustedProxies ?? cfg.gateway?.trustedProxies,
|
trustedProxies: opts.trustedProxies ?? cfg.gateway?.trustedProxies,
|
||||||
});
|
});
|
||||||
if (!authResult.ok) {
|
if (!authResult.ok) {
|
||||||
|
if (authResult.reason === "rate_limited") {
|
||||||
|
sendRateLimited(res);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
sendUnauthorized(res);
|
sendUnauthorized(res);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,7 +4,7 @@ import type { ChannelId } from "../channels/plugins/types.js";
|
|||||||
import type { OpenClawConfig } from "../config/config.js";
|
import type { OpenClawConfig } from "../config/config.js";
|
||||||
import { resolveBrowserConfig, resolveProfile } from "../browser/config.js";
|
import { resolveBrowserConfig, resolveProfile } from "../browser/config.js";
|
||||||
import { resolveConfigPath, resolveStateDir } from "../config/paths.js";
|
import { resolveConfigPath, resolveStateDir } from "../config/paths.js";
|
||||||
import { resolveGatewayAuth } from "../gateway/auth.js";
|
import { DEFAULT_GATEWAY_AUTH_MIN_LENGTH, resolveGatewayAuth } from "../gateway/auth.js";
|
||||||
import { formatCliCommand } from "../cli/command-format.js";
|
import { formatCliCommand } from "../cli/command-format.js";
|
||||||
import { buildGatewayConnectionDetails } from "../gateway/call.js";
|
import { buildGatewayConnectionDetails } from "../gateway/call.js";
|
||||||
import { probeGateway } from "../gateway/probe.js";
|
import { probeGateway } from "../gateway/probe.js";
|
||||||
@ -264,6 +264,7 @@ function collectGatewayConfigFindings(
|
|||||||
const hasPassword = typeof auth.password === "string" && auth.password.trim().length > 0;
|
const hasPassword = typeof auth.password === "string" && auth.password.trim().length > 0;
|
||||||
const hasSharedSecret =
|
const hasSharedSecret =
|
||||||
(auth.mode === "token" && hasToken) || (auth.mode === "password" && hasPassword);
|
(auth.mode === "token" && hasToken) || (auth.mode === "password" && hasPassword);
|
||||||
|
const minAuthLength = cfg.gateway?.auth?.minLength ?? DEFAULT_GATEWAY_AUTH_MIN_LENGTH;
|
||||||
const hasTailscaleAuth = auth.allowTailscale === true && tailscaleMode === "serve";
|
const hasTailscaleAuth = auth.allowTailscale === true && tailscaleMode === "serve";
|
||||||
const hasGatewayAuth = hasSharedSecret || hasTailscaleAuth;
|
const hasGatewayAuth = hasSharedSecret || hasTailscaleAuth;
|
||||||
|
|
||||||
@ -344,12 +345,24 @@ function collectGatewayConfigFindings(
|
|||||||
|
|
||||||
const token =
|
const token =
|
||||||
typeof auth.token === "string" && auth.token.trim().length > 0 ? auth.token.trim() : null;
|
typeof auth.token === "string" && auth.token.trim().length > 0 ? auth.token.trim() : null;
|
||||||
if (auth.mode === "token" && token && token.length < 24) {
|
if (auth.mode === "token" && token && token.length < minAuthLength) {
|
||||||
findings.push({
|
findings.push({
|
||||||
checkId: "gateway.token_too_short",
|
checkId: "gateway.token_too_short",
|
||||||
severity: "warn",
|
severity: "warn",
|
||||||
title: "Gateway token looks short",
|
title: "Gateway token looks short",
|
||||||
detail: `gateway auth token is ${token.length} chars; prefer a long random token.`,
|
detail: `gateway auth token is ${token.length} chars (min length ${minAuthLength}).`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const password =
|
||||||
|
typeof auth.password === "string" && auth.password.trim().length > 0
|
||||||
|
? auth.password.trim()
|
||||||
|
: null;
|
||||||
|
if (auth.mode === "password" && password && password.length < minAuthLength) {
|
||||||
|
findings.push({
|
||||||
|
checkId: "gateway.password_too_short",
|
||||||
|
severity: "warn",
|
||||||
|
title: "Gateway password looks short",
|
||||||
|
detail: `gateway auth password is ${password.length} chars (min length ${minAuthLength}).`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user