fix: initialize hosted Studio behind HTTPS reverse proxies
This commit is contained in:
1 parent
60467a4c05
commit
953e928b05
9 files changed
+186
-4
No files matched your search
@@ -1,3 +1,4 @@
|
|||||||
# Server only. Never put this key in browser scripts or commit your .env file.
|
# Server only. Never put this key in browser scripts or commit your .env file.
|
||||||
APIMART_API_KEY=
|
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.
|
# Design Studio uses the gpt-6-luna model at https://api.apimart.ai/v1/chat/completions.
|
||||||
@@ -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
|
The complete check includes mocked provider contract/error/lock tests and a
|
||||||
compiled-server deployment check. A real APIMart completion requires your key
|
compiled-server deployment check. A real APIMart completion requires your key
|
||||||
and model access; it is not claimed by the mocked tests.
|
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
@@ -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) {
|
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 & 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>`;
|
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 & 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>`;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import type { Middleware } from "@wrnexus/core";
|
|||||||
import type { createStudioHandler } from "@wrnexus/design-studio/server";
|
import type { createStudioHandler } from "@wrnexus/design-studio/server";
|
||||||
import { studioAI } from "../lib/studio-ai.ts";
|
import { studioAI } from "../lib/studio-ai.ts";
|
||||||
import { addStudioGuide, studioHelpScript } from "../lib/studio-guide.ts";
|
import { addStudioGuide, studioHelpScript } from "../lib/studio-guide.ts";
|
||||||
|
import { studioRequest } from "../lib/studio-origin.ts";
|
||||||
|
|
||||||
let studio: ReturnType<typeof createStudioHandler> | undefined;
|
let studio: ReturnType<typeof createStudioHandler> | undefined;
|
||||||
let ai: ReturnType<typeof studioAI> | undefined;
|
let ai: ReturnType<typeof studioAI> | undefined;
|
||||||
@@ -14,8 +15,10 @@ const designStudio: Middleware = async (ctx, next) => {
|
|||||||
headers: { location: "/design-studio/" },
|
headers: { location: "/design-studio/" },
|
||||||
});
|
});
|
||||||
if (!url.pathname.startsWith("/design-studio/")) return next();
|
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());
|
ai ??= studioAI(process.env.APIMART_API_KEY?.trim());
|
||||||
const generated = await ai(ctx.req);
|
const generated = await ai(request);
|
||||||
if (generated) return generated;
|
if (generated) return generated;
|
||||||
if (
|
if (
|
||||||
url.pathname === "/design-studio/site-help.js" &&
|
url.pathname === "/design-studio/site-help.js" &&
|
||||||
@@ -39,7 +42,7 @@ const designStudio: Middleware = async (ctx, next) => {
|
|||||||
studio = undefined;
|
studio = undefined;
|
||||||
throw error;
|
throw error;
|
||||||
});
|
});
|
||||||
const response = await (await studio)(ctx.req);
|
const response = await (await studio)(request);
|
||||||
if (
|
if (
|
||||||
url.pathname === "/design-studio/" &&
|
url.pathname === "/design-studio/" &&
|
||||||
response.ok &&
|
response.ok &&
|
||||||
|
|||||||
Vendored
+1
-1
@@ -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 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 ApiRoute = "/api/teams";
|
||||||
type RealtimeRoute = never;
|
type RealtimeRoute = never;
|
||||||
type EnvironmentKey = "APIMART_API_KEY";
|
type EnvironmentKey = "APIMART_API_KEY" | "STUDIO_PUBLIC_ORIGIN";
|
||||||
type TranslationKey = never;
|
type TranslationKey = never;
|
||||||
type QueueName = never;
|
type QueueName = never;
|
||||||
type CacheKey = never;
|
type CacheKey = never;
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 134 KiB |
@@ -56,6 +56,28 @@ try {
|
|||||||
const application = await (
|
const application = await (
|
||||||
await fetch(origin + "/design-studio/api/application")
|
await fetch(origin + "/design-studio/api/application")
|
||||||
).json();
|
).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 (
|
const generation = await (
|
||||||
await fetch(origin + "/design-studio/api/generation")
|
await fetch(origin + "/design-studio/api/generation")
|
||||||
).json();
|
).json();
|
||||||
|
|||||||
@@ -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" });
|
||||||
|
});
|
||||||
Reference in new issue
Block a user