mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-09 00:38:28 +00:00
refactor(gateway): streamline control-ui secure file serving
This commit is contained in:
@@ -180,6 +180,29 @@ describe("handleControlUiHttpRequest", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("serves HEAD for in-root assets without writing a body", async () => {
|
||||||
|
await withControlUiRoot({
|
||||||
|
fn: async (tmp) => {
|
||||||
|
const assetsDir = path.join(tmp, "assets");
|
||||||
|
await fs.mkdir(assetsDir, { recursive: true });
|
||||||
|
await fs.writeFile(path.join(assetsDir, "actual.txt"), "inside-ok\n");
|
||||||
|
|
||||||
|
const { res, end } = makeMockHttpResponse();
|
||||||
|
const handled = handleControlUiHttpRequest(
|
||||||
|
{ url: "/assets/actual.txt", method: "HEAD" } as IncomingMessage,
|
||||||
|
res,
|
||||||
|
{
|
||||||
|
root: { kind: "resolved", path: tmp },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(handled).toBe(true);
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(end.mock.calls[0]?.length ?? -1).toBe(0);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("rejects symlinked SPA fallback index.html outside control-ui root", async () => {
|
it("rejects symlinked SPA fallback index.html outside control-ui root", async () => {
|
||||||
await withControlUiRoot({
|
await withControlUiRoot({
|
||||||
fn: async (tmp) => {
|
fn: async (tmp) => {
|
||||||
|
|||||||
@@ -179,19 +179,21 @@ function respondNotFound(res: ServerResponse) {
|
|||||||
res.end("Not Found");
|
res.end("Not Found");
|
||||||
}
|
}
|
||||||
|
|
||||||
function serveFile(res: ServerResponse, filePath: string) {
|
function setStaticFileHeaders(res: ServerResponse, filePath: string) {
|
||||||
const ext = path.extname(filePath).toLowerCase();
|
const ext = path.extname(filePath).toLowerCase();
|
||||||
res.setHeader("Content-Type", contentTypeForExt(ext));
|
res.setHeader("Content-Type", contentTypeForExt(ext));
|
||||||
// Static UI should never be cached aggressively while iterating; allow the
|
// Static UI should never be cached aggressively while iterating; allow the
|
||||||
// browser to revalidate.
|
// browser to revalidate.
|
||||||
res.setHeader("Cache-Control", "no-cache");
|
res.setHeader("Cache-Control", "no-cache");
|
||||||
|
}
|
||||||
|
|
||||||
|
function serveFile(res: ServerResponse, filePath: string) {
|
||||||
|
setStaticFileHeaders(res, filePath);
|
||||||
res.end(fs.readFileSync(filePath));
|
res.end(fs.readFileSync(filePath));
|
||||||
}
|
}
|
||||||
|
|
||||||
function serveResolvedFile(res: ServerResponse, filePath: string, body: Buffer) {
|
function serveResolvedFile(res: ServerResponse, filePath: string, body: Buffer) {
|
||||||
const ext = path.extname(filePath).toLowerCase();
|
setStaticFileHeaders(res, filePath);
|
||||||
res.setHeader("Content-Type", contentTypeForExt(ext));
|
|
||||||
res.setHeader("Cache-Control", "no-cache");
|
|
||||||
res.end(body);
|
res.end(body);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -217,12 +219,11 @@ function areSameFileIdentity(preOpen: fs.Stats, opened: fs.Stats): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function resolveSafeControlUiFile(
|
function resolveSafeControlUiFile(
|
||||||
root: string,
|
rootReal: string,
|
||||||
filePath: string,
|
filePath: string,
|
||||||
): { path: string; body: Buffer } | null {
|
): { path: string; fd: number } | null {
|
||||||
let fd: number | null = null;
|
let fd: number | null = null;
|
||||||
try {
|
try {
|
||||||
const rootReal = fs.realpathSync(root);
|
|
||||||
const fileReal = fs.realpathSync(filePath);
|
const fileReal = fs.realpathSync(filePath);
|
||||||
if (!isContainedPath(rootReal, fileReal)) {
|
if (!isContainedPath(rootReal, fileReal)) {
|
||||||
return null;
|
return null;
|
||||||
@@ -243,7 +244,9 @@ function resolveSafeControlUiFile(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return { path: fileReal, body: fs.readFileSync(fd) };
|
const resolved = { path: fileReal, fd };
|
||||||
|
fd = null;
|
||||||
|
return resolved;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (isExpectedSafePathError(error)) {
|
if (isExpectedSafePathError(error)) {
|
||||||
return null;
|
return null;
|
||||||
@@ -377,6 +380,25 @@ export function handleControlUiHttpRequest(
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const rootReal = (() => {
|
||||||
|
try {
|
||||||
|
return fs.realpathSync(root);
|
||||||
|
} catch (error) {
|
||||||
|
if (isExpectedSafePathError(error)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
if (!rootReal) {
|
||||||
|
res.statusCode = 503;
|
||||||
|
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
||||||
|
res.end(
|
||||||
|
"Control UI assets not found. Build them with `pnpm ui:build` (auto-installs UI deps), or run `pnpm ui:dev` during development.",
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
const uiPath =
|
const uiPath =
|
||||||
basePath && pathname.startsWith(`${basePath}/`) ? pathname.slice(basePath.length) : pathname;
|
basePath && pathname.startsWith(`${basePath}/`) ? pathname.slice(basePath.length) : pathname;
|
||||||
const rel = (() => {
|
const rel = (() => {
|
||||||
@@ -402,14 +424,24 @@ export function handleControlUiHttpRequest(
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const safeFile = resolveSafeControlUiFile(root, filePath);
|
const safeFile = resolveSafeControlUiFile(rootReal, filePath);
|
||||||
if (safeFile) {
|
if (safeFile) {
|
||||||
if (path.basename(safeFile.path) === "index.html") {
|
try {
|
||||||
serveResolvedIndexHtml(res, safeFile.body.toString("utf8"));
|
if (req.method === "HEAD") {
|
||||||
|
res.statusCode = 200;
|
||||||
|
setStaticFileHeaders(res, safeFile.path);
|
||||||
|
res.end();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (path.basename(safeFile.path) === "index.html") {
|
||||||
|
serveResolvedIndexHtml(res, fs.readFileSync(safeFile.fd, "utf8"));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
serveResolvedFile(res, safeFile.path, fs.readFileSync(safeFile.fd));
|
||||||
return true;
|
return true;
|
||||||
|
} finally {
|
||||||
|
fs.closeSync(safeFile.fd);
|
||||||
}
|
}
|
||||||
serveResolvedFile(res, safeFile.path, safeFile.body);
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// If the requested path looks like a static asset (known extension), return
|
// If the requested path looks like a static asset (known extension), return
|
||||||
@@ -424,10 +456,20 @@ export function handleControlUiHttpRequest(
|
|||||||
|
|
||||||
// SPA fallback (client-side router): serve index.html for unknown paths.
|
// SPA fallback (client-side router): serve index.html for unknown paths.
|
||||||
const indexPath = path.join(root, "index.html");
|
const indexPath = path.join(root, "index.html");
|
||||||
const safeIndex = resolveSafeControlUiFile(root, indexPath);
|
const safeIndex = resolveSafeControlUiFile(rootReal, indexPath);
|
||||||
if (safeIndex) {
|
if (safeIndex) {
|
||||||
serveResolvedIndexHtml(res, safeIndex.body.toString("utf8"));
|
try {
|
||||||
return true;
|
if (req.method === "HEAD") {
|
||||||
|
res.statusCode = 200;
|
||||||
|
setStaticFileHeaders(res, safeIndex.path);
|
||||||
|
res.end();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
serveResolvedIndexHtml(res, fs.readFileSync(safeIndex.fd, "utf8"));
|
||||||
|
return true;
|
||||||
|
} finally {
|
||||||
|
fs.closeSync(safeIndex.fd);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
respondNotFound(res);
|
respondNotFound(res);
|
||||||
|
|||||||
Reference in New Issue
Block a user