Merge pull request #2311 from pikasTech/fix/2304-opencode-csp-bootstrap
fix: make OpenCode project bootstrap CSP-safe
This commit is contained in:
@@ -21,6 +21,7 @@ const CLIENT_ROUTE_FALLBACK_PATHS = new Set(["/workbench", "/workspace", "/openc
|
||||
const STATIC_ASSET_EXTENSION_PATTERN = /\.[A-Za-z0-9][A-Za-z0-9_-]*$/u;
|
||||
const OPENCODE_PROXY_PREFIX = "/_opencode";
|
||||
const OPENCODE_FRAME_URL_PATH = "/opencode/frame-url";
|
||||
const OPENCODE_DEFAULT_PROJECT_BOOTSTRAP_SCRIPT_PATH = "/__hwlab/opencode-default-project.js";
|
||||
const OPENCODE_TICKET_QUERY = "hwlab_opencode_ticket";
|
||||
const OPENCODE_TICKET_COOKIE = "hwlab_opencode_ticket";
|
||||
const OPENCODE_TICKET_TTL_MS = 5 * 60 * 1000;
|
||||
@@ -822,9 +823,13 @@ async function proxyOpencodeRequest({ request, response, url, cloudApiBaseUrl, c
|
||||
headers: opencodeUpstreamHeaders({ headers: { ...request.headers, ...cloudWebTraceHeaders(traceContext, serviceId) } }, body, authorization)
|
||||
};
|
||||
try {
|
||||
const proxyResult = shouldInjectOpencodeDefaultProject({ request, target, opencodeDefaultProject })
|
||||
? await proxyOpencodeHtmlRequest({ target, request: upstreamRequest, response, timeoutMs: opencodeProxyTimeoutMs, extraResponseHeaders, defaultProject: opencodeDefaultProject })
|
||||
: await proxyCloudApiRequest({
|
||||
let proxyResult;
|
||||
if (shouldServeOpencodeDefaultProjectBootstrapScript({ request, target, opencodeDefaultProject })) {
|
||||
proxyResult = sendOpencodeDefaultProjectBootstrapScript({ response, extraResponseHeaders, defaultProject: opencodeDefaultProject });
|
||||
} else if (shouldInjectOpencodeDefaultProject({ request, target, opencodeDefaultProject })) {
|
||||
proxyResult = await proxyOpencodeHtmlRequest({ target, request: upstreamRequest, response, timeoutMs: opencodeProxyTimeoutMs, extraResponseHeaders, defaultProject: opencodeDefaultProject });
|
||||
} else {
|
||||
proxyResult = await proxyCloudApiRequest({
|
||||
target,
|
||||
request: upstreamRequest,
|
||||
response,
|
||||
@@ -833,6 +838,7 @@ async function proxyOpencodeRequest({ request, response, url, cloudApiBaseUrl, c
|
||||
extraResponseHeaders,
|
||||
streamTransform
|
||||
});
|
||||
}
|
||||
emitOpencodeProxySpanAsync({
|
||||
traceContext,
|
||||
serviceId,
|
||||
@@ -903,6 +909,26 @@ function shouldInjectOpencodeDefaultProject({ request, target, opencodeDefaultPr
|
||||
return true;
|
||||
}
|
||||
|
||||
function shouldServeOpencodeDefaultProjectBootstrapScript({ request, target, opencodeDefaultProject }) {
|
||||
return Boolean(opencodeDefaultProject) && request.method === "GET" && target.pathname === OPENCODE_DEFAULT_PROJECT_BOOTSTRAP_SCRIPT_PATH;
|
||||
}
|
||||
|
||||
function sendOpencodeDefaultProjectBootstrapScript({ response, extraResponseHeaders, defaultProject }) {
|
||||
const outputBody = Buffer.from(opencodeDefaultProjectBootstrapJavaScript(defaultProject), "utf8");
|
||||
response.writeHead(200, mergeOpencodeProxyResponseHeaders({
|
||||
"content-type": "text/javascript; charset=utf-8",
|
||||
"cache-control": "no-store",
|
||||
"content-length": outputBody.length
|
||||
}, extraResponseHeaders));
|
||||
response.end(outputBody);
|
||||
return {
|
||||
statusCode: 200,
|
||||
streaming: false,
|
||||
bodyBytes: outputBody.length,
|
||||
opencodeDefaultProjectBootstrapScript: true
|
||||
};
|
||||
}
|
||||
|
||||
async function proxyOpencodeHtmlRequest({ target, request, response, timeoutMs, extraResponseHeaders, defaultProject }) {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
||||
@@ -919,7 +945,7 @@ async function proxyOpencodeHtmlRequest({ target, request, response, timeoutMs,
|
||||
? Buffer.from(opencodeInjectDefaultProjectHtml(inputBody.toString("utf8"), defaultProject), "utf8")
|
||||
: inputBody;
|
||||
response.writeHead(upstream.status, {
|
||||
...mergeOpencodeProxyResponseHeaders(copyProxyResponseHeaders(upstreamHeaders), extraResponseHeaders),
|
||||
...mergeOpencodeProxyResponseHeaders(opencodeDecodedFetchResponseHeaders(upstreamHeaders), extraResponseHeaders),
|
||||
"content-length": outputBody.length
|
||||
});
|
||||
response.end(outputBody);
|
||||
@@ -939,14 +965,27 @@ async function proxyOpencodeHtmlRequest({ target, request, response, timeoutMs,
|
||||
|
||||
function opencodeInjectDefaultProjectHtml(html, defaultProject) {
|
||||
if (!html || html.includes("data-hwlab-opencode-default-project")) return html;
|
||||
const script = opencodeDefaultProjectBootstrapScript(defaultProject);
|
||||
const script = opencodeDefaultProjectBootstrapScriptTag();
|
||||
if (html.includes("</head>")) return html.replace("</head>", `${script}</head>`);
|
||||
if (html.includes("</body>")) return html.replace("</body>", `${script}</body>`);
|
||||
return `${script}${html}`;
|
||||
}
|
||||
|
||||
function opencodeDefaultProjectBootstrapScript(defaultProject) {
|
||||
return `<script data-hwlab-opencode-default-project>(function(){try{var project=${safeInlineJson(defaultProject)};if(!project)return;var serverKey="opencode.global.dat:server";var server=JSON.parse(localStorage.getItem(serverKey)||"{}");var projects=server.projects&&typeof server.projects==="object"?server.projects:{};var local=Array.isArray(projects.local)?projects.local.slice():[];var found=false;local=local.map(function(item){if(!item||item.worktree!==project)return item;found=true;return Object.assign({},item,{expanded:true});});if(!found)local.unshift({worktree:project,expanded:true});projects.local=local;server.projects=projects;server.lastProject=Object.assign({},server.lastProject||{},{local:project});localStorage.setItem(serverKey,JSON.stringify(server));var layoutKey="opencode.global.dat:layout.page";var layout=JSON.parse(localStorage.getItem(layoutKey)||"{}");layout.workspaceExpanded=Object.assign({},layout.workspaceExpanded||{});layout.workspaceExpanded[project]=true;localStorage.setItem(layoutKey,JSON.stringify(layout));}catch(error){console.warn("HWLAB OpenCode default project bootstrap failed",error);}})();</script>`;
|
||||
function opencodeDefaultProjectBootstrapScriptTag() {
|
||||
return `<script data-hwlab-opencode-default-project src="${OPENCODE_DEFAULT_PROJECT_BOOTSTRAP_SCRIPT_PATH}"></script>`;
|
||||
}
|
||||
|
||||
function opencodeDefaultProjectBootstrapJavaScript(defaultProject) {
|
||||
return `(function(){try{var project=${safeInlineJson(defaultProject)};if(!project)return;var serverKey="opencode.global.dat:server";var server=JSON.parse(localStorage.getItem(serverKey)||"{}");var projects=server.projects&&typeof server.projects==="object"?server.projects:{};var local=Array.isArray(projects.local)?projects.local.slice():[];var found=false;local=local.map(function(item){if(!item||item.worktree!==project)return item;found=true;return Object.assign({},item,{expanded:true});});if(!found)local.unshift({worktree:project,expanded:true});projects.local=local;server.projects=projects;server.lastProject=Object.assign({},server.lastProject||{},{local:project});localStorage.setItem(serverKey,JSON.stringify(server));var layoutKey="opencode.global.dat:layout.page";var layout=JSON.parse(localStorage.getItem(layoutKey)||"{}");layout.workspaceExpanded=Object.assign({},layout.workspaceExpanded||{});layout.workspaceExpanded[project]=true;localStorage.setItem(layoutKey,JSON.stringify(layout));}catch(error){console.warn("HWLAB OpenCode default project bootstrap failed",error);}})();\n`;
|
||||
}
|
||||
|
||||
function opencodeDecodedFetchResponseHeaders(headers = {}) {
|
||||
const result = copyProxyResponseHeaders(headers);
|
||||
for (const key of Object.keys(result)) {
|
||||
const lowerKey = key.toLowerCase();
|
||||
if (lowerKey === "content-encoding" || lowerKey === "content-md5" || lowerKey === "etag") delete result[key];
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function mergeOpencodeProxyResponseHeaders(baseHeaders = {}, extraHeaders = {}) {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { connect } from "node:net";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
import { gzipSync } from "node:zlib";
|
||||
|
||||
import { createCloudWebServer, isClientDisconnectError } from "./cloud-web-runtime.mjs";
|
||||
|
||||
@@ -658,6 +659,8 @@ test("cloud web OpenCode proxy bootstraps the default project for iframe HTML",
|
||||
response.end(JSON.stringify({ authenticated: request.headers.cookie === "hwlab_session=session-a" }));
|
||||
});
|
||||
const opencodeRequests = [];
|
||||
const opencodeHtml = "<html><head></head><body><div id=\"root\"></div></body></html>\n";
|
||||
const opencodeHtmlGzip = gzipSync(Buffer.from(opencodeHtml, "utf8"));
|
||||
const opencode = createServer((request, response) => {
|
||||
opencodeRequests.push({
|
||||
url: request.url,
|
||||
@@ -665,8 +668,8 @@ test("cloud web OpenCode proxy bootstraps the default project for iframe HTML",
|
||||
cookie: request.headers.cookie
|
||||
});
|
||||
request.resume();
|
||||
response.writeHead(200, { "content-type": "text/html; charset=utf-8" });
|
||||
response.end("<html><head></head><body><div id=\"root\"></div></body></html>\n");
|
||||
response.writeHead(200, { "content-type": "text/html; charset=utf-8", "content-encoding": "gzip", etag: "upstream-html-etag" });
|
||||
response.end(opencodeHtmlGzip);
|
||||
});
|
||||
await listen(cloudApi);
|
||||
await listen(opencode);
|
||||
@@ -700,11 +703,24 @@ test("cloud web OpenCode proxy bootstraps the default project for iframe HTML",
|
||||
headers: { accept: "text/html" }
|
||||
});
|
||||
assert.equal(proxied.status, 200);
|
||||
assert.equal(proxied.headers.get("content-encoding"), null);
|
||||
assert.equal(proxied.headers.get("etag"), null);
|
||||
const html = await proxied.text();
|
||||
assert.match(html, /data-hwlab-opencode-default-project/u);
|
||||
assert.match(html, /opencode\.global\.dat:server/u);
|
||||
assert.match(html, /opencode\.global\.dat:layout\.page/u);
|
||||
assert.match(html, /\/workspace/u);
|
||||
assert.match(html, /src="\/__hwlab\/opencode-default-project\.js"/u);
|
||||
assert.doesNotMatch(html, /localStorage/u);
|
||||
|
||||
const ticketCookie = proxied.headers.get("set-cookie")?.match(/hwlab_opencode_ticket=([^;]+)/u)?.[1] || "";
|
||||
assert.ok(ticketCookie, "expected iframe HTML response to set OpenCode ticket cookie");
|
||||
const bootstrap = await fetch(`${serverUrl(cloudWeb)}/_opencode/__hwlab/opencode-default-project.js`, {
|
||||
headers: { accept: "text/javascript", cookie: `hwlab_opencode_ticket=${ticketCookie}` }
|
||||
});
|
||||
assert.equal(bootstrap.status, 200);
|
||||
assert.equal(bootstrap.headers.get("content-type"), "text/javascript; charset=utf-8");
|
||||
const bootstrapScript = await bootstrap.text();
|
||||
assert.match(bootstrapScript, /opencode\.global\.dat:server/u);
|
||||
assert.match(bootstrapScript, /opencode\.global\.dat:layout\.page/u);
|
||||
assert.match(bootstrapScript, /\/workspace/u);
|
||||
assert.deepEqual(opencodeRequests, [{
|
||||
url: "/",
|
||||
authorization: `Basic ${Buffer.from("oc_user:oc_password", "utf8").toString("base64")}`,
|
||||
|
||||
Reference in New Issue
Block a user