Root cause
Authentication & access control
feat(gateway): add OpenResponses /v1/responses endpoint
How AI contributed: New attack surfacePublished Mar 3, 2026 in
openclaw/openclawAll 35 findings in this repository
Summary When tokenless Tailscale auth is enabled, OpenClaw should only allow forwarded-header auth for Control UI websocket authentication on trusted hosts. In affected versions, that tokenless path could also be used by HTTP gateway auth call sites, which could bypass token/password requirements for HTTP routes in trusted-network deployments. Affected Packages / Versions · Package: openclaw (npm) · Affected range: <= 2026.2.19-2 (latest published npm version as of February 21, 2026) · Patched in: planned 2026.2.21 release Impact Deployments relying on token/password for HTTP gateway routes could be downgraded to tokenless behavior when Tailscale header auth is enabled. This weakens expected HTTP route authentication boundaries even in trusted-host network setups. Per SECURITY.md, this does not affect the recommended setup: keep the Gateway loopback-only (or otherwise within a trusted host/network boundary), use Tailscale serve/funnel for remote access, and keep tokenless Tailscale auth scoped to Control UI websocket login. Fix · Added an explicit auth-surface gate (allowTailscaleHeaderAuth, default false) in gateway auth. · Enabled tokenless Tailscale header auth only for Control UI websocket authentication. · Kept HTTP gateway auth call sites on token/password auth paths. · Added regression coverage for HTTP-vs-websocket behavior and Tailscale header handling. Fix Commit(s) · 356d61aacfa5b0f1d5830716ec59d70682a3e7b8 Release Process Note patched_versions is pre-set to the planned next release (2026.2.21) so once npm release is published, this advisory can be published directly without further field edits. OpenClaw thanks @zpbrent for reporting.
The new OpenResponses /v1/responses handler reused authorizeGatewayConnect without restricting the tokenless Tailscale header path, so HTTP callers on a trusted network could bypass token and password requirements when auth.allowTailscale was enabled. The fix adds an allowTailscaleHeaderAuth option that defaults to false and is set true only on the Control UI websocket path.
Root cause
feat(gateway): add OpenResponses /v1/responses endpoint
Fix
fix(gateway): scope tailscale tokenless auth to websocket
Fix by Peter Steinberger · no AI marker found
Case facts
diff --git a/src/gateway/openresponses-http.ts b/src/gateway/openresponses-http.tsnew file mode 100644index 00000000000..0ddbeba667e--- /dev/null+++ b/src/gateway/openresponses-http.ts@@ -0,0 +1,580 @@+/**+ * OpenResponses HTTP Handler+ *+ * Implements the OpenResponses `/v1/responses` endpoint for Clawdbot Gateway.+ *+ * @see https://www.open-responses.com/+ */++import { randomUUID } from "node:crypto";+import type { IncomingMessage, ServerResponse } from "node:http";++import { buildHistoryContextFromEntries, type HistoryEntry } from "../auto-reply/reply/history.js";+import { createDefaultDeps } from "../cli/deps.js";+import { agentCommand } from "../commands/agent.js";+import { emitAgentEvent, onAgentEvent } from "../infra/agent-events.js";+import { buildAgentMainSessionKey, normalizeAgentId } from "../routing/session-key.js";+import { defaultRuntime } from "../runtime.js";+import { authorizeGatewayConnect, type ResolvedGatewayAuth } from "./auth.js";+import { readJsonBody } from "./hooks.js";+import {+ CreateResponseBodySchema,+ type ContentPart,+ type CreateResponseBody,+ type ItemParam,+ type OutputItem,+ type ResponseResource,+ type StreamingEvent,+ type Usage,+} from "./open-responses.schema.js";++type OpenResponsesHttpOptions = {+ auth: ResolvedGatewayAuth;+ maxBodyBytes?: number;+};++function sendJson(res: ServerResponse, status: number, body: unknown) {+ res.statusCode = status;+ res.setHeader("Content-Type", "application/json; charset=utf-8");+ res.end(JSON.stringify(body));+}++function getHeader(req: IncomingMessage, name: string): string | undefined {+ const raw = req.headers[name.toLowerCase()];+ if (typeof raw === "string") return raw;+ if (Array.isArray(raw)) return raw[0];+ return undefined;+}++function getBearerToken(req: IncomingMessage): string | undefined {+ const raw = getHeader(req, "authorization")?.trim() ?? "";+ if (!raw.toLowerCase().startsWith("bearer ")) return undefined;+ const token = raw.slice(7).trim();+ return token || undefined;+}++function writeSseEvent(res: ServerResponse, event: StreamingEvent) {+ res.write(`event: ${event.type}\n`);+ res.write(`data: ${JSON.stringify(event)}\n\n`);+}++function writeDone(res: ServerResponse) {+ res.write("data: [DONE]\n\n");+}++function extractTextContent(content: string | ContentPart[]): string {+ if (typeof content === "string") return content;+ return content+ .map((part) => {+ if (part.type === "input_text") return part.text;+ if (part.type === "output_text") return part.text;+ return "";+ })+ .filter(Boolean)+ .join("\n");+}++function hasUnsupportedContent(content: string | ContentPart[]): string | null {+ if (typeof content === "string") return null;+ for (const part of content) {+ if (part.type === "input_image") return "input_image content is not supported in Phase 1";+ if (part.type === "input_file") return "input_file content is not supported in Phase 1";+ }+ return null;+}++function buildAgentPrompt(input: string | ItemParam[]): {+ message: string;+ extraSystemPrompt?: string;+} {+ if (typeof input === "string") {+ return { message: input };+ }++ const systemParts: string[] = [];+ const conversationEntries: Array<{ role: "user" | "assistant" | "tool"; entry: HistoryEntry }> =+ [];++ for (const item of input) {+ if (item.type === "message") {+ const content = extractTextContent(item.content).trim();+ if (!content) continue;++ if (item.role === "system" || item.role === "developer") {+ systemParts.push(content);+ continue;+ }++ const normalizedRole = item.role === "assistant" ? "assistant" : "user";+ const sender = normalizedRole === "assistant" ? "Assistant" : "User";++ conversationEntries.push({+ role: normalizedRole,+ entry: { sender, body: content },+ });+ } else if (item.type === "function_call_output") {+ conversationEntries.push({+ role: "tool",+ entry: { sender: `Tool:${item.call_id}`, body: item.output },+ });+ }+ // Skip reasoning and item_reference for prompt building (Phase 1)+ }++ let message = "";+ if (conversationEntries.length > 0) {+ // Find the last user or tool message as the current message+ let currentIndex = -1;+ for (let i = conversationEntries.length - 1; i >= 0; i -= 1) {+ const entryRole = conversationEntries[i]?.role;+ if (entryRole === "user" || entryRole === "tool") {+ currentIndex = i;+ break;+ }+ }+ if (currentIndex < 0) currentIndex = conversationEntries.length - 1;++ const currentEntry = conversationEntries[currentIndex]?.entry;+ if (currentEntry) {+ const historyEntries = conversationEntries.slice(0, currentIndex).map((entry) => entry.entry);+ if (historyEntries.length === 0) {+ message = currentEntry.body;+ } else {+ const formatEntry = (entry: HistoryEntry) => `${entry.sender}: ${entry.body}`;+ message = buildHistoryContextFromEntries({+ entries: [...historyEntries, currentEntry],+ currentMessage: formatEntry(currentEntry),+ formatEntry,+ });+ }+ }+ }++ return {+ message,+ extraSystemPrompt: systemParts.length > 0 ? systemParts.join("\n\n") : undefined,+ };+}++function resolveAgentIdFromHeader(req: IncomingMessage): string | undefined {+ const raw =+ getHeader(req, "x-clawdbot-agent-id")?.trim() ||+ getHeader(req, "x-clawdbot-agent")?.trim() ||+ "";+ if (!raw) return undefined;+ return normalizeAgentId(raw);+}++function resolveAgentIdFromModel(model: string | undefined): string | undefined {+ const raw = model?.trim();+ if (!raw) return undefined;++ const m =+ raw.match(/^clawdbot[:/](?<agentId>[a-z0-9][a-z0-9_-]{0,63})$/i) ??+ raw.match(/^agent:(?<agentId>[a-z0-9][a-z0-9_-]{0,63})$/i);+ const agentId = m?.groups?.agentId;+ if (!agentId) return undefined;+ return normalizeAgentId(agentId);+}++function resolveAgentIdForRequest(params: {+ req: IncomingMessage;+ model: string | undefined;+}): string {+ const fromHeader = resolveAgentIdFromHeader(params.req);+ if (fromHeader) return fromHeader;++ const fromModel = resolveAgentIdFromModel(params.model);+ return fromModel ?? "main";+}++function resolveSessionKey(params: {+ req: IncomingMessage;+ agentId: string;+ user?: string | undefined;+}): string {+ const explicit = getHeader(params.req, "x-clawdbot-session-key")?.trim();+ if (explicit) return explicit;++ // Default: stateless per-request session key, but stable if OpenResponses "user" is provided.+ const user = params.user?.trim();+ const mainKey = user ? `openresponses-user:${user}` : `openresponses:${randomUUID()}`;+ return buildAgentMainSessionKey({ agentId: params.agentId, mainKey });+}++function createEmptyUsage(): Usage {+ return { input_tokens: 0, output_tokens: 0, total_tokens: 0 };+}++function createResponseResource(params: {+ id: string;+ model: string;+ status: ResponseResource["status"];+ output: OutputItem[];+ usage?: Usage;+ error?: { code: string; message: string };+}): ResponseResource {+ return {+ id: params.id,+ object: "response",+ created_at: Math.floor(Date.now() / 1000),+ status: params.status,+ model: params.model,+ output: params.output,+ usage: params.usage ?? createEmptyUsage(),+ error: params.error,+ };+}++function createAssistantOutputItem(params: {+ id: string;+ text: string;+ status?: "in_progress" | "completed";+}): OutputItem {+ return {+ type: "message",+ id: params.id,+ role: "assistant",+ content: [{ type: "output_text", text: params.text }],+ status: params.status,+ };+}++export async function handleOpenResponsesHttpRequest(+ req: IncomingMessage,+ res: ServerResponse,+ opts: OpenResponsesHttpOptions,+): Promise<boolean> {+ const url = new URL(req.url ?? "/", `http://${req.headers.host || "localhost"}`);+ if (url.pathname !== "/v1/responses") return false;++ if (req.method !== "POST") {+ res.statusCode = 405;+ res.setHeader("Allow", "POST");+ res.setHeader("Content-Type", "text/plain; charset=utf-8");+ res.end("Method Not Allowed");+ return true;+ }++ const token = getBearerToken(req);+ const authResult = await authorizeGatewayConnect({+ auth: opts.auth,+ connectAuth: { token, password: token },+ req,+ });+ if (!authResult.ok) {+ sendJson(res, 401, {+ error: { message: "Unauthorized", type: "unauthorized" },+ });+ return true;+ }++ const body = await readJsonBody(req, opts.maxBodyBytes ?? 1024 * 1024);+ if (!body.ok) {+ sendJson(res, 400, {+ error: { message: body.error, type: "invalid_request_error" },+ });+ return true;+ }++ // Validate request body with Zod+ const parseResult = CreateResponseBodySchema.safeParse(body.value);+ if (!parseResult.success) {+ const issue = parseResult.error.issues[0];+ const message = issue ? `${issue.path.join(".")}: ${issue.message}` : "Invalid request body";+ sendJson(res, 400, {+ error: { message, type: "invalid_request_error" },+ });+ return true;+ }++ const payload: CreateResponseBody = parseResult.data;+ const stream = Boolean(payload.stream);+ const model = payload.model;+ const user = payload.user;++ // Check for unsupported content types (Phase 1)+ if (Array.isArray(payload.input)) {+ for (const item of payload.input) {+ if (item.type === "message" && typeof item.content !== "string") {+ const unsupported = hasUnsupportedContent(item.content);+ if (unsupported) {+ sendJson(res, 400, {+ error: { message: unsupported, type: "invalid_request_error" },+ });+ return true;+ }+ }+ }+ }++ const agentId = resolveAgentIdForRequest({ req, model });+ const sessionKey = resolveSessionKey({ req, agentId, user });++ // Build prompt from input+ const prompt = buildAgentPrompt(payload.input);++ // Handle instructions as extra system prompt+ const extraSystemPrompt = [payload.instructions, prompt.extraSystemPrompt]+ .filter(Boolean)+ .join("\n\n");++ if (!prompt.message) {+ sendJson(res, 400, {+ error: {+ message: "Missing user message in `input`.",+ type: "invalid_request_error",+ },+ });+ return true;+ }++ const responseId = `resp_${randomUUID()}`;+ const outputItemId = `msg_${randomUUID()}`;+ const deps = createDefaultDeps();++ if (!stream) {+ try {+ const result = await agentCommand(+ {+ message: prompt.message,+ extraSystemPrompt: extraSystemPrompt || undefined,+ sessionKey,+ runId: responseId,+ deliver: false,+ messageChannel: "webchat",+ bestEffortDeliver: false,+ },+ defaultRuntime,+ deps,+ );++ const payloads = (result as { payloads?: Array<{ text?: string }> } | null)?.payloads;+ const content =+ Array.isArray(payloads) && payloads.length > 0+ ? payloads+ .map((p) => (typeof p.text === "string" ? p.text : ""))+ .filter(Boolean)+ .join("\n\n")+ : "No response from Clawdbot.";++ const response = createResponseResource({+ id: responseId,+ model,+ status: "completed",+ output: [+ createAssistantOutputItem({ id: outputItemId, text: content, status: "completed" }),+ ],+ });++ sendJson(res, 200, response);+ } catch (err) {+ const response = createResponseResource({+ id: responseId,+ model,+ status: "failed",+ output: [],+ error: { code: "api_error", message: String(err) },+ });+ sendJson(res, 500, response);+ }+ return true;+ }++ // ─────────────────────────────────────────────────────────────────────────+ // Streaming mode+ // ─────────────────────────────────────────────────────────────────────────++ res.statusCode = 200;+ res.setHeader("Content-Type", "text/event-stream; charset=utf-8");+ res.setHeader("Cache-Control", "no-cache");+ res.setHeader("Connection", "keep-alive");+ res.flushHeaders?.();++ let accumulatedText = "";+ let sawAssistantDelta = false;+ let closed = false;++ // Send initial events+ const initialResponse = createResponseResource({+ id: responseId,+ model,+ status: "in_progress",+ output: [],+ });++ writeSseEvent(res, { type: "response.created", response: initialResponse });++ // Add output item+ const outputItem = createAssistantOutputItem({+ id: outputItemId,+ text: "",+ status: "in_progress",+ });++ writeSseEvent(res, {+ type: "response.output_item.added",+ output_index: 0,+ item: outputItem,+ });++ // Add content part+ writeSseEvent(res, {+ type: "response.content_part.added",+ item_id: outputItemId,+ output_index: 0,+ content_index: 0,+ part: { type: "output_text", text: "" },+ });++ const unsubscribe = onAgentEvent((evt) => {+ if (evt.runId !== responseId) return;+ if (closed) return;++ if (evt.stream === "assistant") {+ const delta = evt.data?.delta;+ const text = evt.data?.text;+ const content = typeof delta === "string" ? delta : typeof text === "string" ? text : "";+ if (!content) return;++ sawAssistantDelta = true;+ accumulatedText += content;++ writeSseEvent(res, {+ type: "response.output_text.delta",+ item_id: outputItemId,+ output_index: 0,+ content_index: 0,+ delta: content,+ });+ return;+ }++ if (evt.stream === "lifecycle") {+ const phase = evt.data?.phase;+ if (phase === "end" || phase === "error") {+ closed = true;+ unsubscribe();++ // Complete the stream with final events+ const finalText = accumulatedText || "No response from Clawdbot.";+ const finalStatus = phase === "error" ? "failed" : "completed";++ writeSseEvent(res, {+ type: "response.output_text.done",+ item_id: outputItemId,+ output_index: 0,+ content_index: 0,+ text: finalText,+ });++ writeSseEvent(res, {+ type: "response.content_part.done",+ item_id: outputItemId,+ output_index: 0,+ content_index: 0,+ part: { type: "output_text", text: finalText },+ });++ const completedItem = createAssistantOutputItem({+ id: outputItemId,+ text: finalText,+ status: "completed",+ });++ writeSseEvent(res, {+ type: "response.output_item.done",+ output_index: 0,+ item: completedItem,+ });++ const finalResponse = createResponseResource({+ id: responseId,+ model,+ status: finalStatus,+ output: [completedItem],+ });++ writeSseEvent(res, { type: "response.completed", response: finalResponse });+ writeDone(res);+ res.end();+ }+ }+ });++ req.on("close", () => {+ closed = true;+ unsubscribe();+ });++ void (async () => {+ try {+ const result = await agentCommand(+ {+ message: prompt.message,+ extraSystemPrompt: extraSystemPrompt || undefined,+ sessionKey,+ runId: responseId,+ deliver: false,+ messageChannel: "webchat",+ bestEffortDeliver: false,+ },+ defaultRuntime,+ deps,+ );++ if (closed) return;++ // Fallback: if no streaming deltas were received, send the full response+ if (!sawAssistantDelta) {+ const payloads = (result as { payloads?: Array<{ text?: string }> } | null)?.payloads;+ const content =+ Array.isArray(payloads) && payloads.length > 0+ ? payloads+ .map((p) => (typeof p.text === "string" ? p.text : ""))+ .filter(Boolean)+ .join("\n\n")+ : "No response from Clawdbot.";++ accumulatedText = content;+ sawAssistantDelta = true;++ writeSseEvent(res, {+ type: "response.output_text.delta",+ item_id: outputItemId,+ output_index: 0,+ content_index: 0,+ delta: content,+ });+ }+ } catch (err) {+ if (closed) return;++ const errorResponse = createResponseResource({+ id: responseId,+ model,+ status: "failed",+ output: [],+ error: { code: "api_error", message: String(err) },+ });++ writeSseEvent(res, { type: "response.failed", response: errorResponse });+ emitAgentEvent({+ runId: responseId,+ stream: "lifecycle",+ data: { phase: "error" },+ });+ } finally {+ if (!closed) {+ // Emit lifecycle end to trigger completion+ emitAgentEvent({+ runId: responseId,+ stream: "lifecycle",+ data: { phase: "end" },+ });+ }+ }+ })();++ return true;+}diff --git a/src/gateway/auth.ts b/src/gateway/auth.tsindex f3a7f2d9056..76236dc2e0a 100644--- a/src/gateway/auth.ts+++ b/src/gateway/auth.ts@@ -327,0 +328,5 @@ export async function authorizeGatewayConnect(params: {+ allowTailscaleHeaderAuth?: boolean;@@ -336,0 +342 @@ export async function authorizeGatewayConnect(params: {+ const allowTailscaleHeaderAuth = params.allowTailscaleHeaderAuth === true;@@ -379 +385 @@ export async function authorizeGatewayConnect(params: {- if (auth.allowTailscale && !localDirect) {+ if (allowTailscaleHeaderAuth && auth.allowTailscale && !localDirect) {diff --git a/src/gateway/http-auth-helpers.ts b/src/gateway/http-auth-helpers.tsindex 449e9369c95..a7ee69eb6fb 100644--- a/src/gateway/http-auth-helpers.ts+++ b/src/gateway/http-auth-helpers.ts@@ -19,0 +20 @@ export async function authorizeGatewayBearerRequestOrReply(params: {+ allowTailscaleHeaderAuth: false,diff --git a/src/gateway/server-http.ts b/src/gateway/server-http.tsindex 63185280c74..6cabb0fb4d3 100644--- a/src/gateway/server-http.ts+++ b/src/gateway/server-http.ts@@ -157,0 +158 @@ async function authorizeCanvasRequest(params: {+ allowTailscaleHeaderAuth: false,@@ -534,0 +536 @@ export function createGatewayHttpServer(opts: {+ allowTailscaleHeaderAuth: false,diff --git a/src/gateway/server/ws-connection/message-handler.ts b/src/gateway/server/ws-connection/message-handler.tsindex 40dcc54e6b7..61be61fafdd 100644--- a/src/gateway/server/ws-connection/message-handler.ts+++ b/src/gateway/server/ws-connection/message-handler.ts@@ -353,0 +354 @@ export function attachGatewayWsMessageHandler(params: {+ allowTailscaleHeaderAuth: true,diff --git a/src/gateway/tools-invoke-http.ts b/src/gateway/tools-invoke-http.tsindex bd2c78a6dc8..eda5b816429 100644--- a/src/gateway/tools-invoke-http.ts+++ b/src/gateway/tools-invoke-http.ts@@ -153,0 +154 @@ export async function handleToolsInvokeHttpRequest(+ allowTailscaleHeaderAuth: false,AI-assisted change 6e9c439b8164d949ad4fe97ce769194bc209f30612bef0487723ab4aea860038 · Fix a6a4ec244d0132ec657871a1f4751906d258b79e059eccf02c8494320cdc69cc