fix: initialize hosted Studio behind HTTPS reverse proxies

This commit is contained in:
Clintchiz committed 2026-10-19 13:59:38 +05:30
1 parent 60467a4c05
commit 953e928b05
9 files changed
+186 -4

No files matched your search

+1
View File
@@ -1,3 +1,4 @@
# Server only. Never put this key in browser scripts or commit your .env file.
APIMART_API_KEY=
STUDIO_PUBLIC_ORIGIN=https://component.wrnexusjs.dev
# Design Studio uses the gpt-6-luna model at https://api.apimart.ai/v1/chat/completions.
+15
View File
@@ -149,3 +149,18 @@ browser storage; export before clearing browser data or moving to another origin
The complete check includes mocked provider contract/error/lock tests and a
compiled-server deployment check. A real APIMart completion requires your key
and model access; it is not claimed by the mocked tests.
### HTTPS proxy origin
The app defaults STUDIO_PUBLIC_ORIGIN to https://component.wrnexusjs.dev. If you
host this app under another domain, set STUDIO_PUBLIC_ORIGIN to that HTTPS origin
in .env. The Studio route normalizes internal HTTP requests to this explicitly
configured origin before validating browser Origin headers. It does not derive
an allowed origin from untrusted forwarded headers. Local loopback development
remains supported. Requests from other origins still return 403.
The deployment check reproduces a browser module request carrying an HTTPS
Origin while the proxy forwards it over HTTP, including missing/combined scheme
headers. This prevents an apparently loaded editor with empty dropdowns when its
module script is rejected before initialization. The browser also reports asset
load failures instead of leaving the editor permanently at Checking.
+19 -1
View File
@@ -1,4 +1,22 @@
export const studioHelpScript = `window.addEventListener("error",function(){var s=document.getElementById("validation-state"),d=document.getElementById("diagnostics");if(s&&s.textContent==="Checking"){s.textContent="Editor could not start";if(d)d.textContent="Reload the page. If this continues, rebuild and restart the site with the latest Studio configuration. Your saved draft has not been deleted.";}});document.addEventListener("DOMContentLoaded",function(){var b=document.querySelector('[data-tab="guided"]');if(b)b.click();});`;
export const studioHelpScript = `(() => {
let failed = false;
function report() {
const state = document.getElementById("validation-state");
const diagnostics = document.getElementById("diagnostics");
if (state && state.textContent === "Checking") {
state.textContent = "Editor could not start";
if (diagnostics) diagnostics.textContent = "The editor script could not initialize. Reload the page after updating the server. Your saved draft has not been deleted.";
}
}
function fail() { failed = true; report(); }
window.addEventListener("error", fail, true);
window.addEventListener("unhandledrejection", fail);
document.addEventListener("DOMContentLoaded", () => {
if (failed) report();
const button = document.querySelector('[data-tab="guided"]');
if (button) button.click();
});
})();`;
export function addStudioGuide(html: string, configured: boolean) {
const guide = `<details open class="help"><summary><strong>How to use Design Studio</strong></summary><ol><li><strong>Describe your website.</strong> Open Guided design, enter your product and audience, then choose colors, shapes and spacing with Next.</li><li><strong>Generate and compare.</strong> Click Generate three directions, review an alternative, then choose Use reviewed proposal.</li><li><strong>Refine it.</strong> Try “make buttons square and use blue as the primary color” under Edit with words. Lock decisions you want to keep.</li><li><strong>Use it in your app.</strong> Open Review &amp; apply and export Theme config or Compiled CSS. This website previews and exports designs; it does not change your deployed app automatically.</li></ol><p>${configured ? "APIMart AI is enabled with gpt-6-luna. Generation sends your brief and current design to APIMart only when you request it." : "AI is not configured. Local presets and supported rule-based edits still work. Set APIMART_API_KEY in the server .env and restart to enable gpt-6-luna."}</p></details>`;
+43
View File
@@ -0,0 +1,43 @@
/** Use an explicit external origin, rather than trusting proxy-supplied schemes. */
export function studioRequest(
request: Request,
publicOrigin = process.env.STUDIO_PUBLIC_ORIGIN ??
"https://component.wrnexusjs.dev",
): Request | Response {
const incoming = new URL(request.url);
const configured = new URL(publicOrigin);
if (
configured.protocol !== "https:" ||
configured.username ||
configured.password ||
configured.pathname !== "/" ||
configured.search ||
configured.hash
)
throw Error(
"STUDIO_PUBLIC_ORIGIN must be an HTTPS origin without a path or credentials.",
);
const origin = request.headers.get("origin");
const loopback = ["localhost", "127.0.0.1", "[::1]"].includes(
incoming.hostname,
);
const expected =
loopback && (!origin || origin === incoming.origin)
? incoming.origin
: configured.origin;
if (origin && origin !== expected)
return Response.json(
{ error: "Same-origin requests only." },
{ status: 403, headers: { "cache-control": "no-store" } },
);
const normalized = new Request(
new URL(incoming.pathname + incoming.search, expected),
request,
);
normalized.headers.set(
"x-forwarded-proto",
new URL(expected).protocol.slice(0, -1),
);
normalized.headers.set("host", new URL(expected).host);
return normalized;
}
+5 -2
View File
@@ -2,6 +2,7 @@ import type { Middleware } from "@wrnexus/core";
import type { createStudioHandler } from "@wrnexus/design-studio/server";
import { studioAI } from "../lib/studio-ai.ts";
import { addStudioGuide, studioHelpScript } from "../lib/studio-guide.ts";
import { studioRequest } from "../lib/studio-origin.ts";
let studio: ReturnType<typeof createStudioHandler> | undefined;
let ai: ReturnType<typeof studioAI> | undefined;
@@ -14,8 +15,10 @@ const designStudio: Middleware = async (ctx, next) => {
headers: { location: "/design-studio/" },
});
if (!url.pathname.startsWith("/design-studio/")) return next();
const request = studioRequest(ctx.req);
if (request instanceof Response) return request;
ai ??= studioAI(process.env.APIMART_API_KEY?.trim());
const generated = await ai(ctx.req);
const generated = await ai(request);
if (generated) return generated;
if (
url.pathname === "/design-studio/site-help.js" &&
@@ -39,7 +42,7 @@ const designStudio: Middleware = async (ctx, next) => {
studio = undefined;
throw error;
});
const response = await (await studio)(ctx.req);
const response = await (await studio)(request);
if (
url.pathname === "/design-studio/" &&
response.ok &&
+1 -1
View File
@@ -16,7 +16,7 @@ declare namespace WRNexusGenerated {
type RouteName = "accessibility" | "advanced.forms" | "base" | "block.library" | "blocks" | "blocks.announcement.bar" | "blocks.ctasection" | "blocks.feature.card" | "blocks.feature.grid" | "blocks.feature.icon.card" | "blocks.footer" | "blocks.hero" | "blocks.hero.actions" | "blocks.marketing.section.header" | "blocks.metric.grid" | "blocks.page.header" | "blocks.section.header" | "blocks.split.hero" | "blocks.stats.bar" | "colors" | "components.accordion" | "components.advanced.select" | "components.alert" | "components.avatar" | "components.avatar.group" | "components.back.to.top" | "components.badge" | "components.blockquote" | "components.breadcrumb" | "components.button" | "components.button.group" | "components.card" | "components.carousel" | "components.chart" | "components.chat.bubble" | "components.checkbox" | "components.clipboard" | "components.collapse" | "components.color.picker" | "components.columns" | "components.combo.box" | "components.confetti" | "components.container" | "components.context.menu" | "components.custom.scrollbar" | "components.data.map" | "components.data.table" | "components.date.picker" | "components.device.frame" | "components.divider" | "components.drag.and.drop" | "components.drawer" | "components.drawflow.canvas" | "components.dropdown" | "components.file.input" | "components.file.upload.progress" | "components.flow.canvas" | "components.grid" | "components.image" | "components.input" | "components.input.group" | "components.input.number" | "components.kbd" | "components.layout.splitter" | "components.legend.indicator" | "components.link" | "components.list" | "components.list.group" | "components.map" | "components.marquee" | "components.mega.menu" | "components.metric.card" | "components.modal" | "components.nav" | "components.navbar" | "components.pagination" | "components.pin.input" | "components.popover" | "components.preference.switcher" | "components.progress" | "components.radio" | "components.range.slider" | "components.rating" | "components.scrollspy" | "components.search.box" | "components.section" | "components.select" | "components.sidebar" | "components.skeleton" | "components.spinner" | "components.stepper" | "components.strong.password" | "components.styled.icon" | "components.switch" | "components.tabs" | "components.text.link" | "components.textarea" | "components.time.picker" | "components.timeline" | "components.toaster" | "components.toggle.count" | "components.toggle.password" | "components.tooltip" | "components.tree.view" | "components.typography" | "components.wysiwyg.editor" | "core" | "dark.mode" | "data" | "docs" | "fonts" | "forms" | "framework.guides" | "index" | "installation" | "integrations" | "layout" | "marketing" | "navigation" | "overlays" | "preview" | "preview.custom.modal" | "preview.profile" | "preview.project.board" | "preview.scheduling" | "preview.sign.in" | "preview.workspace" | "resources" | "sizing" | "tables" | "templates" | "templates.commerce" | "templates.dashboards" | "templates.marketing" | "templates.productivity" | "themes" | "v06.functions" | "v06.imports" | "v06.migration" | "v06.outputs" | "v06.runtime" | "v06.state" | "v06.stores" | "v06.types";
type ApiRoute = "/api/teams";
type RealtimeRoute = never;
type EnvironmentKey = "APIMART_API_KEY";
type EnvironmentKey = "APIMART_API_KEY" | "STUDIO_PUBLIC_ORIGIN";
type TranslationKey = never;
type QueueName = never;
type CacheKey = never;
Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

+22
View File
@@ -56,6 +56,28 @@ try {
const application = await (
await fetch(origin + "/design-studio/api/application")
).json();
// Browser module requests carry Origin; the VM forwards them over plain HTTP.
const publicOrigin =
process.env.STUDIO_PUBLIC_ORIGIN ?? "https://component.wrnexusjs.dev";
const proxiedHeaders = {
origin: publicOrigin,
host: new URL(publicOrigin).host,
};
for (const path of [
"/design-studio/studio.js",
"/design-studio/api/application",
]) {
const proxied = await fetch(origin + path, { headers: proxiedHeaders });
if (!proxied.ok)
throw Error(
`HTTPS-origin request over HTTP failed: ${path} (${proxied.status})`,
);
}
const rejected = await fetch(origin + "/design-studio/studio.js", {
headers: { ...proxiedHeaders, origin: "https://attacker.example" },
});
if (rejected.status !== 403)
throw Error("Cross-origin Studio requests were not rejected.");
const generation = await (
await fetch(origin + "/design-studio/api/generation")
).json();
+80
View File
@@ -0,0 +1,80 @@
import { expect, test } from "bun:test";
import { studioRequest } from "../app/lib/studio-origin.ts";
import { createContext } from "@wrnexus/core";
import designStudio from "../app/middleware/design-studio.ts";
const origin = "https://component.wrnexusjs.dev";
test("HTTPS browser module requests work behind an HTTP proxy without forwarded headers", async () => {
const req = new Request(
"http://component.wrnexusjs.dev/design-studio/studio.js",
{ headers: { origin } },
);
const normalized = studioRequest(req);
expect(normalized).toBeInstanceOf(Request);
expect((normalized as Request).url).toBe(origin + "/design-studio/studio.js");
const response = await designStudio(
createContext(req, new URL(req.url)),
() => new Response("wrong route", { status: 404 }),
);
expect(response.status).toBe(200);
expect(response.headers.get("content-type")).toContain("javascript");
});
test("proxy chains and internal Host values cannot break the canonical origin", () => {
for (const proto of ["http", "https,http", "https, http", ""]) {
const normalized = studioRequest(
new Request("http://127.0.0.1:3000/design-studio/api/preview?page=1", {
headers: {
origin,
"x-forwarded-proto": proto,
"x-forwarded-host": "attacker.example",
},
}),
) as Request;
expect(normalized.url).toBe(origin + "/design-studio/api/preview?page=1");
expect(normalized.headers.get("x-forwarded-proto")).toBe("https");
}
});
test("foreign and opaque origins remain rejected and local development keeps working", () => {
for (const from of [
"https://attacker.example",
"null",
"http://component.wrnexusjs.dev",
]) {
const response = studioRequest(
new Request("http://component.wrnexusjs.dev/design-studio/api/generate", {
headers: { origin: from },
}),
);
expect((response as Response).status).toBe(403);
}
const local = "http://127.0.0.1:4320/design-studio/";
expect(
(
studioRequest(
new Request(local, { headers: { origin: "http://127.0.0.1:4320" } }),
) as Request
).url,
).toBe(local);
expect(() =>
studioRequest(new Request(local), "https://example.org/path"),
).toThrow("HTTPS origin");
});
test("origin normalization preserves request bodies and configured custom domains", async () => {
const req = new Request("http://internal:3000/design-studio/api/generate", {
method: "POST",
headers: {
origin: "https://studio.example.org",
"content-type": "application/json",
},
body: '{"draft":"preserved"}',
});
const normalized = studioRequest(
req,
"https://studio.example.org",
) as Request;
expect(normalized.method).toBe("POST");
expect(await normalized.json()).toEqual({ draft: "preserved" });
});