Merge main into Advanced styles branch

This commit is contained in:
Oleg Isonen committed 2026-11-04 10:26:45 +00:00
commit 13e4f35443
46 files changed
+479 -30

No files matched your search

+2
View File
@@ -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.
@@ -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;
@@ -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;
@@ -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),
});
};
@@ -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;
@@ -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;
@@ -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),
});
};
@@ -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;
@@ -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;
@@ -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),
});
};
@@ -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;
@@ -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;
@@ -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),
});
};
@@ -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),
});
};
@@ -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;
@@ -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;
@@ -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),
});
};
@@ -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;
@@ -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;
@@ -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;
@@ -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;
@@ -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;
@@ -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;
@@ -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;
@@ -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;
@@ -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;
@@ -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;
@@ -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;
@@ -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;
@@ -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;
@@ -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,
@@ -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;
@@ -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;
+62
View File
@@ -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({
+11 -4
View File
@@ -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),
});`
}
};
@@ -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;
@@ -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);
@@ -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);
@@ -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;
@@ -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,
@@ -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,
@@ -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"));
}
});
@@ -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": {
@@ -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 }])
@@ -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: {
+2 -2
View File
@@ -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)[]
) =>