Merge pull request #2311 from pikasTech/fix/2304-opencode-csp-bootstrap

fix: make OpenCode project bootstrap CSP-safe
This commit is contained in:
Lyon
2026-06-30 22:08:38 +08:00
committed by GitHub
2 changed files with 67 additions and 12 deletions
+46 -7
View File
@@ -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")}`,