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:
Michael Appel
2026-04-14 13:00:28 -04:00
committed by GitHub
parent 0a87707092
commit acd4e0a32f
6 changed files with 104 additions and 2 deletions

View File

@@ -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

View File

@@ -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,

View File

@@ -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) {

View File

@@ -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,
});
}

View File

@@ -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: {

View File

@@ -450,6 +450,7 @@ export async function startGatewayServer(
resolvedAuth,
rateLimiter: authRateLimiter,
gatewayTls,
getResolvedAuth,
hooksConfig: () => runtimeState?.hooksConfig ?? initialHooksConfig,
getHookClientIpConfig: () => runtimeState?.hookClientIpConfig ?? initialHookClientIpConfig,
pluginRegistry,