fix(ui): include proxy credentials in manifest requests (#111053)

* fix(ui): include credentials in manifest requests

* test(ui): verify authenticated PWA manifest browser requests

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
batmanscode
2026-07-29 17:40:36 +05:30
committed by GitHub
parent bfb7ff189e
commit f3b70d5902
3 changed files with 177 additions and 2 deletions
+1 -1
View File
@@ -11,7 +11,7 @@
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32.png" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<link rel="manifest" href="/manifest.webmanifest" />
<link rel="manifest" href="/manifest.webmanifest" crossorigin="use-credentials" />
<script>
// Configure Zod before module evaluation so its JIT probe cannot violate strict CSP.
globalThis.__zod_globalConfig ??= {};
+14 -1
View File
@@ -9,8 +9,12 @@ const indexHtmlPath = path.resolve(
);
type TestWindow = Window & typeof globalThis;
async function readIndexHtml(): Promise<string> {
return readFile(indexHtmlPath, "utf8");
}
async function readIndexHtmlWithDelay(delayMs: number): Promise<string> {
const html = await readFile(indexHtmlPath, "utf8");
const html = await readIndexHtml();
return html.replace(
'data-openclaw-mount-timeout-ms="12000"',
`data-openclaw-mount-timeout-ms="${delayMs}"`,
@@ -74,6 +78,15 @@ function requireElementById<T extends HTMLElement>(
return element;
}
describe("Control UI document shell", () => {
it("requests the web app manifest with credentials", async () => {
const parsed = new DOMParser().parseFromString(await readIndexHtml(), "text/html");
const manifest = parsed.querySelector<HTMLLinkElement>('link[rel="manifest"]');
expect(manifest?.getAttribute("crossorigin")).toBe("use-credentials");
});
});
describe("Control UI mount fallback", () => {
afterEach(() => {
document.body.innerHTML = "";
@@ -0,0 +1,162 @@
// Control UI tests prove real PWA manifest loading through an HTTP Basic Auth proxy.
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
import type { AddressInfo } from "node:net";
import { expect, it } from "vitest";
import { createChatFlowE2eSuite, installMockGateway } from "./chat-flow.test-support.ts";
type ManifestRequest = {
authorized: boolean;
status: 200 | 401;
};
type AuthenticatedProxy = {
baseUrl: string;
close: () => Promise<void>;
manifestRequests: ManifestRequest[];
};
const httpCredentials = {
password: "pwa-e2e-test-password",
username: "pwa-e2e-test-user",
};
const unforwardedHeaders = new Set([
"cache-control",
"connection",
"content-encoding",
"content-length",
]);
async function startAuthenticatedProxy(upstreamBaseUrl: string): Promise<AuthenticatedProxy> {
const manifestRequests: ManifestRequest[] = [];
const expectedAuthorization = `Basic ${Buffer.from(
`${httpCredentials.username}:${httpCredentials.password}`,
).toString("base64")}`;
const server = createServer((request, response) => {
void forwardAuthenticatedRequest({
expectedAuthorization,
manifestRequests,
request,
response,
upstreamBaseUrl,
}).catch(() => {
if (!response.headersSent) {
response.writeHead(502, { "Cache-Control": "no-store" });
}
response.end();
});
});
await listenOnLoopback(server);
const address = server.address();
if (!address || typeof address === "string") {
throw new Error("HTTP Basic Auth proof proxy did not expose a loopback port");
}
return {
baseUrl: `http://127.0.0.1:${address.port}/`,
close: () =>
new Promise<void>((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
}),
manifestRequests,
};
}
async function listenOnLoopback(server: Server): Promise<AddressInfo> {
return new Promise((resolve, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", () => {
const address = server.address();
if (!address || typeof address === "string") {
reject(new Error("HTTP Basic Auth proof proxy did not expose a loopback port"));
return;
}
resolve(address);
});
});
}
async function forwardAuthenticatedRequest(params: {
expectedAuthorization: string;
manifestRequests: ManifestRequest[];
request: IncomingMessage;
response: ServerResponse;
upstreamBaseUrl: string;
}): Promise<void> {
const { expectedAuthorization, manifestRequests, request, response, upstreamBaseUrl } = params;
const requestUrl = new URL(request.url ?? "/", upstreamBaseUrl);
const authorized = request.headers.authorization === expectedAuthorization;
if (requestUrl.pathname === "/manifest.webmanifest") {
manifestRequests.push({ authorized, status: authorized ? 200 : 401 });
}
response.setHeader("Cache-Control", "no-store");
if (!authorized) {
response.writeHead(401, { "WWW-Authenticate": 'Basic realm="OpenClaw PWA E2E"' });
response.end("HTTP Basic authentication required");
return;
}
const upstream = await fetch(requestUrl, {
headers: { Accept: request.headers.accept ?? "*/*" },
method: request.method,
});
response.statusCode = upstream.status;
for (const [name, value] of upstream.headers) {
if (!unforwardedHeaders.has(name)) {
response.setHeader(name, value);
}
}
response.end(request.method === "HEAD" ? undefined : Buffer.from(await upstream.arrayBuffer()));
}
const suite = createChatFlowE2eSuite();
suite.define(() => {
it("loads the installable same-origin PWA manifest through an HTTP Basic Auth proxy", async () => {
const proxy = await startAuthenticatedProxy(suite.server.baseUrl);
const context = await suite.newBrowserContext({
httpCredentials: { ...httpCredentials, origin: new URL(proxy.baseUrl).origin },
serviceWorkers: "block",
});
try {
const unauthenticated = await fetch(new URL("manifest.webmanifest", proxy.baseUrl));
expect(unauthenticated.status).toBe(401);
expect(unauthenticated.headers.get("www-authenticate")).toContain("Basic");
proxy.manifestRequests.length = 0;
const page = await context.newPage();
await installMockGateway(page);
const response = await page.goto(proxy.baseUrl, { waitUntil: "domcontentloaded" });
expect(response?.status()).toBe(200);
// CDP uses Chromium's PWA manifest loader; page.fetch cannot prove link credentials.
const session = await context.newCDPSession(page);
const manifest = await session.send("Page.getAppManifest");
expect(proxy.manifestRequests.length).toBeGreaterThan(0);
expect(proxy.manifestRequests.filter((request) => !request.authorized)).toEqual([]);
expect(proxy.manifestRequests.every((request) => request.status === 200)).toBe(true);
expect(manifest.url).toBe(new URL("manifest.webmanifest", proxy.baseUrl).href);
expect(manifest.errors).toEqual([]);
expect(JSON.parse(manifest.data ?? "null")).toEqual(
expect.objectContaining({ display: "standalone", name: "OpenClaw Control" }),
);
expect(
await page.locator('link[rel="manifest"]').evaluate((link) => ({
crossOrigin: (link as HTMLLinkElement).crossOrigin,
href: (link as HTMLLinkElement).href,
})),
).toEqual({
crossOrigin: "use-credentials",
href: new URL("manifest.webmanifest", proxy.baseUrl).href,
});
} finally {
await suite.closeBrowserContext(context);
await proxy.close();
}
});
});