mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 07:00:43 +00:00
fix(gateway): re-resolve HTTP auth per-request to honor credential rotation [AI] (#66651)
* fix: address issue * fix: address review feedback * changelog: note HTTP auth per-request rotation honor (#66651) --------- Co-authored-by: Devin Robison <drobison@nvidia.com>
This commit is contained in:
@@ -17,6 +17,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Setup/providers: guard preferred-provider lookup during setup so malformed plugin metadata with a missing provider id no longer crashes the wizard with `Cannot read properties of undefined (reading 'trim')`. (#66649) Thanks @Tianworld.
|
||||
- Matrix/security: normalize sandboxed profile avatar params, preserve `mxc://` avatar URLs, and surface gmail watcher stop failures during reload. (#64701) Thanks @slepybear.
|
||||
- Telegram/documents: drop leaked binary caption bytes from inbound Telegram text handling so document uploads like `.mobi` or `.epub` no longer explode prompt token counts. (#66663) Thanks @joelnishanth.
|
||||
- Gateway/auth: resolve the active gateway bearer per-request on the HTTP server and the HTTP upgrade handler via `getResolvedAuth()`, mirroring the WebSocket path, so a secret rotated through `secrets.reload` or config hot-reload stops authenticating on `/v1/*`, `/tools/invoke`, plugin HTTP routes, and the canvas upgrade path immediately instead of remaining valid on HTTP until gateway restart. (#66651) Thanks @mmaps.
|
||||
|
||||
## 2026.4.14
|
||||
|
||||
|
||||
@@ -113,6 +113,65 @@ describe("gateway probe endpoints", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("re-resolves auth for remote /ready requests after shared auth rotation", async () => {
|
||||
const getReadiness: ReadinessChecker = () => ({
|
||||
ready: false,
|
||||
failing: ["discord", "telegram"],
|
||||
uptimeMs: 8_000,
|
||||
});
|
||||
let currentAuth = AUTH_TOKEN;
|
||||
|
||||
await withGatewayServer({
|
||||
prefix: "probe-remote-rotated-auth",
|
||||
// `resolvedAuth` remains the static fallback; `getResolvedAuth` drives the rotated value.
|
||||
resolvedAuth: AUTH_TOKEN,
|
||||
overrides: {
|
||||
getReadiness,
|
||||
getResolvedAuth: () => currentAuth,
|
||||
},
|
||||
run: async (server) => {
|
||||
const sendReady = async (authorization: string) => {
|
||||
const req = createRequest({
|
||||
path: "/ready",
|
||||
remoteAddress: "10.0.0.8",
|
||||
host: "gateway.test",
|
||||
authorization,
|
||||
});
|
||||
const { res, getBody } = createResponse();
|
||||
await dispatchRequest(server, req, res);
|
||||
return { statusCode: res.statusCode, body: JSON.parse(getBody()) };
|
||||
};
|
||||
|
||||
await expect(sendReady("Bearer test-token")).resolves.toEqual({
|
||||
statusCode: 503,
|
||||
body: {
|
||||
ready: false,
|
||||
failing: ["discord", "telegram"],
|
||||
uptimeMs: 8_000,
|
||||
},
|
||||
});
|
||||
|
||||
currentAuth = {
|
||||
...AUTH_TOKEN,
|
||||
token: "rotated-token",
|
||||
};
|
||||
|
||||
await expect(sendReady("Bearer test-token")).resolves.toEqual({
|
||||
statusCode: 503,
|
||||
body: { ready: false },
|
||||
});
|
||||
await expect(sendReady("Bearer rotated-token")).resolves.toEqual({
|
||||
statusCode: 503,
|
||||
body: {
|
||||
ready: false,
|
||||
failing: ["discord", "telegram"],
|
||||
uptimeMs: 8_000,
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("hides readiness details when trusted-proxy auth violates browser origin policy", async () => {
|
||||
const getReadiness: ReadinessChecker = () => ({
|
||||
ready: false,
|
||||
|
||||
@@ -838,6 +838,7 @@ export function createGatewayHttpServer(opts: {
|
||||
handlePluginRequest?: PluginHttpRequestHandler;
|
||||
shouldEnforcePluginGatewayAuth?: (pathContext: PluginRoutePathContext) => boolean;
|
||||
resolvedAuth: ResolvedGatewayAuth;
|
||||
getResolvedAuth?: () => ResolvedGatewayAuth;
|
||||
/** Optional rate limiter for auth brute-force protection. */
|
||||
rateLimiter?: AuthRateLimiter;
|
||||
getReadiness?: ReadinessChecker;
|
||||
@@ -861,6 +862,7 @@ export function createGatewayHttpServer(opts: {
|
||||
rateLimiter,
|
||||
getReadiness,
|
||||
} = opts;
|
||||
const getResolvedAuth = opts.getResolvedAuth ?? (() => resolvedAuth);
|
||||
const openAiCompatEnabled = openAiChatCompletionsEnabled || openResponsesEnabled;
|
||||
const httpServer: HttpServer = opts.tlsOptions
|
||||
? createHttpsServer(opts.tlsOptions, (req, res) => {
|
||||
@@ -896,6 +898,7 @@ export function createGatewayHttpServer(opts: {
|
||||
const pluginPathContext = handlePluginRequest
|
||||
? resolvePluginRoutePathContext(requestPath)
|
||||
: null;
|
||||
const resolvedAuth = getResolvedAuth();
|
||||
const requestStages: GatewayHttpRequestStage[] = [
|
||||
{
|
||||
name: "hooks",
|
||||
@@ -1117,6 +1120,7 @@ export function attachGatewayUpgradeHandler(opts: {
|
||||
clients: Set<GatewayWsClient>;
|
||||
preauthConnectionBudget: PreauthConnectionBudget;
|
||||
resolvedAuth: ResolvedGatewayAuth;
|
||||
getResolvedAuth?: () => ResolvedGatewayAuth;
|
||||
/** Optional rate limiter for auth brute-force protection. */
|
||||
rateLimiter?: AuthRateLimiter;
|
||||
}) {
|
||||
@@ -1129,6 +1133,7 @@ export function attachGatewayUpgradeHandler(opts: {
|
||||
resolvedAuth,
|
||||
rateLimiter,
|
||||
} = opts;
|
||||
const getResolvedAuth = opts.getResolvedAuth ?? (() => resolvedAuth);
|
||||
httpServer.on("upgrade", (req, socket, head) => {
|
||||
void (async () => {
|
||||
const configSnapshot = loadConfig();
|
||||
@@ -1143,6 +1148,7 @@ export function attachGatewayUpgradeHandler(opts: {
|
||||
if (scopedCanvas.rewrittenUrl) {
|
||||
req.url = scopedCanvas.rewrittenUrl;
|
||||
}
|
||||
const resolvedAuth = getResolvedAuth();
|
||||
if (canvasHost) {
|
||||
const url = new URL(req.url ?? "/", "http://localhost");
|
||||
if (url.pathname === CANVAS_WS_PATH) {
|
||||
|
||||
@@ -61,6 +61,7 @@ export async function createGatewayRuntimeState(params: {
|
||||
openResponsesConfig?: import("../config/types.gateway.js").GatewayHttpResponsesConfig;
|
||||
strictTransportSecurityHeader?: string;
|
||||
resolvedAuth: ResolvedGatewayAuth;
|
||||
getResolvedAuth: () => ResolvedGatewayAuth;
|
||||
/** Optional rate limiter for auth brute-force protection. */
|
||||
rateLimiter?: AuthRateLimiter;
|
||||
gatewayTls?: GatewayTlsRuntime;
|
||||
@@ -185,6 +186,7 @@ export async function createGatewayRuntimeState(params: {
|
||||
handlePluginRequest,
|
||||
shouldEnforcePluginGatewayAuth,
|
||||
resolvedAuth: params.resolvedAuth,
|
||||
getResolvedAuth: params.getResolvedAuth,
|
||||
rateLimiter: params.rateLimiter,
|
||||
getReadiness: params.getReadiness,
|
||||
tlsOptions: params.gatewayTls?.enabled ? params.gatewayTls.tlsOptions : undefined,
|
||||
@@ -224,6 +226,7 @@ export async function createGatewayRuntimeState(params: {
|
||||
clients,
|
||||
preauthConnectionBudget,
|
||||
resolvedAuth: params.resolvedAuth,
|
||||
getResolvedAuth: params.getResolvedAuth,
|
||||
rateLimiter: params.rateLimiter,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -123,9 +123,9 @@ async function expectWsRejected(
|
||||
});
|
||||
}
|
||||
|
||||
async function expectWsConnected(url: string): Promise<void> {
|
||||
async function expectWsConnected(url: string, headers?: Record<string, string>): Promise<void> {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const ws = new WebSocket(url);
|
||||
const ws = new WebSocket(url, headers ? { headers } : undefined);
|
||||
let settled = false;
|
||||
const finish = (fn: () => void) => {
|
||||
if (settled) {
|
||||
@@ -207,6 +207,7 @@ const allowCanvasHostHttp: CanvasHostHandler["handleHttpRequest"] = async (req,
|
||||
};
|
||||
async function withCanvasGatewayHarness(params: {
|
||||
resolvedAuth: ResolvedGatewayAuth;
|
||||
getResolvedAuth?: () => ResolvedGatewayAuth;
|
||||
listenHost?: string;
|
||||
rateLimiter?: ReturnType<typeof createAuthRateLimiter>;
|
||||
handleHttpRequest: CanvasHostHandler["handleHttpRequest"];
|
||||
@@ -241,6 +242,7 @@ async function withCanvasGatewayHarness(params: {
|
||||
openResponsesEnabled: false,
|
||||
handleHooksRequest: async () => false,
|
||||
resolvedAuth: params.resolvedAuth,
|
||||
getResolvedAuth: params.getResolvedAuth,
|
||||
rateLimiter: params.rateLimiter,
|
||||
});
|
||||
|
||||
@@ -252,6 +254,7 @@ async function withCanvasGatewayHarness(params: {
|
||||
clients,
|
||||
preauthConnectionBudget: createPreauthConnectionBudget(8),
|
||||
resolvedAuth: params.resolvedAuth,
|
||||
getResolvedAuth: params.getResolvedAuth,
|
||||
rateLimiter: params.rateLimiter,
|
||||
});
|
||||
|
||||
@@ -424,6 +427,35 @@ describe("gateway canvas host auth", () => {
|
||||
});
|
||||
}, 60_000);
|
||||
|
||||
test("re-resolves canvas bearer auth on each upgrade after shared auth rotation", async () => {
|
||||
let currentAuth = tokenResolvedAuth;
|
||||
|
||||
await withCanvasGatewayHarness({
|
||||
resolvedAuth: tokenResolvedAuth,
|
||||
getResolvedAuth: () => currentAuth,
|
||||
handleHttpRequest: allowCanvasHostHttp,
|
||||
run: async ({ listener }) => {
|
||||
const url = `ws://127.0.0.1:${listener.port}${CANVAS_WS_PATH}`;
|
||||
|
||||
await expectWsConnected(url, {
|
||||
authorization: "Bearer test-token",
|
||||
});
|
||||
|
||||
currentAuth = {
|
||||
...tokenResolvedAuth,
|
||||
token: "rotated-token",
|
||||
};
|
||||
|
||||
await expectWsRejected(url, {
|
||||
authorization: "Bearer test-token",
|
||||
});
|
||||
await expectWsConnected(url, {
|
||||
authorization: "Bearer rotated-token",
|
||||
});
|
||||
},
|
||||
});
|
||||
}, 60_000);
|
||||
|
||||
test("accepts capability-scoped paths over IPv6 loopback", async () => {
|
||||
await withTempConfig({
|
||||
cfg: {
|
||||
|
||||
@@ -450,6 +450,7 @@ export async function startGatewayServer(
|
||||
resolvedAuth,
|
||||
rateLimiter: authRateLimiter,
|
||||
gatewayTls,
|
||||
getResolvedAuth,
|
||||
hooksConfig: () => runtimeState?.hooksConfig ?? initialHooksConfig,
|
||||
getHookClientIpConfig: () => runtimeState?.hookClientIpConfig ?? initialHookClientIpConfig,
|
||||
pluginRegistry,
|
||||
|
||||
Reference in New Issue
Block a user