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/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": { 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)[] ) =>