From 410fedbe839f798f2cc2ce1880378874af689bf5 Mon Sep 17 00:00:00 2001 From: Oleg Isonen Date: Sun, 1 Nov 2026 11:54:07 +0000 Subject: [PATCH 1/2] fix: Allow self-hosted Node forms to reach sibling APIs (#6627) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Docker and Coolify exports still reject a Form Resource on a sibling API host. The Node generator denies the entire registrable domain, so `webstudio-demo-john.stauferth.de` cannot submit to `cms.stauferth.de`. This follow-up will allow public sibling API hosts while keeping DNS address validation and stopping Resource requests that return to the same project through an alias. Ref #6623 ## Plan - [x] Reproduce the Node export rejection and add a failing regression test. - [x] Restrict the Node hostname deny rule to the site's known hosts. - [x] Mark outbound Node Resource requests and reject same-project reentry before page data loads. - [x] Verify public DNS pinning, redirects, other-project requests, and existing Cloudflare behavior. - [x] Review the Builder product docs for Node self-hosting and update them if needed. - [x] Run targeted tests, typechecks, lint, fixture generation, and required CI. All checks on `fb283ea4b6` passed, including the main workflow, visual regression, lint, submodules, and staging deploy. Expected production change: roughly 30–50 net lines across the generator, Node transport, and route templates. No new package, service, or user-facing error text. A CLI release and republish will be required to update existing exports. ## Verification - Reproduced the Node rejection for `cms.stauferth.de` from `webstudio-demo-john.stauferth.de`; the new generator test failed before the fix and passed after it. - SDK marker test failed without the marker assignment, then passed. Focused SDK tests: 27 passed. CLI prebuild: 144 passed. - SDK and CLI typechecks, SDK published types, root lint and package boundaries, fixture generation, Docker fixture typecheck and production build passed. - The built Docker fixture returned 508 for marked same-project GET and Form POST requests and 200 for another project's GET. - The Node connector still vets and pins public DNS answers on each hop. A reverse proxy must preserve the marker header for unknown-alias reentry detection. No live customer deployment was tested. - Actual source change: 59 net non-test lines, plus 268 generated fixture lines. Both legacy and React Router route templates need entry checks because Node exports lack a Worker entrypoint. --- docs/university/core-components/form.md | 2 + .../app/routes/[another-page]._index.tsx | 10 +++ .../app/routes/_index.tsx | 10 +++ .../$resources.managed-form-fetch.server.ts | 8 ++- .../app/routes/[another-page]._index.tsx | 10 +++ .../react-router-docker/app/routes/_index.tsx | 10 +++ .../$resources.managed-form-fetch.server.ts | 8 ++- .../app/routes/[another-page]._index.tsx | 10 +++ .../app/routes/_index.tsx | 10 +++ .../$resources.managed-form-fetch.server.ts | 8 ++- .../app/routes/[another-page]._index.tsx | 10 +++ .../react-router-vercel/app/routes/_index.tsx | 10 +++ .../$resources.managed-form-fetch.server.ts | 8 ++- .../$resources.managed-form-fetch.server.ts | 8 ++- .../app/routes/[another-page]._index.tsx | 10 +++ .../app/routes/_index.tsx | 10 +++ .../$resources.managed-form-fetch.server.ts | 8 ++- .../routes/[_route_with_symbols_]._index.tsx | 10 +++ .../app/routes/[animations]._index.tsx | 10 +++ .../app/routes/[assets1]._index.tsx | 10 +++ .../app/routes/[class-names]._index.tsx | 10 +++ .../app/routes/[content-block]._index.tsx | 10 +++ .../app/routes/[duration]._index.tsx | 10 +++ .../app/routes/[expressions]._index.tsx | 10 +++ .../app/routes/[form]._index.tsx | 10 +++ .../app/routes/[head-tag]._index.tsx | 10 +++ .../app/routes/[heading-with-id]._index.tsx | 10 +++ .../routes/[nested].[nested-page]._index.tsx | 10 +++ .../app/routes/[radix]._index.tsx | 10 +++ .../app/routes/[resources]._index.tsx | 10 +++ .../app/routes/[sitemap.xml]._index.tsx | 6 ++ .../app/routes/[text-duration]._index.tsx | 10 +++ .../webstudio-features/app/routes/_index.tsx | 10 +++ packages/cli/src/prebuild.test.ts | 62 +++++++++++++++++++ packages/cli/src/prebuild.ts | 15 +++-- .../defaults/app/route-templates/html.tsx | 10 +++ .../defaults/app/route-templates/text.tsx | 7 ++- .../defaults/app/route-templates/xml.tsx | 7 ++- .../react-router/app/route-templates/html.tsx | 10 +++ .../react-router/app/route-templates/text.tsx | 7 ++- .../react-router/app/route-templates/xml.tsx | 7 ++- .../src/protected-resource-fetch-node.test.ts | 18 ++++++ .../sdk/src/protected-resource-fetch-node.ts | 10 +++ packages/sdk/src/protected-resource-fetch.ts | 4 +- 44 files changed, 435 insertions(+), 28 deletions(-) diff --git a/docs/university/core-components/form.md b/docs/university/core-components/form.md index 867f58d655..cd5950eb4b 100644 --- a/docs/university/core-components/form.md +++ b/docs/university/core-components/form.md @@ -354,6 +354,8 @@ Resource actions need a server. Webstudio Cloud provides this; a static export c Self-hosted sites need a supported server setup and an email integration to send Email Resources. See [self-hosting](../self-hosting/README.md). +For a Docker or other Node export, an HTTP Resource action can send to a public API on a sibling subdomain, such as `cms.example.com` from `site.example.com`. The export blocks known site hostnames and rejects marked requests that return through another alias. Rebuild and redeploy the export with an updated CLI; installing a newer SDK in an existing container does not change the generated destination policy. + ## Related - [Variables](../foundations/variables.md) — Resources, scope, and bindings. diff --git a/fixtures/react-router-cloudflare/app/routes/[another-page]._index.tsx b/fixtures/react-router-cloudflare/app/routes/[another-page]._index.tsx index 33082a08a6..80a2596921 100644 --- a/fixtures/react-router-cloudflare/app/routes/[another-page]._index.tsx +++ b/fixtures/react-router-cloudflare/app/routes/[another-page]._index.tsx @@ -1,3 +1,4 @@ +import { isProtectedResourceReentry } from "@webstudio-is/sdk/protected-resource-fetch"; import { type ComponentProps, memo, useMemo } from "react"; import { type MetaFunction, @@ -98,6 +99,11 @@ const customFetch: typeof fetch = (input, init) => { }; export const loader = async (arg: LoaderFunctionArgs) => { + // Node exports have no Worker entrypoint guard; stop requests through aliases here. + if (isProtectedResourceReentry(arg.request, projectId)) { + throw new Response(null, { status: 508 }); + } + const authRoute = authenticateProjectRequest( arg.request, authRoutes, @@ -263,6 +269,10 @@ export const action = async ({ }: ActionFunctionArgs): Promise< { success: true } | { success: false; errors: string[] } | ManagedFormResponse > => { + if (isProtectedResourceReentry(request, projectId)) { + throw new Response(null, { status: 508 }); + } + authenticateProjectRequest(request, authRoutes, projectDomain); let isManagedFormRequest = false; diff --git a/fixtures/react-router-cloudflare/app/routes/_index.tsx b/fixtures/react-router-cloudflare/app/routes/_index.tsx index 840dca4f76..0ff01dc639 100644 --- a/fixtures/react-router-cloudflare/app/routes/_index.tsx +++ b/fixtures/react-router-cloudflare/app/routes/_index.tsx @@ -1,3 +1,4 @@ +import { isProtectedResourceReentry } from "@webstudio-is/sdk/protected-resource-fetch"; import { type ComponentProps, memo, useMemo } from "react"; import { type MetaFunction, @@ -98,6 +99,11 @@ const customFetch: typeof fetch = (input, init) => { }; export const loader = async (arg: LoaderFunctionArgs) => { + // Node exports have no Worker entrypoint guard; stop requests through aliases here. + if (isProtectedResourceReentry(arg.request, projectId)) { + throw new Response(null, { status: 508 }); + } + const authRoute = authenticateProjectRequest( arg.request, authRoutes, @@ -263,6 +269,10 @@ export const action = async ({ }: ActionFunctionArgs): Promise< { success: true } | { success: false; errors: string[] } | ManagedFormResponse > => { + if (isProtectedResourceReentry(request, projectId)) { + throw new Response(null, { status: 508 }); + } + authenticateProjectRequest(request, authRoutes, projectDomain); let isManagedFormRequest = false; diff --git a/fixtures/react-router-docker/app/__generated__/$resources.managed-form-fetch.server.ts b/fixtures/react-router-docker/app/__generated__/$resources.managed-form-fetch.server.ts index 5ddbf5e2e5..7adfddde7b 100644 --- a/fixtures/react-router-docker/app/__generated__/$resources.managed-form-fetch.server.ts +++ b/fixtures/react-router-docker/app/__generated__/$resources.managed-form-fetch.server.ts @@ -1,13 +1,15 @@ -import { getDeniedResourceHostnames } from "@webstudio-is/sdk/protected-resource-fetch"; import { createNodeProtectedResourceFetch } from "@webstudio-is/sdk/protected-resource-fetch-node"; export const createManagedFormEmailSender = (_input: { context: unknown; formData: FormData; projectId: string }) => undefined; export const validateManagedFormEmail = (_request: unknown, _formData: FormData) => undefined; export const createManagedFormResourceFetch = ({ request, context, projectDomain }: { request: Request; context: unknown; projectDomain?: string }) => { void context; return createNodeProtectedResourceFetch({ - deniedHostnames: getDeniedResourceHostnames([ + projectId: "d845c167-ea07-4875-b08d-83e97c09dcce", + matchSubdomains: false, + deniedHostnames: [ new URL(request.url).hostname, projectDomain, - ]), + ...["cli-basic-test-d0osr"], + ].filter((hostname): hostname is string => hostname !== undefined), }); }; diff --git a/fixtures/react-router-docker/app/routes/[another-page]._index.tsx b/fixtures/react-router-docker/app/routes/[another-page]._index.tsx index 33082a08a6..80a2596921 100644 --- a/fixtures/react-router-docker/app/routes/[another-page]._index.tsx +++ b/fixtures/react-router-docker/app/routes/[another-page]._index.tsx @@ -1,3 +1,4 @@ +import { isProtectedResourceReentry } from "@webstudio-is/sdk/protected-resource-fetch"; import { type ComponentProps, memo, useMemo } from "react"; import { type MetaFunction, @@ -98,6 +99,11 @@ const customFetch: typeof fetch = (input, init) => { }; export const loader = async (arg: LoaderFunctionArgs) => { + // Node exports have no Worker entrypoint guard; stop requests through aliases here. + if (isProtectedResourceReentry(arg.request, projectId)) { + throw new Response(null, { status: 508 }); + } + const authRoute = authenticateProjectRequest( arg.request, authRoutes, @@ -263,6 +269,10 @@ export const action = async ({ }: ActionFunctionArgs): Promise< { success: true } | { success: false; errors: string[] } | ManagedFormResponse > => { + if (isProtectedResourceReentry(request, projectId)) { + throw new Response(null, { status: 508 }); + } + authenticateProjectRequest(request, authRoutes, projectDomain); let isManagedFormRequest = false; diff --git a/fixtures/react-router-docker/app/routes/_index.tsx b/fixtures/react-router-docker/app/routes/_index.tsx index 840dca4f76..0ff01dc639 100644 --- a/fixtures/react-router-docker/app/routes/_index.tsx +++ b/fixtures/react-router-docker/app/routes/_index.tsx @@ -1,3 +1,4 @@ +import { isProtectedResourceReentry } from "@webstudio-is/sdk/protected-resource-fetch"; import { type ComponentProps, memo, useMemo } from "react"; import { type MetaFunction, @@ -98,6 +99,11 @@ const customFetch: typeof fetch = (input, init) => { }; export const loader = async (arg: LoaderFunctionArgs) => { + // Node exports have no Worker entrypoint guard; stop requests through aliases here. + if (isProtectedResourceReentry(arg.request, projectId)) { + throw new Response(null, { status: 508 }); + } + const authRoute = authenticateProjectRequest( arg.request, authRoutes, @@ -263,6 +269,10 @@ export const action = async ({ }: ActionFunctionArgs): Promise< { success: true } | { success: false; errors: string[] } | ManagedFormResponse > => { + if (isProtectedResourceReentry(request, projectId)) { + throw new Response(null, { status: 508 }); + } + authenticateProjectRequest(request, authRoutes, projectDomain); let isManagedFormRequest = false; diff --git a/fixtures/react-router-netlify/app/__generated__/$resources.managed-form-fetch.server.ts b/fixtures/react-router-netlify/app/__generated__/$resources.managed-form-fetch.server.ts index 5ddbf5e2e5..7adfddde7b 100644 --- a/fixtures/react-router-netlify/app/__generated__/$resources.managed-form-fetch.server.ts +++ b/fixtures/react-router-netlify/app/__generated__/$resources.managed-form-fetch.server.ts @@ -1,13 +1,15 @@ -import { getDeniedResourceHostnames } from "@webstudio-is/sdk/protected-resource-fetch"; import { createNodeProtectedResourceFetch } from "@webstudio-is/sdk/protected-resource-fetch-node"; export const createManagedFormEmailSender = (_input: { context: unknown; formData: FormData; projectId: string }) => undefined; export const validateManagedFormEmail = (_request: unknown, _formData: FormData) => undefined; export const createManagedFormResourceFetch = ({ request, context, projectDomain }: { request: Request; context: unknown; projectDomain?: string }) => { void context; return createNodeProtectedResourceFetch({ - deniedHostnames: getDeniedResourceHostnames([ + projectId: "d845c167-ea07-4875-b08d-83e97c09dcce", + matchSubdomains: false, + deniedHostnames: [ new URL(request.url).hostname, projectDomain, - ]), + ...["cli-basic-test-d0osr"], + ].filter((hostname): hostname is string => hostname !== undefined), }); }; diff --git a/fixtures/react-router-netlify/app/routes/[another-page]._index.tsx b/fixtures/react-router-netlify/app/routes/[another-page]._index.tsx index 33082a08a6..80a2596921 100644 --- a/fixtures/react-router-netlify/app/routes/[another-page]._index.tsx +++ b/fixtures/react-router-netlify/app/routes/[another-page]._index.tsx @@ -1,3 +1,4 @@ +import { isProtectedResourceReentry } from "@webstudio-is/sdk/protected-resource-fetch"; import { type ComponentProps, memo, useMemo } from "react"; import { type MetaFunction, @@ -98,6 +99,11 @@ const customFetch: typeof fetch = (input, init) => { }; export const loader = async (arg: LoaderFunctionArgs) => { + // Node exports have no Worker entrypoint guard; stop requests through aliases here. + if (isProtectedResourceReentry(arg.request, projectId)) { + throw new Response(null, { status: 508 }); + } + const authRoute = authenticateProjectRequest( arg.request, authRoutes, @@ -263,6 +269,10 @@ export const action = async ({ }: ActionFunctionArgs): Promise< { success: true } | { success: false; errors: string[] } | ManagedFormResponse > => { + if (isProtectedResourceReentry(request, projectId)) { + throw new Response(null, { status: 508 }); + } + authenticateProjectRequest(request, authRoutes, projectDomain); let isManagedFormRequest = false; diff --git a/fixtures/react-router-netlify/app/routes/_index.tsx b/fixtures/react-router-netlify/app/routes/_index.tsx index 840dca4f76..0ff01dc639 100644 --- a/fixtures/react-router-netlify/app/routes/_index.tsx +++ b/fixtures/react-router-netlify/app/routes/_index.tsx @@ -1,3 +1,4 @@ +import { isProtectedResourceReentry } from "@webstudio-is/sdk/protected-resource-fetch"; import { type ComponentProps, memo, useMemo } from "react"; import { type MetaFunction, @@ -98,6 +99,11 @@ const customFetch: typeof fetch = (input, init) => { }; export const loader = async (arg: LoaderFunctionArgs) => { + // Node exports have no Worker entrypoint guard; stop requests through aliases here. + if (isProtectedResourceReentry(arg.request, projectId)) { + throw new Response(null, { status: 508 }); + } + const authRoute = authenticateProjectRequest( arg.request, authRoutes, @@ -263,6 +269,10 @@ export const action = async ({ }: ActionFunctionArgs): Promise< { success: true } | { success: false; errors: string[] } | ManagedFormResponse > => { + if (isProtectedResourceReentry(request, projectId)) { + throw new Response(null, { status: 508 }); + } + authenticateProjectRequest(request, authRoutes, projectDomain); let isManagedFormRequest = false; diff --git a/fixtures/react-router-vercel/app/__generated__/$resources.managed-form-fetch.server.ts b/fixtures/react-router-vercel/app/__generated__/$resources.managed-form-fetch.server.ts index 5ddbf5e2e5..7adfddde7b 100644 --- a/fixtures/react-router-vercel/app/__generated__/$resources.managed-form-fetch.server.ts +++ b/fixtures/react-router-vercel/app/__generated__/$resources.managed-form-fetch.server.ts @@ -1,13 +1,15 @@ -import { getDeniedResourceHostnames } from "@webstudio-is/sdk/protected-resource-fetch"; import { createNodeProtectedResourceFetch } from "@webstudio-is/sdk/protected-resource-fetch-node"; export const createManagedFormEmailSender = (_input: { context: unknown; formData: FormData; projectId: string }) => undefined; export const validateManagedFormEmail = (_request: unknown, _formData: FormData) => undefined; export const createManagedFormResourceFetch = ({ request, context, projectDomain }: { request: Request; context: unknown; projectDomain?: string }) => { void context; return createNodeProtectedResourceFetch({ - deniedHostnames: getDeniedResourceHostnames([ + projectId: "d845c167-ea07-4875-b08d-83e97c09dcce", + matchSubdomains: false, + deniedHostnames: [ new URL(request.url).hostname, projectDomain, - ]), + ...["cli-basic-test-d0osr"], + ].filter((hostname): hostname is string => hostname !== undefined), }); }; diff --git a/fixtures/react-router-vercel/app/routes/[another-page]._index.tsx b/fixtures/react-router-vercel/app/routes/[another-page]._index.tsx index 33082a08a6..80a2596921 100644 --- a/fixtures/react-router-vercel/app/routes/[another-page]._index.tsx +++ b/fixtures/react-router-vercel/app/routes/[another-page]._index.tsx @@ -1,3 +1,4 @@ +import { isProtectedResourceReentry } from "@webstudio-is/sdk/protected-resource-fetch"; import { type ComponentProps, memo, useMemo } from "react"; import { type MetaFunction, @@ -98,6 +99,11 @@ const customFetch: typeof fetch = (input, init) => { }; export const loader = async (arg: LoaderFunctionArgs) => { + // Node exports have no Worker entrypoint guard; stop requests through aliases here. + if (isProtectedResourceReentry(arg.request, projectId)) { + throw new Response(null, { status: 508 }); + } + const authRoute = authenticateProjectRequest( arg.request, authRoutes, @@ -263,6 +269,10 @@ export const action = async ({ }: ActionFunctionArgs): Promise< { success: true } | { success: false; errors: string[] } | ManagedFormResponse > => { + if (isProtectedResourceReentry(request, projectId)) { + throw new Response(null, { status: 508 }); + } + authenticateProjectRequest(request, authRoutes, projectDomain); let isManagedFormRequest = false; diff --git a/fixtures/react-router-vercel/app/routes/_index.tsx b/fixtures/react-router-vercel/app/routes/_index.tsx index 840dca4f76..0ff01dc639 100644 --- a/fixtures/react-router-vercel/app/routes/_index.tsx +++ b/fixtures/react-router-vercel/app/routes/_index.tsx @@ -1,3 +1,4 @@ +import { isProtectedResourceReentry } from "@webstudio-is/sdk/protected-resource-fetch"; import { type ComponentProps, memo, useMemo } from "react"; import { type MetaFunction, @@ -98,6 +99,11 @@ const customFetch: typeof fetch = (input, init) => { }; export const loader = async (arg: LoaderFunctionArgs) => { + // Node exports have no Worker entrypoint guard; stop requests through aliases here. + if (isProtectedResourceReentry(arg.request, projectId)) { + throw new Response(null, { status: 508 }); + } + const authRoute = authenticateProjectRequest( arg.request, authRoutes, @@ -263,6 +269,10 @@ export const action = async ({ }: ActionFunctionArgs): Promise< { success: true } | { success: false; errors: string[] } | ManagedFormResponse > => { + if (isProtectedResourceReentry(request, projectId)) { + throw new Response(null, { status: 508 }); + } + authenticateProjectRequest(request, authRoutes, projectDomain); let isManagedFormRequest = false; diff --git a/fixtures/ssg-cloudflare-pages/app/__generated__/$resources.managed-form-fetch.server.ts b/fixtures/ssg-cloudflare-pages/app/__generated__/$resources.managed-form-fetch.server.ts index 5ddbf5e2e5..3666f08422 100644 --- a/fixtures/ssg-cloudflare-pages/app/__generated__/$resources.managed-form-fetch.server.ts +++ b/fixtures/ssg-cloudflare-pages/app/__generated__/$resources.managed-form-fetch.server.ts @@ -1,13 +1,15 @@ -import { getDeniedResourceHostnames } from "@webstudio-is/sdk/protected-resource-fetch"; import { createNodeProtectedResourceFetch } from "@webstudio-is/sdk/protected-resource-fetch-node"; export const createManagedFormEmailSender = (_input: { context: unknown; formData: FormData; projectId: string }) => undefined; export const validateManagedFormEmail = (_request: unknown, _formData: FormData) => undefined; export const createManagedFormResourceFetch = ({ request, context, projectDomain }: { request: Request; context: unknown; projectDomain?: string }) => { void context; return createNodeProtectedResourceFetch({ - deniedHostnames: getDeniedResourceHostnames([ + projectId: "d845c167-ea07-4875-b08d-83e97c09dcce", + matchSubdomains: false, + deniedHostnames: [ new URL(request.url).hostname, projectDomain, - ]), + ...[], + ].filter((hostname): hostname is string => hostname !== undefined), }); }; diff --git a/fixtures/ssg-netlify-by-project-id/app/__generated__/$resources.managed-form-fetch.server.ts b/fixtures/ssg-netlify-by-project-id/app/__generated__/$resources.managed-form-fetch.server.ts index 5ddbf5e2e5..ab5a34f243 100644 --- a/fixtures/ssg-netlify-by-project-id/app/__generated__/$resources.managed-form-fetch.server.ts +++ b/fixtures/ssg-netlify-by-project-id/app/__generated__/$resources.managed-form-fetch.server.ts @@ -1,13 +1,15 @@ -import { getDeniedResourceHostnames } from "@webstudio-is/sdk/protected-resource-fetch"; import { createNodeProtectedResourceFetch } from "@webstudio-is/sdk/protected-resource-fetch-node"; export const createManagedFormEmailSender = (_input: { context: unknown; formData: FormData; projectId: string }) => undefined; export const validateManagedFormEmail = (_request: unknown, _formData: FormData) => undefined; export const createManagedFormResourceFetch = ({ request, context, projectDomain }: { request: Request; context: unknown; projectDomain?: string }) => { void context; return createNodeProtectedResourceFetch({ - deniedHostnames: getDeniedResourceHostnames([ + projectId: "8a7358b1-7de3-459d-b7b1-56dddfb6ce1e", + matchSubdomains: false, + deniedHostnames: [ new URL(request.url).hostname, projectDomain, - ]), + ...[], + ].filter((hostname): hostname is string => hostname !== undefined), }); }; diff --git a/fixtures/webstudio-cloudflare-template/app/routes/[another-page]._index.tsx b/fixtures/webstudio-cloudflare-template/app/routes/[another-page]._index.tsx index 3d8c4b3a46..f2ab7c4834 100644 --- a/fixtures/webstudio-cloudflare-template/app/routes/[another-page]._index.tsx +++ b/fixtures/webstudio-cloudflare-template/app/routes/[another-page]._index.tsx @@ -1,3 +1,4 @@ +import { isProtectedResourceReentry } from "@webstudio-is/sdk/protected-resource-fetch"; import { type ComponentProps, memo, useMemo } from "react"; import { type ServerRuntimeMetaFunction as MetaFunction, @@ -117,6 +118,11 @@ const customFetch: typeof fetch = (input, init) => { }; export const loader = async (arg: LoaderFunctionArgs) => { + // Node exports have no Worker entrypoint guard; stop requests through aliases here. + if (isProtectedResourceReentry(arg.request, projectId)) { + throw new Response(null, { status: 508 }); + } + const authRoute = authenticateProductionRequest(arg.request); const url = new URL(arg.request.url); @@ -278,6 +284,10 @@ export const action = async ({ }: ActionFunctionArgs): Promise< { success: true } | { success: false; errors: string[] } | ManagedFormResponse > => { + if (isProtectedResourceReentry(request, projectId)) { + throw new Response(null, { status: 508 }); + } + authenticateProductionRequest(request); let isManagedFormRequest = false; diff --git a/fixtures/webstudio-cloudflare-template/app/routes/_index.tsx b/fixtures/webstudio-cloudflare-template/app/routes/_index.tsx index 8da265f3ed..2876d3ecce 100644 --- a/fixtures/webstudio-cloudflare-template/app/routes/_index.tsx +++ b/fixtures/webstudio-cloudflare-template/app/routes/_index.tsx @@ -1,3 +1,4 @@ +import { isProtectedResourceReentry } from "@webstudio-is/sdk/protected-resource-fetch"; import { type ComponentProps, memo, useMemo } from "react"; import { type ServerRuntimeMetaFunction as MetaFunction, @@ -117,6 +118,11 @@ const customFetch: typeof fetch = (input, init) => { }; export const loader = async (arg: LoaderFunctionArgs) => { + // Node exports have no Worker entrypoint guard; stop requests through aliases here. + if (isProtectedResourceReentry(arg.request, projectId)) { + throw new Response(null, { status: 508 }); + } + const authRoute = authenticateProductionRequest(arg.request); const url = new URL(arg.request.url); @@ -278,6 +284,10 @@ export const action = async ({ }: ActionFunctionArgs): Promise< { success: true } | { success: false; errors: string[] } | ManagedFormResponse > => { + if (isProtectedResourceReentry(request, projectId)) { + throw new Response(null, { status: 508 }); + } + authenticateProductionRequest(request); let isManagedFormRequest = false; diff --git a/fixtures/webstudio-features/app/__generated__/$resources.managed-form-fetch.server.ts b/fixtures/webstudio-features/app/__generated__/$resources.managed-form-fetch.server.ts index 5ddbf5e2e5..39990f87b2 100644 --- a/fixtures/webstudio-features/app/__generated__/$resources.managed-form-fetch.server.ts +++ b/fixtures/webstudio-features/app/__generated__/$resources.managed-form-fetch.server.ts @@ -1,13 +1,15 @@ -import { getDeniedResourceHostnames } from "@webstudio-is/sdk/protected-resource-fetch"; import { createNodeProtectedResourceFetch } from "@webstudio-is/sdk/protected-resource-fetch-node"; export const createManagedFormEmailSender = (_input: { context: unknown; formData: FormData; projectId: string }) => undefined; export const validateManagedFormEmail = (_request: unknown, _formData: FormData) => undefined; export const createManagedFormResourceFetch = ({ request, context, projectDomain }: { request: Request; context: unknown; projectDomain?: string }) => { void context; return createNodeProtectedResourceFetch({ - deniedHostnames: getDeniedResourceHostnames([ + projectId: "cddc1d44-af37-4cb6-a430-d300cf6f932d", + matchSubdomains: false, + deniedHostnames: [ new URL(request.url).hostname, projectDomain, - ]), + ...["webstudio-fixture-project-a-0su3o"], + ].filter((hostname): hostname is string => hostname !== undefined), }); }; diff --git a/fixtures/webstudio-features/app/routes/[_route_with_symbols_]._index.tsx b/fixtures/webstudio-features/app/routes/[_route_with_symbols_]._index.tsx index 57332b9c06..3185456027 100644 --- a/fixtures/webstudio-features/app/routes/[_route_with_symbols_]._index.tsx +++ b/fixtures/webstudio-features/app/routes/[_route_with_symbols_]._index.tsx @@ -1,3 +1,4 @@ +import { isProtectedResourceReentry } from "@webstudio-is/sdk/protected-resource-fetch"; import { type ComponentProps, memo, useMemo } from "react"; import { type MetaFunction, @@ -98,6 +99,11 @@ const customFetch: typeof fetch = (input, init) => { }; export const loader = async (arg: LoaderFunctionArgs) => { + // Node exports have no Worker entrypoint guard; stop requests through aliases here. + if (isProtectedResourceReentry(arg.request, projectId)) { + throw new Response(null, { status: 508 }); + } + const authRoute = authenticateProjectRequest( arg.request, authRoutes, @@ -263,6 +269,10 @@ export const action = async ({ }: ActionFunctionArgs): Promise< { success: true } | { success: false; errors: string[] } | ManagedFormResponse > => { + if (isProtectedResourceReentry(request, projectId)) { + throw new Response(null, { status: 508 }); + } + authenticateProjectRequest(request, authRoutes, projectDomain); let isManagedFormRequest = false; diff --git a/fixtures/webstudio-features/app/routes/[animations]._index.tsx b/fixtures/webstudio-features/app/routes/[animations]._index.tsx index f0b75252dd..8891fbecde 100644 --- a/fixtures/webstudio-features/app/routes/[animations]._index.tsx +++ b/fixtures/webstudio-features/app/routes/[animations]._index.tsx @@ -1,3 +1,4 @@ +import { isProtectedResourceReentry } from "@webstudio-is/sdk/protected-resource-fetch"; import { type ComponentProps, memo, useMemo } from "react"; import { type MetaFunction, @@ -98,6 +99,11 @@ const customFetch: typeof fetch = (input, init) => { }; export const loader = async (arg: LoaderFunctionArgs) => { + // Node exports have no Worker entrypoint guard; stop requests through aliases here. + if (isProtectedResourceReentry(arg.request, projectId)) { + throw new Response(null, { status: 508 }); + } + const authRoute = authenticateProjectRequest( arg.request, authRoutes, @@ -263,6 +269,10 @@ export const action = async ({ }: ActionFunctionArgs): Promise< { success: true } | { success: false; errors: string[] } | ManagedFormResponse > => { + if (isProtectedResourceReentry(request, projectId)) { + throw new Response(null, { status: 508 }); + } + authenticateProjectRequest(request, authRoutes, projectDomain); let isManagedFormRequest = false; diff --git a/fixtures/webstudio-features/app/routes/[assets1]._index.tsx b/fixtures/webstudio-features/app/routes/[assets1]._index.tsx index 16384a72ba..ae91103aef 100644 --- a/fixtures/webstudio-features/app/routes/[assets1]._index.tsx +++ b/fixtures/webstudio-features/app/routes/[assets1]._index.tsx @@ -1,3 +1,4 @@ +import { isProtectedResourceReentry } from "@webstudio-is/sdk/protected-resource-fetch"; import { type ComponentProps, memo, useMemo } from "react"; import { type MetaFunction, @@ -98,6 +99,11 @@ const customFetch: typeof fetch = (input, init) => { }; export const loader = async (arg: LoaderFunctionArgs) => { + // Node exports have no Worker entrypoint guard; stop requests through aliases here. + if (isProtectedResourceReentry(arg.request, projectId)) { + throw new Response(null, { status: 508 }); + } + const authRoute = authenticateProjectRequest( arg.request, authRoutes, @@ -263,6 +269,10 @@ export const action = async ({ }: ActionFunctionArgs): Promise< { success: true } | { success: false; errors: string[] } | ManagedFormResponse > => { + if (isProtectedResourceReentry(request, projectId)) { + throw new Response(null, { status: 508 }); + } + authenticateProjectRequest(request, authRoutes, projectDomain); let isManagedFormRequest = false; diff --git a/fixtures/webstudio-features/app/routes/[class-names]._index.tsx b/fixtures/webstudio-features/app/routes/[class-names]._index.tsx index be6c2f5442..e6d71e9293 100644 --- a/fixtures/webstudio-features/app/routes/[class-names]._index.tsx +++ b/fixtures/webstudio-features/app/routes/[class-names]._index.tsx @@ -1,3 +1,4 @@ +import { isProtectedResourceReentry } from "@webstudio-is/sdk/protected-resource-fetch"; import { type ComponentProps, memo, useMemo } from "react"; import { type MetaFunction, @@ -98,6 +99,11 @@ const customFetch: typeof fetch = (input, init) => { }; export const loader = async (arg: LoaderFunctionArgs) => { + // Node exports have no Worker entrypoint guard; stop requests through aliases here. + if (isProtectedResourceReentry(arg.request, projectId)) { + throw new Response(null, { status: 508 }); + } + const authRoute = authenticateProjectRequest( arg.request, authRoutes, @@ -263,6 +269,10 @@ export const action = async ({ }: ActionFunctionArgs): Promise< { success: true } | { success: false; errors: string[] } | ManagedFormResponse > => { + if (isProtectedResourceReentry(request, projectId)) { + throw new Response(null, { status: 508 }); + } + authenticateProjectRequest(request, authRoutes, projectDomain); let isManagedFormRequest = false; diff --git a/fixtures/webstudio-features/app/routes/[content-block]._index.tsx b/fixtures/webstudio-features/app/routes/[content-block]._index.tsx index 5ab84b3de8..00ffdbc8e6 100644 --- a/fixtures/webstudio-features/app/routes/[content-block]._index.tsx +++ b/fixtures/webstudio-features/app/routes/[content-block]._index.tsx @@ -1,3 +1,4 @@ +import { isProtectedResourceReentry } from "@webstudio-is/sdk/protected-resource-fetch"; import { type ComponentProps, memo, useMemo } from "react"; import { type MetaFunction, @@ -98,6 +99,11 @@ const customFetch: typeof fetch = (input, init) => { }; export const loader = async (arg: LoaderFunctionArgs) => { + // Node exports have no Worker entrypoint guard; stop requests through aliases here. + if (isProtectedResourceReentry(arg.request, projectId)) { + throw new Response(null, { status: 508 }); + } + const authRoute = authenticateProjectRequest( arg.request, authRoutes, @@ -263,6 +269,10 @@ export const action = async ({ }: ActionFunctionArgs): Promise< { success: true } | { success: false; errors: string[] } | ManagedFormResponse > => { + if (isProtectedResourceReentry(request, projectId)) { + throw new Response(null, { status: 508 }); + } + authenticateProjectRequest(request, authRoutes, projectDomain); let isManagedFormRequest = false; diff --git a/fixtures/webstudio-features/app/routes/[duration]._index.tsx b/fixtures/webstudio-features/app/routes/[duration]._index.tsx index bf5ffb4da7..2ffdb33695 100644 --- a/fixtures/webstudio-features/app/routes/[duration]._index.tsx +++ b/fixtures/webstudio-features/app/routes/[duration]._index.tsx @@ -1,3 +1,4 @@ +import { isProtectedResourceReentry } from "@webstudio-is/sdk/protected-resource-fetch"; import { type ComponentProps, memo, useMemo } from "react"; import { type MetaFunction, @@ -98,6 +99,11 @@ const customFetch: typeof fetch = (input, init) => { }; export const loader = async (arg: LoaderFunctionArgs) => { + // Node exports have no Worker entrypoint guard; stop requests through aliases here. + if (isProtectedResourceReentry(arg.request, projectId)) { + throw new Response(null, { status: 508 }); + } + const authRoute = authenticateProjectRequest( arg.request, authRoutes, @@ -263,6 +269,10 @@ export const action = async ({ }: ActionFunctionArgs): Promise< { success: true } | { success: false; errors: string[] } | ManagedFormResponse > => { + if (isProtectedResourceReentry(request, projectId)) { + throw new Response(null, { status: 508 }); + } + authenticateProjectRequest(request, authRoutes, projectDomain); let isManagedFormRequest = false; diff --git a/fixtures/webstudio-features/app/routes/[expressions]._index.tsx b/fixtures/webstudio-features/app/routes/[expressions]._index.tsx index c02e16acfa..7b5ae660ab 100644 --- a/fixtures/webstudio-features/app/routes/[expressions]._index.tsx +++ b/fixtures/webstudio-features/app/routes/[expressions]._index.tsx @@ -1,3 +1,4 @@ +import { isProtectedResourceReentry } from "@webstudio-is/sdk/protected-resource-fetch"; import { type ComponentProps, memo, useMemo } from "react"; import { type MetaFunction, @@ -98,6 +99,11 @@ const customFetch: typeof fetch = (input, init) => { }; export const loader = async (arg: LoaderFunctionArgs) => { + // Node exports have no Worker entrypoint guard; stop requests through aliases here. + if (isProtectedResourceReentry(arg.request, projectId)) { + throw new Response(null, { status: 508 }); + } + const authRoute = authenticateProjectRequest( arg.request, authRoutes, @@ -263,6 +269,10 @@ export const action = async ({ }: ActionFunctionArgs): Promise< { success: true } | { success: false; errors: string[] } | ManagedFormResponse > => { + if (isProtectedResourceReentry(request, projectId)) { + throw new Response(null, { status: 508 }); + } + authenticateProjectRequest(request, authRoutes, projectDomain); let isManagedFormRequest = false; diff --git a/fixtures/webstudio-features/app/routes/[form]._index.tsx b/fixtures/webstudio-features/app/routes/[form]._index.tsx index db615b0850..ddf616aca7 100644 --- a/fixtures/webstudio-features/app/routes/[form]._index.tsx +++ b/fixtures/webstudio-features/app/routes/[form]._index.tsx @@ -1,3 +1,4 @@ +import { isProtectedResourceReentry } from "@webstudio-is/sdk/protected-resource-fetch"; import { type ComponentProps, memo, useMemo } from "react"; import { type MetaFunction, @@ -98,6 +99,11 @@ const customFetch: typeof fetch = (input, init) => { }; export const loader = async (arg: LoaderFunctionArgs) => { + // Node exports have no Worker entrypoint guard; stop requests through aliases here. + if (isProtectedResourceReentry(arg.request, projectId)) { + throw new Response(null, { status: 508 }); + } + const authRoute = authenticateProjectRequest( arg.request, authRoutes, @@ -263,6 +269,10 @@ export const action = async ({ }: ActionFunctionArgs): Promise< { success: true } | { success: false; errors: string[] } | ManagedFormResponse > => { + if (isProtectedResourceReentry(request, projectId)) { + throw new Response(null, { status: 508 }); + } + authenticateProjectRequest(request, authRoutes, projectDomain); let isManagedFormRequest = false; diff --git a/fixtures/webstudio-features/app/routes/[head-tag]._index.tsx b/fixtures/webstudio-features/app/routes/[head-tag]._index.tsx index c59bf90c8a..6a6d9ca69c 100644 --- a/fixtures/webstudio-features/app/routes/[head-tag]._index.tsx +++ b/fixtures/webstudio-features/app/routes/[head-tag]._index.tsx @@ -1,3 +1,4 @@ +import { isProtectedResourceReentry } from "@webstudio-is/sdk/protected-resource-fetch"; import { type ComponentProps, memo, useMemo } from "react"; import { type MetaFunction, @@ -98,6 +99,11 @@ const customFetch: typeof fetch = (input, init) => { }; export const loader = async (arg: LoaderFunctionArgs) => { + // Node exports have no Worker entrypoint guard; stop requests through aliases here. + if (isProtectedResourceReentry(arg.request, projectId)) { + throw new Response(null, { status: 508 }); + } + const authRoute = authenticateProjectRequest( arg.request, authRoutes, @@ -263,6 +269,10 @@ export const action = async ({ }: ActionFunctionArgs): Promise< { success: true } | { success: false; errors: string[] } | ManagedFormResponse > => { + if (isProtectedResourceReentry(request, projectId)) { + throw new Response(null, { status: 508 }); + } + authenticateProjectRequest(request, authRoutes, projectDomain); let isManagedFormRequest = false; diff --git a/fixtures/webstudio-features/app/routes/[heading-with-id]._index.tsx b/fixtures/webstudio-features/app/routes/[heading-with-id]._index.tsx index 976533e354..e5753a8c18 100644 --- a/fixtures/webstudio-features/app/routes/[heading-with-id]._index.tsx +++ b/fixtures/webstudio-features/app/routes/[heading-with-id]._index.tsx @@ -1,3 +1,4 @@ +import { isProtectedResourceReentry } from "@webstudio-is/sdk/protected-resource-fetch"; import { type ComponentProps, memo, useMemo } from "react"; import { type MetaFunction, @@ -98,6 +99,11 @@ const customFetch: typeof fetch = (input, init) => { }; export const loader = async (arg: LoaderFunctionArgs) => { + // Node exports have no Worker entrypoint guard; stop requests through aliases here. + if (isProtectedResourceReentry(arg.request, projectId)) { + throw new Response(null, { status: 508 }); + } + const authRoute = authenticateProjectRequest( arg.request, authRoutes, @@ -263,6 +269,10 @@ export const action = async ({ }: ActionFunctionArgs): Promise< { success: true } | { success: false; errors: string[] } | ManagedFormResponse > => { + if (isProtectedResourceReentry(request, projectId)) { + throw new Response(null, { status: 508 }); + } + authenticateProjectRequest(request, authRoutes, projectDomain); let isManagedFormRequest = false; diff --git a/fixtures/webstudio-features/app/routes/[nested].[nested-page]._index.tsx b/fixtures/webstudio-features/app/routes/[nested].[nested-page]._index.tsx index b2a00e190b..3287b89911 100644 --- a/fixtures/webstudio-features/app/routes/[nested].[nested-page]._index.tsx +++ b/fixtures/webstudio-features/app/routes/[nested].[nested-page]._index.tsx @@ -1,3 +1,4 @@ +import { isProtectedResourceReentry } from "@webstudio-is/sdk/protected-resource-fetch"; import { type ComponentProps, memo, useMemo } from "react"; import { type MetaFunction, @@ -98,6 +99,11 @@ const customFetch: typeof fetch = (input, init) => { }; export const loader = async (arg: LoaderFunctionArgs) => { + // Node exports have no Worker entrypoint guard; stop requests through aliases here. + if (isProtectedResourceReentry(arg.request, projectId)) { + throw new Response(null, { status: 508 }); + } + const authRoute = authenticateProjectRequest( arg.request, authRoutes, @@ -263,6 +269,10 @@ export const action = async ({ }: ActionFunctionArgs): Promise< { success: true } | { success: false; errors: string[] } | ManagedFormResponse > => { + if (isProtectedResourceReentry(request, projectId)) { + throw new Response(null, { status: 508 }); + } + authenticateProjectRequest(request, authRoutes, projectDomain); let isManagedFormRequest = false; diff --git a/fixtures/webstudio-features/app/routes/[radix]._index.tsx b/fixtures/webstudio-features/app/routes/[radix]._index.tsx index d53aac7455..85bf44424e 100644 --- a/fixtures/webstudio-features/app/routes/[radix]._index.tsx +++ b/fixtures/webstudio-features/app/routes/[radix]._index.tsx @@ -1,3 +1,4 @@ +import { isProtectedResourceReentry } from "@webstudio-is/sdk/protected-resource-fetch"; import { type ComponentProps, memo, useMemo } from "react"; import { type MetaFunction, @@ -98,6 +99,11 @@ const customFetch: typeof fetch = (input, init) => { }; export const loader = async (arg: LoaderFunctionArgs) => { + // Node exports have no Worker entrypoint guard; stop requests through aliases here. + if (isProtectedResourceReentry(arg.request, projectId)) { + throw new Response(null, { status: 508 }); + } + const authRoute = authenticateProjectRequest( arg.request, authRoutes, @@ -263,6 +269,10 @@ export const action = async ({ }: ActionFunctionArgs): Promise< { success: true } | { success: false; errors: string[] } | ManagedFormResponse > => { + if (isProtectedResourceReentry(request, projectId)) { + throw new Response(null, { status: 508 }); + } + authenticateProjectRequest(request, authRoutes, projectDomain); let isManagedFormRequest = false; diff --git a/fixtures/webstudio-features/app/routes/[resources]._index.tsx b/fixtures/webstudio-features/app/routes/[resources]._index.tsx index d99b4e4be9..196a8c8431 100644 --- a/fixtures/webstudio-features/app/routes/[resources]._index.tsx +++ b/fixtures/webstudio-features/app/routes/[resources]._index.tsx @@ -1,3 +1,4 @@ +import { isProtectedResourceReentry } from "@webstudio-is/sdk/protected-resource-fetch"; import { type ComponentProps, memo, useMemo } from "react"; import { type MetaFunction, @@ -98,6 +99,11 @@ const customFetch: typeof fetch = (input, init) => { }; export const loader = async (arg: LoaderFunctionArgs) => { + // Node exports have no Worker entrypoint guard; stop requests through aliases here. + if (isProtectedResourceReentry(arg.request, projectId)) { + throw new Response(null, { status: 508 }); + } + const authRoute = authenticateProjectRequest( arg.request, authRoutes, @@ -263,6 +269,10 @@ export const action = async ({ }: ActionFunctionArgs): Promise< { success: true } | { success: false; errors: string[] } | ManagedFormResponse > => { + if (isProtectedResourceReentry(request, projectId)) { + throw new Response(null, { status: 508 }); + } + authenticateProjectRequest(request, authRoutes, projectDomain); let isManagedFormRequest = false; diff --git a/fixtures/webstudio-features/app/routes/[sitemap.xml]._index.tsx b/fixtures/webstudio-features/app/routes/[sitemap.xml]._index.tsx index 83656483fb..5a38f6c74c 100644 --- a/fixtures/webstudio-features/app/routes/[sitemap.xml]._index.tsx +++ b/fixtures/webstudio-features/app/routes/[sitemap.xml]._index.tsx @@ -1,3 +1,4 @@ +import { isProtectedResourceReentry } from "@webstudio-is/sdk/protected-resource-fetch"; import { renderToString } from "react-dom/server"; import { type LoaderFunctionArgs, redirect } from "react-router"; import { @@ -13,6 +14,7 @@ import { import { Page, breakpoints, + projectId, projectDomain, } from "../__generated__/[sitemap.xml]._index"; import { @@ -59,6 +61,10 @@ const customFetch: typeof fetch = (input, init) => { }; export const loader = async (arg: LoaderFunctionArgs) => { + if (isProtectedResourceReentry(arg.request, projectId)) { + throw new Response(null, { status: 508 }); + } + const authRoute = authenticateProjectRequest( arg.request, authRoutes, diff --git a/fixtures/webstudio-features/app/routes/[text-duration]._index.tsx b/fixtures/webstudio-features/app/routes/[text-duration]._index.tsx index 3a1dd3f384..721380b1a5 100644 --- a/fixtures/webstudio-features/app/routes/[text-duration]._index.tsx +++ b/fixtures/webstudio-features/app/routes/[text-duration]._index.tsx @@ -1,3 +1,4 @@ +import { isProtectedResourceReentry } from "@webstudio-is/sdk/protected-resource-fetch"; import { type ComponentProps, memo, useMemo } from "react"; import { type MetaFunction, @@ -98,6 +99,11 @@ const customFetch: typeof fetch = (input, init) => { }; export const loader = async (arg: LoaderFunctionArgs) => { + // Node exports have no Worker entrypoint guard; stop requests through aliases here. + if (isProtectedResourceReentry(arg.request, projectId)) { + throw new Response(null, { status: 508 }); + } + const authRoute = authenticateProjectRequest( arg.request, authRoutes, @@ -263,6 +269,10 @@ export const action = async ({ }: ActionFunctionArgs): Promise< { success: true } | { success: false; errors: string[] } | ManagedFormResponse > => { + if (isProtectedResourceReentry(request, projectId)) { + throw new Response(null, { status: 508 }); + } + authenticateProjectRequest(request, authRoutes, projectDomain); let isManagedFormRequest = false; diff --git a/fixtures/webstudio-features/app/routes/_index.tsx b/fixtures/webstudio-features/app/routes/_index.tsx index 840dca4f76..0ff01dc639 100644 --- a/fixtures/webstudio-features/app/routes/_index.tsx +++ b/fixtures/webstudio-features/app/routes/_index.tsx @@ -1,3 +1,4 @@ +import { isProtectedResourceReentry } from "@webstudio-is/sdk/protected-resource-fetch"; import { type ComponentProps, memo, useMemo } from "react"; import { type MetaFunction, @@ -98,6 +99,11 @@ const customFetch: typeof fetch = (input, init) => { }; export const loader = async (arg: LoaderFunctionArgs) => { + // Node exports have no Worker entrypoint guard; stop requests through aliases here. + if (isProtectedResourceReentry(arg.request, projectId)) { + throw new Response(null, { status: 508 }); + } + const authRoute = authenticateProjectRequest( arg.request, authRoutes, @@ -263,6 +269,10 @@ export const action = async ({ }: ActionFunctionArgs): Promise< { success: true } | { success: false; errors: string[] } | ManagedFormResponse > => { + if (isProtectedResourceReentry(request, projectId)) { + throw new Response(null, { status: 508 }); + } + authenticateProjectRequest(request, authRoutes, projectDomain); let isManagedFormRequest = false; diff --git a/packages/cli/src/prebuild.test.ts b/packages/cli/src/prebuild.test.ts index 52524c1b06..96f0151241 100644 --- a/packages/cli/src/prebuild.test.ts +++ b/packages/cli/src/prebuild.test.ts @@ -1131,6 +1131,68 @@ describe("prebuild", () => { } ); + test("allows a sibling API host but blocks the site's hosts in a Node export", async () => { + const siteData = createSiteData(); + siteData.projectDomain = "project.wstd.io"; + await writeFile( + ".webstudio/data.json", + JSON.stringify({ + ...siteData, + build: { + ...siteData.build, + deployment: { + destination: undefined, + domains: ["webstudio-demo-john.stauferth.de"], + }, + }, + }) + ); + await prebuild({ assets: false, template: ["react-router"] }); + await symlink(join(originalCwd, "node_modules"), "node_modules", "dir"); + const bundle = await build({ + entryPoints: [ + join( + tempDir, + "app/__generated__/$resources.managed-form-fetch.server.ts" + ), + ], + bundle: true, + format: "esm", + platform: "node", + conditions: ["webstudio"], + banner: { + js: `import { createRequire } from "node:module"; const require = createRequire(${JSON.stringify(pathToFileURL(join(originalCwd, "package.json")).href)});`, + }, + write: false, + }); + const adapter = (await import( + /* @vite-ignore */ + `data:text/javascript;base64,${Buffer.from(bundle.outputFiles[0].text).toString("base64")}` + )) as { + createManagedFormResourceFetch: (input: { + request: Request; + context: unknown; + projectDomain: string; + }) => typeof fetch & { validateDestination: (url: URL) => void }; + }; + const resourceFetch = adapter.createManagedFormResourceFetch({ + request: new Request("https://webstudio-demo-john.stauferth.de"), + context: {}, + projectDomain: siteData.projectDomain, + }); + expect(() => + resourceFetch.validateDestination(new URL("https://cms.stauferth.de")) + ).not.toThrow(); + for (const hostname of [ + "webstudio-demo-john.stauferth.de", + "project.wstd.io", + ]) { + expect(() => + resourceFetch.validateDestination(new URL(`https://${hostname}`)) + ).toThrow(); + } + }); + test("publishes configured file-input attributes", async () => { await writeSiteData( createSiteData({ diff --git a/packages/cli/src/prebuild.ts b/packages/cli/src/prebuild.ts index ef51eaf665..4a36d6d3a5 100644 --- a/packages/cli/src/prebuild.ts +++ b/packages/cli/src/prebuild.ts @@ -1106,8 +1106,7 @@ export const createManagedFormResourceFetch = ({ request, context, projectDomain }); }; ` - : `import { getDeniedResourceHostnames } from "@webstudio-is/sdk/protected-resource-fetch"; -import { createNodeProtectedResourceFetch } from "@webstudio-is/sdk/protected-resource-fetch-node"; + : `${evaluationTransport === undefined ? "" : 'import { getDeniedResourceHostnames } from "@webstudio-is/sdk/protected-resource-fetch";\n'}import { createNodeProtectedResourceFetch } from "@webstudio-is/sdk/protected-resource-fetch-node"; export const createManagedFormEmailSender = (_input: { context: unknown; formData: FormData; projectId: string }) => undefined; export const validateManagedFormEmail = (_request: unknown, _formData: FormData) => undefined; export const createManagedFormResourceFetch = ({ request, context, projectDomain }: { request: Request; context: unknown; projectDomain?: string }) => { @@ -1115,10 +1114,18 @@ export const createManagedFormResourceFetch = ({ request, context, projectDomain ${ evaluationTransport ?? `return createNodeProtectedResourceFetch({ - deniedHostnames: getDeniedResourceHostnames([ + projectId: ${JSON.stringify(siteData.build.projectId)}, + matchSubdomains: false, + deniedHostnames: [ new URL(request.url).hostname, projectDomain, - ]), + ...${JSON.stringify( + siteData.build.deployment !== undefined && + "domains" in siteData.build.deployment + ? siteData.build.deployment.domains + : [] + )}, + ].filter((hostname): hostname is string => hostname !== undefined), });` } }; diff --git a/packages/cli/templates/defaults/app/route-templates/html.tsx b/packages/cli/templates/defaults/app/route-templates/html.tsx index fb3e5561e1..6813efdbd1 100644 --- a/packages/cli/templates/defaults/app/route-templates/html.tsx +++ b/packages/cli/templates/defaults/app/route-templates/html.tsx @@ -1,3 +1,4 @@ +import { isProtectedResourceReentry } from "@webstudio-is/sdk/protected-resource-fetch"; import { type ComponentProps, memo, useMemo } from "react"; import { type ServerRuntimeMetaFunction as MetaFunction, @@ -117,6 +118,11 @@ const customFetch: typeof fetch = (input, init) => { }; export const loader = async (arg: LoaderFunctionArgs) => { + // Node exports have no Worker entrypoint guard; stop requests through aliases here. + if (isProtectedResourceReentry(arg.request, projectId)) { + throw new Response(null, { status: 508 }); + } + const authRoute = authenticateProductionRequest(arg.request); const url = new URL(arg.request.url); @@ -278,6 +284,10 @@ export const action = async ({ }: ActionFunctionArgs): Promise< { success: true } | { success: false; errors: string[] } | ManagedFormResponse > => { + if (isProtectedResourceReentry(request, projectId)) { + throw new Response(null, { status: 508 }); + } + authenticateProductionRequest(request); let isManagedFormRequest = false; diff --git a/packages/cli/templates/defaults/app/route-templates/text.tsx b/packages/cli/templates/defaults/app/route-templates/text.tsx index e0cb9d07bd..ce02499fcd 100644 --- a/packages/cli/templates/defaults/app/route-templates/text.tsx +++ b/packages/cli/templates/defaults/app/route-templates/text.tsx @@ -1,7 +1,8 @@ +import { isProtectedResourceReentry } from "@webstudio-is/sdk/protected-resource-fetch"; import { type LoaderFunctionArgs, redirect } from "@remix-run/server-runtime"; import { isLocalResource, loadResources, getSystemSearch } from "@webstudio-is/sdk/runtime"; import { authenticateRequest } from "@webstudio-is/wsauth"; -import { projectDomain } from "__CLIENT__"; +import { projectId, projectDomain } from "__CLIENT__"; import { getPageMeta, getRemixParams, getResources } from "__SERVER__"; import { sitemap } from "__SITEMAP__"; import { authRoutes } from "__AUTH__"; @@ -57,6 +58,10 @@ const customFetch: typeof fetch = (input, init) => { }; export const loader = async (arg: LoaderFunctionArgs) => { + if (isProtectedResourceReentry(arg.request, projectId)) { + throw new Response(null, { status: 508 }); + } + const authRoute = authenticateProductionRequest(arg.request); const url = new URL(arg.request.url); diff --git a/packages/cli/templates/defaults/app/route-templates/xml.tsx b/packages/cli/templates/defaults/app/route-templates/xml.tsx index b2ece0ac41..73b6eb4cce 100644 --- a/packages/cli/templates/defaults/app/route-templates/xml.tsx +++ b/packages/cli/templates/defaults/app/route-templates/xml.tsx @@ -1,3 +1,4 @@ +import { isProtectedResourceReentry } from "@webstudio-is/sdk/protected-resource-fetch"; import { renderToString } from "react-dom/server"; import { type LoaderFunctionArgs, redirect } from "@remix-run/server-runtime"; import { isLocalResource, loadResources, getSystemSearch } from "@webstudio-is/sdk/runtime"; @@ -6,7 +7,7 @@ import { ReactSdkContext, xmlNodeTagSuffix, } from "@webstudio-is/react-sdk/runtime"; -import { Page, breakpoints, projectDomain } from "__CLIENT__"; +import { Page, breakpoints, projectId, projectDomain } from "__CLIENT__"; import { getPageMeta, getRemixParams, getResources } from "__SERVER__"; import { assetBaseUrl, imageLoader } from "__CONSTANTS__"; import { sitemap } from "__SITEMAP__"; @@ -65,6 +66,10 @@ const customFetch: typeof fetch = (input, init) => { }; export const loader = async (arg: LoaderFunctionArgs) => { + if (isProtectedResourceReentry(arg.request, projectId)) { + throw new Response(null, { status: 508 }); + } + const authRoute = authenticateProductionRequest(arg.request); const url = new URL(arg.request.url); diff --git a/packages/cli/templates/react-router/app/route-templates/html.tsx b/packages/cli/templates/react-router/app/route-templates/html.tsx index e21bf1942a..9ce67117ba 100644 --- a/packages/cli/templates/react-router/app/route-templates/html.tsx +++ b/packages/cli/templates/react-router/app/route-templates/html.tsx @@ -1,3 +1,4 @@ +import { isProtectedResourceReentry } from "@webstudio-is/sdk/protected-resource-fetch"; import { type ComponentProps, memo, useMemo } from "react"; import { type MetaFunction, @@ -98,6 +99,11 @@ const customFetch: typeof fetch = (input, init) => { }; export const loader = async (arg: LoaderFunctionArgs) => { + // Node exports have no Worker entrypoint guard; stop requests through aliases here. + if (isProtectedResourceReentry(arg.request, projectId)) { + throw new Response(null, { status: 508 }); + } + const authRoute = authenticateProjectRequest( arg.request, authRoutes, @@ -263,6 +269,10 @@ export const action = async ({ }: ActionFunctionArgs): Promise< { success: true } | { success: false; errors: string[] } | ManagedFormResponse > => { + if (isProtectedResourceReentry(request, projectId)) { + throw new Response(null, { status: 508 }); + } + authenticateProjectRequest(request, authRoutes, projectDomain); let isManagedFormRequest = false; diff --git a/packages/cli/templates/react-router/app/route-templates/text.tsx b/packages/cli/templates/react-router/app/route-templates/text.tsx index af6dfab7f2..505cdd3217 100644 --- a/packages/cli/templates/react-router/app/route-templates/text.tsx +++ b/packages/cli/templates/react-router/app/route-templates/text.tsx @@ -1,7 +1,8 @@ +import { isProtectedResourceReentry } from "@webstudio-is/sdk/protected-resource-fetch"; import { type LoaderFunctionArgs, redirect } from "react-router"; import { isLocalResource, loadResources, getSystemSearch } from "@webstudio-is/sdk/runtime"; import { authenticateProjectRequest } from "@webstudio-is/wsauth"; -import { projectDomain } from "__CLIENT__"; +import { projectId, projectDomain } from "__CLIENT__"; import { getPageMeta, getRemixParams, getResources } from "__SERVER__"; import { sitemap } from "__SITEMAP__"; import { authRoutes } from "__AUTH__"; @@ -39,6 +40,10 @@ const customFetch: typeof fetch = (input, init) => { }; export const loader = async (arg: LoaderFunctionArgs) => { + if (isProtectedResourceReentry(arg.request, projectId)) { + throw new Response(null, { status: 508 }); + } + const authRoute = authenticateProjectRequest( arg.request, authRoutes, diff --git a/packages/cli/templates/react-router/app/route-templates/xml.tsx b/packages/cli/templates/react-router/app/route-templates/xml.tsx index 2e812c36fb..be9a0eeb4d 100644 --- a/packages/cli/templates/react-router/app/route-templates/xml.tsx +++ b/packages/cli/templates/react-router/app/route-templates/xml.tsx @@ -1,3 +1,4 @@ +import { isProtectedResourceReentry } from "@webstudio-is/sdk/protected-resource-fetch"; import { renderToString } from "react-dom/server"; import { type LoaderFunctionArgs, redirect } from "react-router"; import { isLocalResource, loadResources, getSystemSearch } from "@webstudio-is/sdk/runtime"; @@ -6,7 +7,7 @@ import { ReactSdkContext, xmlNodeTagSuffix, } from "@webstudio-is/react-sdk/runtime"; -import { Page, breakpoints, projectDomain } from "__CLIENT__"; +import { Page, breakpoints, projectId, projectDomain } from "__CLIENT__"; import { getPageMeta, getRemixParams, getResources } from "__SERVER__"; import { assetBaseUrl, imageLoader } from "__CONSTANTS__"; import { sitemap } from "__SITEMAP__"; @@ -47,6 +48,10 @@ const customFetch: typeof fetch = (input, init) => { }; export const loader = async (arg: LoaderFunctionArgs) => { + if (isProtectedResourceReentry(arg.request, projectId)) { + throw new Response(null, { status: 508 }); + } + const authRoute = authenticateProjectRequest( arg.request, authRoutes, diff --git a/packages/sdk/src/protected-resource-fetch-node.test.ts b/packages/sdk/src/protected-resource-fetch-node.test.ts index 28fd60b998..d8b61788f3 100644 --- a/packages/sdk/src/protected-resource-fetch-node.test.ts +++ b/packages/sdk/src/protected-resource-fetch-node.test.ts @@ -25,6 +25,7 @@ vi.mock("undici", () => ({ })); import { createNodeProtectedResourceFetch } from "./protected-resource-fetch-node"; +import { isProtectedResourceReentry } from "./protected-resource-fetch"; beforeEach(() => { mocks.lookup.mockReset(); @@ -103,6 +104,23 @@ test("pins the vetted address in the connector and retains the URL hostname", as expect(mocks.agents[0].close).toHaveBeenCalledTimes(1); }); +test("marks Node Resource hops with the originating project", async () => { + mocks.lookup.mockResolvedValue([{ address: "8.8.8.8", family: 4 }]); + mocks.fetch.mockResolvedValue(new Response("ok")); + const resourceFetch = createNodeProtectedResourceFetch({ + projectId: "project-a", + deniedHostnames: ["site.example"], + matchSubdomains: false, + }); + + await resourceFetch("https://api.site.example/data"); + const [, init] = mocks.fetch.mock.calls[0]; + const outgoing = new Request("https://api.site.example/data", init); + expect(isProtectedResourceReentry(outgoing, "project-a")).toBe(true); + expect(isProtectedResourceReentry(outgoing, "project-b")).toBe(false); + expect(mocks.agents[0].close).toHaveBeenCalledOnce(); +}); + test("resolves and rejects private DNS answers on a cross-origin redirect", async () => { mocks.lookup .mockResolvedValueOnce([{ address: "8.8.8.8", family: 4 }]) diff --git a/packages/sdk/src/protected-resource-fetch-node.ts b/packages/sdk/src/protected-resource-fetch-node.ts index 19d5827d1c..ad51b50752 100644 --- a/packages/sdk/src/protected-resource-fetch-node.ts +++ b/packages/sdk/src/protected-resource-fetch-node.ts @@ -4,6 +4,7 @@ import ipaddr from "ipaddr.js"; import { Agent, fetch as undiciFetch } from "undici"; import { createProtectedResourceFetch, + protectedResourceRequestHeader, type ProtectedResourceFetch, } from "./protected-resource-fetch"; @@ -60,12 +61,21 @@ const resolvePublicAddress = async (hostname: string) => { */ export const createNodeProtectedResourceFetch = ({ deniedHostnames = [], + matchSubdomains = true, + projectId, }: { deniedHostnames?: readonly string[]; + matchSubdomains?: boolean; + projectId?: string; } = {}): ProtectedResourceFetch => createProtectedResourceFetch({ deniedHostnames, + matchSubdomains, transport: async ({ url, method, headers, body, signal }) => { + if (projectId !== undefined) { + // The generated route checks this ID before loading Resources again. + headers.set(protectedResourceRequestHeader, projectId); + } const resolved = await resolvePublicAddress(url.hostname); const agent = new Agent({ connect: { diff --git a/packages/sdk/src/protected-resource-fetch.ts b/packages/sdk/src/protected-resource-fetch.ts index 94636458c0..f173c0506e 100644 --- a/packages/sdk/src/protected-resource-fetch.ts +++ b/packages/sdk/src/protected-resource-fetch.ts @@ -22,7 +22,7 @@ const redirectStatuses = new Set([301, 302, 303, 307, 308]); const defaultMaxRedirects = 3; const maxRequestBytes = 25 * 1024 * 1024; -const protectedResourceRequestHeader = "x-webstudio-protected-resource"; +export const protectedResourceRequestHeader = "x-webstudio-protected-resource"; /** Project identity survives aliases while allowing calls to other projects. * This cooperative marker is not a sandbox for untrusted code or proxies that strip it. @@ -34,7 +34,7 @@ export const isProtectedResourceReentry = ( projectId !== "" && request.headers.get(protectedResourceRequestHeader) === projectId; -/** Preserve the domain-wide policy for Node and Workers without public routing. */ +/** Domain-wide policy for callers that intentionally restrict an entire zone. */ export const getDeniedResourceHostnames = ( hostnames: readonly (string | undefined)[] ) => From c755578dbe768780b632f7c9db1fcfc587e62fd4 Mon Sep 17 00:00:00 2001 From: Oleg Isonen Date: Tue, 3 Nov 2026 16:46:12 +0000 Subject: [PATCH 2/2] fix: Clarify enabled switch colors (#6631) In dark mode, an available but unchecked Builder switch in the share dialog looked less available than the disabled Editor and Admin switches. The shared Switch now uses the lighter track for available unchecked controls and the darker track for disabled controls. The blue checked color, label emphasis, and permission behavior are unchanged. - [x] Swap available unchecked and disabled Switch track colors - [x] Verify all three switch states in light and dark themes with a browser test - [x] Capture and review light and dark Storybook screenshots of the Switch and share permissions - [x] Review documentation impact (the docs do not specify switch track colors; no update needed) - [x] Run design system and share dialog tests, design system typecheck, targeted lint, formatting, and diff checks - [x] Verify required PR checks, including the approved visual change The browser test failed with the old colors and passed with the change. Design system: 256 tests passed. Share dialog: 11 tests passed. The main workflow passed after an unrelated MCP Email end-to-end test passed on retry. The approved visual regression rerun passed. No data, permissions, fixture inputs, or generated outputs change. The non-test diff swaps two color values with no net line increase. --- .../form-control-contrast.browser.test.ts | 42 +++++++++++++++++++ .../design-system/src/components/switch.tsx | 4 +- 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/packages/design-system/src/components/form-control-contrast.browser.test.ts b/packages/design-system/src/components/form-control-contrast.browser.test.ts index e169d60737..7c75f1cbfe 100644 --- a/packages/design-system/src/components/form-control-contrast.browser.test.ts +++ b/packages/design-system/src/components/form-control-contrast.browser.test.ts @@ -33,6 +33,15 @@ const readColor = (color: string) => { return Array.from(context.getImageData(0, 0, 1, 1).data); }; +const readTokenColor = (name: string) => { + const sample = document.createElement("span"); + sample.style.color = `var(${name})`; + document.body.append(sample); + const color = readColor(getComputedStyle(sample).color); + sample.remove(); + return color; +}; + test("compact form controls use their intended resting treatment", () => { const container = document.createElement("div"); document.body.append(container); @@ -55,6 +64,22 @@ test("compact form controls use their intended resting treatment", () => { { "data-switch": true }, createElement(Switch, { "aria-label": "Switch" }) ), + createElement( + "div", + { "data-disabled-switch": true }, + createElement(Switch, { + "aria-label": "Disabled switch", + disabled: true, + }) + ), + createElement( + "div", + { "data-checked-switch": true }, + createElement(Switch, { + "aria-label": "Checked switch", + checked: true, + }) + ), createElement( "div", { "data-neutral-control": true }, @@ -86,6 +111,23 @@ test("compact form controls use their intended resting treatment", () => { expect( readColor(getComputedStyle(switchControl, "::before").backgroundColor), `${mode} switch resting track` + ).toEqual(readTokenColor("--foreground-disabled")); + const disabledSwitch = container.querySelector( + "[data-disabled-switch] > button" + ); + const checkedSwitch = container.querySelector( + "[data-checked-switch] > button" + ); + if (disabledSwitch === null || checkedSwitch === null) { + throw new Error("Expected disabled and checked switches"); + } + expect( + readColor(getComputedStyle(disabledSwitch, "::before").backgroundColor), + `${mode} disabled switch track` ).toEqual(readColor(getComputedStyle(neutralControl).backgroundColor)); + expect( + readColor(getComputedStyle(checkedSwitch, "::before").backgroundColor), + `${mode} checked switch track` + ).toEqual(readTokenColor("--background-accent")); } }); diff --git a/packages/design-system/src/components/switch.tsx b/packages/design-system/src/components/switch.tsx index ae50d130cc..b4579120f6 100644 --- a/packages/design-system/src/components/switch.tsx +++ b/packages/design-system/src/components/switch.tsx @@ -29,7 +29,7 @@ const switchStyle = css({ position: "absolute", inset: padding, borderRadius: theme.borderRadius.pill, - backgroundColor: neutralControlBackground, + backgroundColor: cssVar("--foreground-disabled"), }, "&[data-state=checked]:not([data-disabled]):before, &[aria-checked=true]:not([data-disabled]):before": @@ -38,7 +38,7 @@ const switchStyle = css({ }, "&[data-disabled]:before": { - backgroundColor: cssVar("--foreground-disabled"), + backgroundColor: neutralControlBackground, }, "&:focus": {