fix(configure): reject literal "undefined" and "null" gateway auth tokens (#13767)

* fix(configure): reject literal "undefined" and "null" gateway auth tokens

* fix(configure): reject literal "undefined" and "null" gateway auth tokens

* fix(configure): validate gateway password prompt and harden token coercion (#13767) (thanks @omair445)

* test: remove unused vitest imports in baseline lint fixtures (#13767)

---------

Co-authored-by: Luna AI <luna@coredirection.ai>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
Omair Afzal 2026-02-13 21:04:41 +05:00 committed by GitHub
parent 4dc93f40d5
commit 59733a02c8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 118 additions and 11 deletions

View File

@ -73,6 +73,7 @@ Docs: https://docs.openclaw.ai
- Gateway: drain active turns before restart to prevent message loss. (#13931) Thanks @0xRaini.
- Gateway: auto-generate auth token during install to prevent launchd restart loops. (#13813) Thanks @cathrynlavery.
- Gateway: prevent `undefined`/missing token in auth config. (#13809) Thanks @asklee-klawd.
- Configure/Gateway: reject literal `"undefined"`/`"null"` token input and validate gateway password prompt values to avoid invalid password-mode configs. (#13767) Thanks @omair445.
- Gateway: handle async `EPIPE` on stdout/stderr during shutdown. (#13414) Thanks @keshav55.
- Gateway/Control UI: resolve missing dashboard assets when `openclaw` is installed globally via symlink-based Node managers (nvm/fnm/n/Homebrew). (#14919) Thanks @aynorica.
- Cron: use requested `agentId` for isolated job auth resolution. (#13983) Thanks @0xRaini.

View File

@ -44,6 +44,15 @@ describe("buildGatewayAuthConfig", () => {
expect(result).toEqual({ mode: "password", password: "secret" });
});
it("does not silently omit password when literal string is provided", () => {
const result = buildGatewayAuthConfig({
mode: "password",
password: "undefined",
});
expect(result).toEqual({ mode: "password", password: "undefined" });
});
it("generates random token when token param is undefined", () => {
const result = buildGatewayAuthConfig({
mode: "token",
@ -82,4 +91,30 @@ describe("buildGatewayAuthConfig", () => {
expect(typeof result?.token).toBe("string");
expect(result?.token?.length).toBeGreaterThan(0);
});
it('generates random token when token param is the literal string "undefined"', () => {
const result = buildGatewayAuthConfig({
mode: "token",
token: "undefined",
});
expect(result?.mode).toBe("token");
expect(result?.token).toBeDefined();
expect(result?.token).not.toBe("undefined");
expect(typeof result?.token).toBe("string");
expect(result?.token?.length).toBeGreaterThan(0);
});
it('generates random token when token param is the literal string "null"', () => {
const result = buildGatewayAuthConfig({
mode: "token",
token: "null",
});
expect(result?.mode).toBe("token");
expect(result?.token).toBeDefined();
expect(result?.token).not.toBe("null");
expect(typeof result?.token).toBe("string");
expect(result?.token?.length).toBeGreaterThan(0);
});
});

View File

@ -16,6 +16,18 @@ import { randomToken } from "./onboard-helpers.js";
type GatewayAuthChoice = "token" | "password";
/** Reject undefined, empty, and common JS string-coercion artifacts for token auth. */
function sanitizeTokenValue(value: string | undefined): string | undefined {
if (typeof value !== "string") {
return undefined;
}
const trimmed = value.trim();
if (!trimmed || trimmed === "undefined" || trimmed === "null") {
return undefined;
}
return trimmed;
}
const ANTHROPIC_OAUTH_MODEL_KEYS = [
"anthropic/claude-opus-4-6",
"anthropic/claude-opus-4-5",
@ -36,11 +48,12 @@ export function buildGatewayAuthConfig(params: {
}
if (params.mode === "token") {
// Guard against undefined/empty token to prevent JSON.stringify from writing the string "undefined"
const safeToken = params.token?.trim() || randomToken();
return { ...base, mode: "token", token: safeToken };
// Keep token mode always valid: treat empty/undefined/"undefined"/"null" as missing and generate a token.
const token = sanitizeTokenValue(params.token) ?? randomToken();
return { ...base, mode: "token", token };
}
return { ...base, mode: "password", password: params.password };
const password = params.password?.trim();
return { ...base, mode: "password", ...(password && { password }) };
}
export async function promptAuthConfig(

View File

@ -5,7 +5,12 @@ import { findTailscaleBinary } from "../infra/tailscale.js";
import { note } from "../terminal/note.js";
import { buildGatewayAuthConfig } from "./configure.gateway-auth.js";
import { confirm, select, text } from "./configure.shared.js";
import { guardCancel, normalizeGatewayTokenInput, randomToken } from "./onboard-helpers.js";
import {
guardCancel,
normalizeGatewayTokenInput,
randomToken,
validateGatewayPasswordInput,
} from "./onboard-helpers.js";
type GatewayAuthChoice = "token" | "password";
@ -189,7 +194,7 @@ export async function promptGatewayConfig(
const password = guardCancel(
await text({
message: "Gateway password",
validate: (value) => (value?.trim() ? undefined : "Required"),
validate: validateGatewayPasswordInput,
}),
runtime,
);

View File

@ -4,6 +4,7 @@ import {
openUrl,
resolveBrowserOpenCommand,
resolveControlUiLinks,
validateGatewayPasswordInput,
} from "./onboard-helpers.js";
const mocks = vi.hoisted(() => ({
@ -121,4 +122,32 @@ describe("normalizeGatewayTokenInput", () => {
it("returns empty string for non-string input", () => {
expect(normalizeGatewayTokenInput(123)).toBe("");
});
it('rejects the literal string "undefined"', () => {
expect(normalizeGatewayTokenInput("undefined")).toBe("");
});
it('rejects the literal string "null"', () => {
expect(normalizeGatewayTokenInput("null")).toBe("");
});
});
describe("validateGatewayPasswordInput", () => {
it("requires a non-empty password", () => {
expect(validateGatewayPasswordInput("")).toBe("Required");
expect(validateGatewayPasswordInput(" ")).toBe("Required");
});
it("rejects literal string coercion artifacts", () => {
expect(validateGatewayPasswordInput("undefined")).toBe(
'Cannot be the literal string "undefined" or "null"',
);
expect(validateGatewayPasswordInput("null")).toBe(
'Cannot be the literal string "undefined" or "null"',
);
});
it("accepts a normal password", () => {
expect(validateGatewayPasswordInput(" secret ")).toBeUndefined();
});
});

View File

@ -73,7 +73,27 @@ export function normalizeGatewayTokenInput(value: unknown): string {
if (typeof value !== "string") {
return "";
}
return value.trim();
const trimmed = value.trim();
// Reject the literal string "undefined" — a common bug when JS undefined
// gets coerced to a string via template literals or String(undefined).
if (trimmed === "undefined" || trimmed === "null") {
return "";
}
return trimmed;
}
export function validateGatewayPasswordInput(value: unknown): string | undefined {
if (typeof value !== "string") {
return "Required";
}
const trimmed = value.trim();
if (!trimmed) {
return "Required";
}
if (trimmed === "undefined" || trimmed === "null") {
return 'Cannot be the literal string "undefined" or "null"';
}
return undefined;
}
export function printWizardHeader(runtime: RuntimeEnv) {

View File

@ -1,6 +1,6 @@
import fs from "node:fs/promises";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import { describe, expect, it } from "vitest";
import { withTempHome } from "../../test/helpers/temp-home.js";
import { resolveProviderAuths } from "./provider-usage.auth.js";

View File

@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { afterEach, describe, expect, it } from "vitest";
import { discoverOpenClawPlugins } from "./discovery.js";
const tempDirs: string[] = [];

View File

@ -7,7 +7,11 @@ import type {
WizardFlow,
} from "./onboarding.types.js";
import type { WizardPrompter } from "./prompts.js";
import { normalizeGatewayTokenInput, randomToken } from "../commands/onboard-helpers.js";
import {
normalizeGatewayTokenInput,
randomToken,
validateGatewayPasswordInput,
} from "../commands/onboard-helpers.js";
import { findTailscaleBinary } from "../infra/tailscale.js";
// These commands are "high risk" (privacy writes/recording) and should be
@ -208,7 +212,7 @@ export async function configureGatewayForOnboarding(
? quickstartGateway.password
: await prompter.text({
message: "Gateway password",
validate: (value) => (value?.trim() ? undefined : "Required"),
validate: validateGatewayPasswordInput,
});
nextConfig = {
...nextConfig,