diff --git a/.env.example b/.env.example index d813777..bc41989 100644 --- a/.env.example +++ b/.env.example @@ -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. diff --git a/README.md b/README.md index e4dae85..d93092b 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/app/lib/studio-guide.ts b/app/lib/studio-guide.ts index 79687f1..ac19c3b 100644 --- a/app/lib/studio-guide.ts +++ b/app/lib/studio-guide.ts @@ -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 = `
How to use Design Studio
  1. Describe your website. Open Guided design, enter your product and audience, then choose colors, shapes and spacing with Next.
  2. Generate and compare. Click Generate three directions, review an alternative, then choose Use reviewed proposal.
  3. Refine it. Try “make buttons square and use blue as the primary color” under Edit with words. Lock decisions you want to keep.
  4. Use it in your app. Open Review & apply and export Theme config or Compiled CSS. This website previews and exports designs; it does not change your deployed app automatically.

${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."}

`; diff --git a/app/lib/studio-origin.ts b/app/lib/studio-origin.ts new file mode 100644 index 0000000..61883fa --- /dev/null +++ b/app/lib/studio-origin.ts @@ -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; +} diff --git a/app/middleware/design-studio.ts b/app/middleware/design-studio.ts index c6175b7..a532734 100644 --- a/app/middleware/design-studio.ts +++ b/app/middleware/design-studio.ts @@ -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 | undefined; let ai: ReturnType | 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 && diff --git a/app/types/wrnexus.generated.d.ts b/app/types/wrnexus.generated.d.ts index bddbf61..bf5aab1 100644 --- a/app/types/wrnexus.generated.d.ts +++ b/app/types/wrnexus.generated.d.ts @@ -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; diff --git a/deploy/studio-proxy-origin-fixed.jpg b/deploy/studio-proxy-origin-fixed.jpg new file mode 100644 index 0000000..63b53b3 Binary files /dev/null and b/deploy/studio-proxy-origin-fixed.jpg differ diff --git a/scripts/verify-deployment.ts b/scripts/verify-deployment.ts index 49fd923..b018caa 100644 --- a/scripts/verify-deployment.ts +++ b/scripts/verify-deployment.ts @@ -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(); diff --git a/test/studio-origin.test.ts b/test/studio-origin.test.ts new file mode 100644 index 0000000..4c3cc25 --- /dev/null +++ b/test/studio-origin.test.ts @@ -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" }); +});