fix: Add CSP back to login page (#22688)
* Middleware only logic to add CSP header as Next.js App Router doesnt allow setting header in server component * add tests * Remove nonce from Page Router and fix ts issues * Add checkly test
This commit is contained in:
@@ -1,4 +1,6 @@
|
||||
# Checkly Tests
|
||||
|
||||
Run as `yarn checkly test`
|
||||
Optionally, run as `yarn checkly test --record` to be able to get detailed debugging view in Checkly UI
|
||||
Run the tests for a particylar file as `yarn checkly test {filePattern}`
|
||||
Deploy the tests as `yarn checkly deploy`
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
test.describe("CSP Headers", () => {
|
||||
test("Login page should have CSP header with nonce", async ({ page }) => {
|
||||
const targetUrl = process.env.ENVIRONMENT_URL || "https://app.cal.com";
|
||||
|
||||
const response = await page.goto(`${targetUrl}/auth/login`);
|
||||
|
||||
expect(response?.status()).toBe(200);
|
||||
|
||||
const cspHeader = response?.headers()["content-security-policy"];
|
||||
expect(cspHeader).toBeTruthy();
|
||||
|
||||
// Verify nonce is present in CSP header
|
||||
const nonceMatch = cspHeader?.match(/'nonce-([^']+)'/);
|
||||
expect(nonceMatch).toBeTruthy();
|
||||
expect(nonceMatch![1]).toHaveLength(24);
|
||||
expect(nonceMatch![1]).toMatch(/==$/);
|
||||
|
||||
await page.screenshot({ path: "screenshot.jpg" });
|
||||
});
|
||||
});
|
||||
@@ -4,7 +4,7 @@ import PageWrapper from "@components/PageWrapperAppDir";
|
||||
|
||||
export default async function BookingPageWrapperLayout({ children }: { children: React.ReactNode }) {
|
||||
const h = await headers();
|
||||
const nonce = h.get("x-nonce") ?? undefined;
|
||||
const nonce = h.get("x-csp-nonce") ?? undefined;
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -5,7 +5,7 @@ import PageWrapper from "@components/PageWrapperAppDir";
|
||||
|
||||
export default async function PageWrapperLayout({ children }: { children: React.ReactNode }) {
|
||||
const h = await headers();
|
||||
const nonce = h.get("x-nonce") ?? undefined;
|
||||
const nonce = h.get("x-csp-nonce") ?? undefined;
|
||||
const headScript = process.env.NEXT_PUBLIC_HEAD_SCRIPTS;
|
||||
const bodyScript = process.env.NEXT_PUBLIC_BODY_SCRIPTS;
|
||||
|
||||
|
||||
@@ -96,7 +96,7 @@ const getInitialProps = async () => {
|
||||
|
||||
export default async function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
const h = await headers();
|
||||
const nonce = h.get("x-csp") ?? "";
|
||||
const nonce = h.get("x-csp-nonce") ?? "";
|
||||
|
||||
const { locale, direction, isEmbed, embedColorScheme } = await getInitialProps();
|
||||
|
||||
@@ -151,7 +151,7 @@ export default async function RootLayout({ children }: { children: React.ReactNo
|
||||
]}
|
||||
/>
|
||||
|
||||
<Providers isEmbed={isEmbed}>
|
||||
<Providers isEmbed={isEmbed} nonce={nonce}>
|
||||
<AppRouterI18nProvider translations={translations} locale={locale} ns={ns}>
|
||||
{children}
|
||||
</AppRouterI18nProvider>
|
||||
|
||||
@@ -21,7 +21,7 @@ export const generateMetadata = async () => {
|
||||
|
||||
const ServerPage = async () => {
|
||||
const h = await headers();
|
||||
const nonce = h.get("x-nonce") ?? undefined;
|
||||
const nonce = h.get("x-csp-nonce") ?? undefined;
|
||||
const host = h.get("x-forwarded-host") ?? "";
|
||||
|
||||
return (
|
||||
|
||||
@@ -13,14 +13,15 @@ import PlainChat from "@lib/plain/dynamicProvider";
|
||||
type ProvidersProps = {
|
||||
isEmbed: boolean;
|
||||
children: React.ReactNode;
|
||||
nonce: string | undefined;
|
||||
};
|
||||
export function Providers({ isEmbed, children }: ProvidersProps) {
|
||||
export function Providers({ isEmbed, children, nonce }: ProvidersProps) {
|
||||
const isBookingPage = useIsBookingPage();
|
||||
|
||||
return (
|
||||
<SessionProvider>
|
||||
<TrpcProvider>
|
||||
{!isBookingPage ? <PlainChat /> : null}
|
||||
{!isBookingPage ? <PlainChat nonce={nonce} /> : null}
|
||||
{!isEmbed && !isBookingPage && <NotificationSoundHandler />}
|
||||
{/* @ts-expect-error FIXME remove this comment when upgrading typescript to v5 */}
|
||||
<CacheProvider>
|
||||
|
||||
@@ -44,17 +44,8 @@ function PageWrapper(props: AppProps) {
|
||||
pageStatus = "403";
|
||||
}
|
||||
|
||||
// On client side don't let nonce creep into DOM
|
||||
// It also avoids hydration warning that says that Client has the nonce value but server has "" because browser removes nonce attributes before DOM is built
|
||||
// See https://github.com/kentcdodds/nonce-hydration-issues
|
||||
// Set "" only if server had it set otherwise keep it undefined because server has to match with client to avoid hydration error
|
||||
const nonce = typeof window !== "undefined" ? (pageProps.nonce ? "" : undefined) : pageProps.nonce;
|
||||
const providerProps = {
|
||||
...props,
|
||||
pageProps: {
|
||||
...props.pageProps,
|
||||
nonce,
|
||||
},
|
||||
};
|
||||
// Use the layout defined at the page level, if available
|
||||
const getLayout = Component.getLayout ?? ((page) => page);
|
||||
@@ -79,7 +70,6 @@ function PageWrapper(props: AppProps) {
|
||||
{...seoConfig.defaultNextSeo}
|
||||
/>
|
||||
<Script
|
||||
nonce={nonce}
|
||||
id="page-status"
|
||||
// It is strictly not necessary to disable, but in a future update of react/no-danger this will error.
|
||||
// And we don't want it to error here anyways
|
||||
|
||||
@@ -16,7 +16,6 @@ import { useFlags } from "@calcom/features/flags/hooks";
|
||||
import useIsBookingPage from "@lib/hooks/useIsBookingPage";
|
||||
import useIsThemeSupported from "@lib/hooks/useIsThemeSupported";
|
||||
import type { WithLocaleProps } from "@lib/withLocale";
|
||||
import type { WithNonceProps } from "@lib/withNonce";
|
||||
|
||||
import type { PageWrapperProps } from "@components/PageWrapperAppDir";
|
||||
|
||||
@@ -25,12 +24,11 @@ import { getThemeProviderProps } from "./getThemeProviderProps";
|
||||
// Workaround for https://github.com/vercel/next.js/issues/8592
|
||||
export type AppProps = Omit<
|
||||
NextAppProps<
|
||||
WithLocaleProps<
|
||||
WithNonceProps<{
|
||||
themeBasis?: string;
|
||||
session: Session;
|
||||
}>
|
||||
>
|
||||
WithLocaleProps<{
|
||||
nonce: string | undefined;
|
||||
themeBasis?: string;
|
||||
session: Session;
|
||||
}>
|
||||
>,
|
||||
"Component"
|
||||
> & {
|
||||
|
||||
@@ -20,7 +20,6 @@ import { useFlags } from "@calcom/features/flags/hooks";
|
||||
|
||||
import useIsBookingPage from "@lib/hooks/useIsBookingPage";
|
||||
import type { WithLocaleProps } from "@lib/withLocale";
|
||||
import type { WithNonceProps } from "@lib/withNonce";
|
||||
|
||||
import { useViewerI18n } from "@components/I18nLanguageHandler";
|
||||
|
||||
@@ -33,13 +32,11 @@ const I18nextAdapter = appWithTranslation<
|
||||
// Workaround for https://github.com/vercel/next.js/issues/8592
|
||||
export type AppProps = Omit<
|
||||
NextAppProps<
|
||||
WithLocaleProps<
|
||||
WithNonceProps<{
|
||||
themeBasis?: string;
|
||||
session: Session;
|
||||
i18n?: SSRConfig;
|
||||
}>
|
||||
>
|
||||
WithLocaleProps<{
|
||||
themeBasis?: string;
|
||||
session: Session;
|
||||
i18n?: SSRConfig;
|
||||
}>
|
||||
>,
|
||||
"Component"
|
||||
> & {
|
||||
@@ -72,12 +69,7 @@ const getEmbedNamespace = (query: ParsedUrlQuery) => {
|
||||
return typeof window !== "undefined" ? window.getEmbedNamespace() : (query.embed as string) || null;
|
||||
};
|
||||
|
||||
// We dont need to pass nonce to the i18n provider - this was causing x2-x3 re-renders on a hard refresh
|
||||
type AppPropsWithoutNonce = Omit<AppPropsWithChildren, "pageProps"> & {
|
||||
pageProps: Omit<AppPropsWithChildren["pageProps"], "nonce">;
|
||||
};
|
||||
|
||||
const CustomI18nextProvider = (props: AppPropsWithoutNonce) => {
|
||||
const CustomI18nextProvider = (props: AppPropsWithChildren) => {
|
||||
/**
|
||||
* i18n should never be clubbed with other queries, so that it's caching can be managed independently.
|
||||
**/
|
||||
@@ -141,7 +133,7 @@ const enum ThemeSupport {
|
||||
|
||||
type CalcomThemeProps = PropsWithChildren<
|
||||
Pick<AppProps, "router"> &
|
||||
Pick<AppProps["pageProps"], "nonce" | "themeBasis"> &
|
||||
Pick<AppProps["pageProps"], "themeBasis"> &
|
||||
Pick<AppProps["Component"], "isBookingPage" | "isThemeSupported">
|
||||
>;
|
||||
const CalcomThemeProvider = (props: CalcomThemeProps) => {
|
||||
@@ -253,7 +245,6 @@ function getThemeProviderProps({
|
||||
storageKey,
|
||||
forcedTheme,
|
||||
themeSupport,
|
||||
nonce: props.nonce,
|
||||
enableColorScheme: false,
|
||||
enableSystem: themeSupport !== ThemeSupport.None,
|
||||
// next-themes doesn't listen to changes on storageKey. So we need to force a re-render when storageKey changes
|
||||
@@ -280,24 +271,13 @@ function OrgBrandProvider({ children }: { children: React.ReactNode }) {
|
||||
|
||||
const AppProviders = (props: AppPropsWithChildren) => {
|
||||
const isBookingPage = useIsBookingPage();
|
||||
const { pageProps, ...rest } = props;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { nonce, ...restPageProps } = pageProps;
|
||||
const propsWithoutNonce = {
|
||||
pageProps: {
|
||||
...restPageProps,
|
||||
},
|
||||
...rest,
|
||||
};
|
||||
|
||||
const RemainingProviders = (
|
||||
<EventCollectionProvider options={{ apiPath: "/api/collect-events" }}>
|
||||
<CustomI18nextProvider {...propsWithoutNonce}>
|
||||
<CustomI18nextProvider {...props}>
|
||||
<TooltipProvider>
|
||||
<CalcomThemeProvider
|
||||
themeBasis={props.pageProps.themeBasis}
|
||||
nonce={props.pageProps.nonce}
|
||||
isThemeSupported={props.Component.isThemeSupported}
|
||||
isBookingPage={props.Component.isBookingPage || isBookingPage}
|
||||
router={props.router}>
|
||||
|
||||
+19
-75
@@ -1,6 +1,3 @@
|
||||
import type { IncomingMessage, OutgoingMessage } from "http";
|
||||
import type { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
import { IS_PRODUCTION } from "@calcom/lib/constants";
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
|
||||
@@ -21,7 +18,7 @@ function getCspPolicy(nonce: string) {
|
||||
default-src 'self' ${IS_PRODUCTION ? "" : "data:"};
|
||||
script-src ${
|
||||
IS_PRODUCTION
|
||||
? // 'self' 'unsafe-inline' https: added for Browsers not supporting strict-dynamic not supporting strict-dynamic
|
||||
? // 'self' 'unsafe-inline' https: added for Browsers not supporting strict-dynamic
|
||||
`'nonce-${nonce}' 'strict-dynamic' 'self' 'unsafe-inline' https:`
|
||||
: // Note: We could use 'strict-dynamic' with 'nonce-..' instead of unsafe-inline but there are some streaming related scripts that get blocked(because they don't have nonce on them). It causes a really frustrating full page error model by Next.js to show up sometimes
|
||||
"'unsafe-inline' 'unsafe-eval' https: http:"
|
||||
@@ -38,77 +35,24 @@ function getCspPolicy(nonce: string) {
|
||||
`;
|
||||
}
|
||||
|
||||
// Taken from @next-safe/middleware
|
||||
const isPagePathRequest = (url: URL) => {
|
||||
const isNonPagePathPrefix = /^\/(?:_next|api)\//;
|
||||
const isFile = /\..*$/;
|
||||
const { pathname } = url;
|
||||
return !isNonPagePathPrefix.test(pathname) && !isFile.test(pathname);
|
||||
};
|
||||
|
||||
function safeParseString(value: unknown): { success: boolean; data?: string } {
|
||||
if (typeof value === "string") {
|
||||
return { success: true, data: value };
|
||||
}
|
||||
return { success: false };
|
||||
}
|
||||
|
||||
export function csp(req: IncomingMessage | NextRequest | null, res: OutgoingMessage | NextResponse | null) {
|
||||
if (!req) {
|
||||
return { nonce: undefined };
|
||||
}
|
||||
const existingNonce = "cache" in req ? req.headers.get("x-nonce") : req.headers["x-nonce"];
|
||||
|
||||
if (existingNonce) {
|
||||
const existingNoneParsed = safeParseString(existingNonce);
|
||||
return { nonce: existingNoneParsed.success ? existingNoneParsed.data : "" };
|
||||
}
|
||||
if (!req.url) {
|
||||
return { nonce: undefined };
|
||||
}
|
||||
const CSP_POLICY = process.env.CSP_POLICY;
|
||||
const cspEnabledForInstance = CSP_POLICY;
|
||||
export function getCspNonce() {
|
||||
const nonce = buildNonce(crypto.getRandomValues(new Uint8Array(22)));
|
||||
|
||||
const parsedUrl = new URL(req.url, "http://base_url");
|
||||
const cspEnabledForPage = cspEnabledForInstance && isPagePathRequest(parsedUrl);
|
||||
if (!cspEnabledForPage) {
|
||||
return {
|
||||
nonce: undefined,
|
||||
};
|
||||
}
|
||||
// Set x-nonce request header to be used by `getServerSideProps` or similar fns and `Document.getInitialProps` to read the nonce from
|
||||
// It is generated for all page requests but only used by pages that need CSP
|
||||
|
||||
if ("cache" in req) {
|
||||
req.headers.set("x-nonce", nonce);
|
||||
} else {
|
||||
req.headers["x-nonce"] = nonce;
|
||||
}
|
||||
|
||||
if (res) {
|
||||
const enforced =
|
||||
"cache" in req ? req.headers.get("x-csp-enforce") === "true" : req.headers["x-csp-enforce"] === "true";
|
||||
|
||||
// No need to enable REPORT ONLY mode for CSP unless we start actively working on it. See https://github.com/calcom/cal.com/issues/13844
|
||||
const name = enforced ? "Content-Security-Policy" : /*"Content-Security-Policy-Report-Only"*/ null;
|
||||
|
||||
if (!name) {
|
||||
return {
|
||||
nonce: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
const value = getCspPolicy(nonce)
|
||||
.replace(/\s{2,}/g, " ")
|
||||
.trim();
|
||||
|
||||
if ("body" in res) {
|
||||
res.headers.set(name, value);
|
||||
} else {
|
||||
res.setHeader(name, value);
|
||||
}
|
||||
}
|
||||
|
||||
return { nonce };
|
||||
return nonce;
|
||||
}
|
||||
|
||||
export function getCspHeader({ shouldEnforceCsp, nonce }: { shouldEnforceCsp: boolean; nonce: string }) {
|
||||
const cspHeaderName = shouldEnforceCsp
|
||||
? "Content-Security-Policy"
|
||||
: /*"Content-Security-Policy-Report-Only"*/ null;
|
||||
|
||||
if (!cspHeaderName) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const cspHeaderValue = getCspPolicy(nonce)
|
||||
.replace(/\s{2,}/g, " ")
|
||||
.trim();
|
||||
|
||||
return { name: cspHeaderName, value: cspHeaderValue };
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import dynamic from "next/dynamic";
|
||||
import { Fragment } from "react";
|
||||
|
||||
// Preload caused by dynamic import doesn't seem to add nonce and thus preload fails but the functionality still works - https://github.com/vercel/next.js/issues/81260
|
||||
export default process.env.NEXT_PUBLIC_PLAIN_CHAT_ID ? dynamic(() => import("./plainChat")) : Fragment;
|
||||
|
||||
@@ -80,7 +80,7 @@ interface PlainChatConfig {
|
||||
}
|
||||
|
||||
const PlainChat = IS_PLAIN_CHAT_ENABLED
|
||||
? () => {
|
||||
? ({ nonce }: { nonce: string | undefined }) => {
|
||||
const [config, setConfig] = useState<PlainChatConfig | null>(null);
|
||||
const [isSmallScreen, setIsSmallScreen] = useState(false);
|
||||
const { data: session } = useSession();
|
||||
@@ -297,12 +297,14 @@ const PlainChat = IS_PLAIN_CHAT_ENABLED
|
||||
return (
|
||||
<>
|
||||
<Script
|
||||
nonce={nonce}
|
||||
id="plain-chat"
|
||||
src="https://chat.cdn-plain.com/index.js"
|
||||
strategy="afterInteractive"
|
||||
onLoad={() => window.plainScriptLoaded?.()}
|
||||
/>
|
||||
<Script
|
||||
nonce={nonce}
|
||||
id="plain-chat-init"
|
||||
strategy="afterInteractive"
|
||||
dangerouslySetInnerHTML={{ __html: plainChatScript }}
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
import type { GetServerSideProps } from "next";
|
||||
|
||||
import { csp } from "@lib/csp";
|
||||
|
||||
export type WithNonceProps<T extends Record<string, unknown>> = T & {
|
||||
nonce?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Make any getServerSideProps fn return the nonce so that it can be used by Components in the page to add any script tag.
|
||||
* Note that if the Components are not adding any script tag then this is not needed. Even in absence of this, Document.getInitialProps would be able to generate nonce itself which it needs to add script tags common to all pages
|
||||
* There is no harm in wrapping a `getServerSideProps` fn with this even if it doesn't add any script tag.
|
||||
*/
|
||||
export default function withNonce<T extends Record<string, unknown>>(
|
||||
getServerSideProps: GetServerSideProps<T>
|
||||
): GetServerSideProps<WithNonceProps<T>> {
|
||||
return async (context) => {
|
||||
const ssrResponse = await getServerSideProps(context);
|
||||
|
||||
if (!("props" in ssrResponse)) {
|
||||
return ssrResponse;
|
||||
}
|
||||
|
||||
const { nonce } = csp(context.req, context.res);
|
||||
|
||||
// Skip nonce property if it's not available instead of setting it to undefined because undefined can't be serialized.
|
||||
const nonceProps = nonce
|
||||
? {
|
||||
nonce,
|
||||
}
|
||||
: null;
|
||||
|
||||
// Helps in debugging that withNonce was used but a valid nonce couldn't be set
|
||||
context.res.setHeader("x-csp", nonce ? "ssr" : "false");
|
||||
|
||||
return {
|
||||
...ssrResponse,
|
||||
props: {
|
||||
...ssrResponse.props,
|
||||
...nonceProps,
|
||||
},
|
||||
};
|
||||
};
|
||||
}
|
||||
+443
-1
@@ -1,9 +1,154 @@
|
||||
// Import mocked functions
|
||||
import { get as edgeConfigGet } from "@vercel/edge-config";
|
||||
import { NextRequest } from "next/server";
|
||||
import { describe, it, expect } from "vitest";
|
||||
import type { Mock } from "vitest";
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
|
||||
import { checkPostMethod } from "./middleware";
|
||||
// We'll test the wrapped middleware as it would be used in production
|
||||
import middleware from "./middleware";
|
||||
|
||||
// Mock dependencies at module level
|
||||
vi.mock("@vercel/edge-config", () => ({
|
||||
get: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("next-collect/server", () => ({
|
||||
collectEvents: vi.fn((config: any) => config.middleware),
|
||||
}));
|
||||
|
||||
vi.mock("@calcom/lib/telemetry", () => ({
|
||||
extendEventData: vi.fn(),
|
||||
nextCollectBasicSettings: {},
|
||||
}));
|
||||
|
||||
// Mock NextResponse.json since it's not available in test environment
|
||||
vi.mock("next/server", async () => {
|
||||
const actual = await vi.importActual<typeof import("next/server")>("next/server");
|
||||
|
||||
// Create a NextResponse constructor that returns Response objects
|
||||
const NextResponse = function (body: any, init?: ResponseInit) {
|
||||
return new Response(body, init);
|
||||
};
|
||||
|
||||
// Add static methods
|
||||
NextResponse.json = (body: any, init?: ResponseInit) => {
|
||||
return new Response(JSON.stringify(body), {
|
||||
...init,
|
||||
headers: {
|
||||
...init?.headers,
|
||||
"content-type": "application/json",
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
NextResponse.next = (init?: ResponseInit) => {
|
||||
const response = new Response(null, {
|
||||
status: 200,
|
||||
...init,
|
||||
headers: {
|
||||
...init?.headers,
|
||||
"x-middleware-next": "1",
|
||||
},
|
||||
});
|
||||
|
||||
// Add cookies property
|
||||
(response as any).cookies = {
|
||||
delete: vi.fn(),
|
||||
set: vi.fn(),
|
||||
get: vi.fn(),
|
||||
};
|
||||
|
||||
return response;
|
||||
};
|
||||
|
||||
NextResponse.rewrite = (url: URL | string, init?: ResponseInit) => {
|
||||
const rewriteUrl = typeof url === "string" ? new URL(url) : url;
|
||||
return new Response(null, {
|
||||
status: 200,
|
||||
...init,
|
||||
headers: {
|
||||
...init?.headers,
|
||||
"x-middleware-rewrite": rewriteUrl.toString(),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
NextResponse.redirect = (url: URL | string, statusOrInit?: number | ResponseInit) => {
|
||||
const redirectUrl = typeof url === "string" ? new URL(url) : url;
|
||||
const status = typeof statusOrInit === "number" ? statusOrInit : statusOrInit?.status || 307;
|
||||
const init = typeof statusOrInit === "object" ? statusOrInit : {};
|
||||
|
||||
const response = new Response(null, {
|
||||
...init,
|
||||
status,
|
||||
headers: {
|
||||
...init.headers,
|
||||
location: redirectUrl.toString(),
|
||||
},
|
||||
});
|
||||
|
||||
// Add cookies property
|
||||
(response as any).cookies = {
|
||||
delete: vi.fn(),
|
||||
set: vi.fn(),
|
||||
get: vi.fn(),
|
||||
};
|
||||
|
||||
return response;
|
||||
};
|
||||
|
||||
return {
|
||||
...actual,
|
||||
NextResponse,
|
||||
};
|
||||
});
|
||||
|
||||
const createTestRequest = (overrides?: {
|
||||
url?: string;
|
||||
method?: string;
|
||||
headers?: Record<string, string>;
|
||||
cookies?: Record<string, string>;
|
||||
}) => {
|
||||
const url = overrides?.url || `${WEBAPP_URL}/test-path`;
|
||||
const method = overrides?.method || "GET";
|
||||
const headers = new Headers(overrides?.headers || {});
|
||||
|
||||
const req = new NextRequest(new Request(url, { method, headers }));
|
||||
|
||||
// Add cookies if provided
|
||||
if (overrides?.cookies) {
|
||||
Object.entries(overrides.cookies).forEach(([name, value]) => {
|
||||
req.cookies.set(name, value);
|
||||
});
|
||||
}
|
||||
|
||||
return req;
|
||||
};
|
||||
|
||||
const createEdgeConfigMock = (config: Record<string, any>) => {
|
||||
return (key: string) => {
|
||||
if (key in config) return Promise.resolve(config[key]);
|
||||
return Promise.resolve(undefined);
|
||||
};
|
||||
};
|
||||
|
||||
// Helper to safely get header value
|
||||
const getHeader = (res: Response, name: string) => {
|
||||
return res.headers.get(name);
|
||||
};
|
||||
|
||||
// Helper to check response status
|
||||
const expectStatus = (res: Response, status: number) => {
|
||||
expect(res.status).toBe(status);
|
||||
};
|
||||
|
||||
// Wrapper for middleware calls to handle type casting
|
||||
const callMiddleware = async (req: NextRequest): Promise<Response> => {
|
||||
return (await (middleware as any)(req)) as Response;
|
||||
};
|
||||
|
||||
describe("Middleware - POST requests restriction", () => {
|
||||
const createRequest = (path: string, method: string) => {
|
||||
@@ -41,3 +186,300 @@ describe("Middleware - POST requests restriction", () => {
|
||||
expect(res).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Middleware Integration Tests", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Set test environment
|
||||
vi.stubEnv("NEXT_PUBLIC_WEBAPP_URL", WEBAPP_URL);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("Static File Handling", () => {
|
||||
it("should skip processing for static files", async () => {
|
||||
const staticPaths = ["/images/logo.png", "/_next/static/main.js", "/favicon.ico"];
|
||||
|
||||
for (const path of staticPaths) {
|
||||
const req = createTestRequest({ url: `${WEBAPP_URL}${path}` });
|
||||
const res = await callMiddleware(req);
|
||||
|
||||
expect(res).toBeDefined();
|
||||
expectStatus(res, 200);
|
||||
expect(getHeader(res, "x-middleware-next")).toBe("1");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST Method Protection", () => {
|
||||
it("should allow POST to /api/auth/signup", async () => {
|
||||
const req = createTestRequest({
|
||||
url: `${WEBAPP_URL}/api/auth/signup`,
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
const res = await callMiddleware(req);
|
||||
expectStatus(res, 200);
|
||||
});
|
||||
|
||||
it("should block POST to regular routes", async () => {
|
||||
const req = createTestRequest({
|
||||
url: `${WEBAPP_URL}/team/test`,
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
const res = await callMiddleware(req);
|
||||
expectStatus(res, 405);
|
||||
expect(getHeader(res, "Allow")).toBe("GET");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Maintenance Mode", () => {
|
||||
it("should redirect to maintenance when enabled", async () => {
|
||||
(edgeConfigGet as Mock).mockImplementation(
|
||||
createEdgeConfigMock({
|
||||
isInMaintenanceMode: true,
|
||||
})
|
||||
);
|
||||
|
||||
const req = createTestRequest({
|
||||
url: `${WEBAPP_URL}/team/test`,
|
||||
});
|
||||
|
||||
const res = await callMiddleware(req);
|
||||
expect(res).toBeDefined();
|
||||
expectStatus(res, 200);
|
||||
});
|
||||
|
||||
it("should not affect API routes in maintenance mode", async () => {
|
||||
(edgeConfigGet as Mock).mockImplementation(
|
||||
createEdgeConfigMock({
|
||||
isInMaintenanceMode: true,
|
||||
})
|
||||
);
|
||||
|
||||
const req = createTestRequest({
|
||||
url: `${WEBAPP_URL}/api/bookings`,
|
||||
});
|
||||
|
||||
const res = await callMiddleware(req);
|
||||
expect(res).toBeDefined();
|
||||
expectStatus(res, 200);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Signup Control", () => {
|
||||
it("should block signup when disabled", async () => {
|
||||
(edgeConfigGet as Mock).mockImplementation(
|
||||
createEdgeConfigMock({
|
||||
isSignupDisabled: true,
|
||||
})
|
||||
);
|
||||
|
||||
const req = createTestRequest({
|
||||
url: `${WEBAPP_URL}/api/auth/signup`,
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
const res = await callMiddleware(req);
|
||||
expectStatus(res, 503);
|
||||
|
||||
const body = await res.text();
|
||||
expect(body).toContain("Signup is disabled");
|
||||
});
|
||||
|
||||
it("should allow signup when enabled", async () => {
|
||||
(edgeConfigGet as Mock).mockImplementation(
|
||||
createEdgeConfigMock({
|
||||
isSignupDisabled: false,
|
||||
})
|
||||
);
|
||||
|
||||
const req = createTestRequest({
|
||||
url: `${WEBAPP_URL}/api/auth/signup`,
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
const res = await callMiddleware(req);
|
||||
expectStatus(res, 200);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Cookie Management", () => {
|
||||
it("should handle return-to cookie redirect", async () => {
|
||||
const returnUrl = "/dashboard";
|
||||
const req = createTestRequest({
|
||||
url: `${WEBAPP_URL}/apps/installed`,
|
||||
cookies: { "return-to": returnUrl },
|
||||
});
|
||||
|
||||
const res = await callMiddleware(req);
|
||||
expectStatus(res, 307);
|
||||
expect(getHeader(res, "location")).toContain(returnUrl);
|
||||
});
|
||||
|
||||
it("should not redirect without return-to cookie", async () => {
|
||||
const req = createTestRequest({
|
||||
url: `${WEBAPP_URL}/apps/installed`,
|
||||
});
|
||||
|
||||
const res = await callMiddleware(req);
|
||||
expectStatus(res, 200);
|
||||
});
|
||||
|
||||
it("should delete session cookie on logout", async () => {
|
||||
const req = createTestRequest({
|
||||
url: `${WEBAPP_URL}/auth/logout`,
|
||||
});
|
||||
|
||||
const res = await callMiddleware(req);
|
||||
const setCookie = getHeader(res, "set-cookie");
|
||||
|
||||
if (setCookie) {
|
||||
expect(setCookie).toContain("next-auth.session-token");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Embed Routes", () => {
|
||||
it("should add embed headers", async () => {
|
||||
const req = createTestRequest({
|
||||
url: `${WEBAPP_URL}/team/test/embed`,
|
||||
});
|
||||
|
||||
const res = await callMiddleware(req);
|
||||
expect(getHeader(res, "x-isEmbed")).toBe("true");
|
||||
});
|
||||
|
||||
it("should add COEP header when requested", async () => {
|
||||
const req = createTestRequest({
|
||||
url: `${WEBAPP_URL}/team/test/embed?flag.coep=true`,
|
||||
});
|
||||
|
||||
const res = await callMiddleware(req);
|
||||
expect(getHeader(res, "Cross-Origin-Embedder-Policy")).toBe("require-corp");
|
||||
});
|
||||
|
||||
it("should add color scheme header", async () => {
|
||||
const req = createTestRequest({
|
||||
url: `${WEBAPP_URL}/team/test/embed?ui.color-scheme=dark`,
|
||||
});
|
||||
|
||||
const res = await callMiddleware(req);
|
||||
expect(getHeader(res, "x-embedColorScheme")).toBe("dark");
|
||||
});
|
||||
});
|
||||
|
||||
describe("CSP Headers", () => {
|
||||
beforeEach(() => {
|
||||
vi.stubEnv("CSP_POLICY", "strict");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it("should add CSP headers to login pages", async () => {
|
||||
const req = createTestRequest({
|
||||
url: `${WEBAPP_URL}/auth/login`,
|
||||
});
|
||||
|
||||
const res = await callMiddleware(req);
|
||||
const cspHeader = getHeader(res, "content-security-policy");
|
||||
|
||||
expect(cspHeader).toBeTruthy();
|
||||
expect(cspHeader).toContain("default-src");
|
||||
expect(cspHeader).toContain("script-src");
|
||||
});
|
||||
|
||||
it("should not add CSP headers to non-login pages", async () => {
|
||||
const req = createTestRequest({
|
||||
url: `${WEBAPP_URL}/team/test`,
|
||||
});
|
||||
|
||||
const res = await callMiddleware(req);
|
||||
const cspHeader = getHeader(res, "content-security-policy");
|
||||
|
||||
expect(cspHeader).toBeNull();
|
||||
});
|
||||
|
||||
it("should add x-csp-status when CSP_POLICY not set", async () => {
|
||||
vi.unstubAllEnvs();
|
||||
|
||||
const req = createTestRequest({
|
||||
url: `${WEBAPP_URL}/auth/login`,
|
||||
});
|
||||
|
||||
const res = await callMiddleware(req);
|
||||
expect(getHeader(res, "x-csp-status")).toBe("not-opted-in");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Routing Forms", () => {
|
||||
it("should rewrite legacy routing_forms URLs", async () => {
|
||||
const req = createTestRequest({
|
||||
url: `${WEBAPP_URL}/apps/routing_forms/form-id`,
|
||||
});
|
||||
|
||||
const res = await callMiddleware(req);
|
||||
expect(res).toBeDefined();
|
||||
expectStatus(res, 200);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Multiple Features", () => {
|
||||
it("should handle POST protection before maintenance mode", async () => {
|
||||
(edgeConfigGet as Mock).mockImplementation(
|
||||
createEdgeConfigMock({
|
||||
isInMaintenanceMode: true,
|
||||
})
|
||||
);
|
||||
|
||||
const req = createTestRequest({
|
||||
url: `${WEBAPP_URL}/team/test`,
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
const res = await callMiddleware(req);
|
||||
// POST protection should trigger first
|
||||
expectStatus(res, 405);
|
||||
});
|
||||
|
||||
it("should handle embed route with routing forms rewrite", async () => {
|
||||
const req = createTestRequest({
|
||||
url: `${WEBAPP_URL}/apps/routing_forms/form/embed?ui.color-scheme=light`,
|
||||
});
|
||||
|
||||
const res = await callMiddleware(req);
|
||||
expect(getHeader(res, "x-isEmbed")).toBe("true");
|
||||
expect(getHeader(res, "x-embedColorScheme")).toBe("light");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Error Handling", () => {
|
||||
it("should handle Edge Config errors gracefully", async () => {
|
||||
(edgeConfigGet as Mock).mockImplementation(() => {
|
||||
throw new Error("Config error");
|
||||
});
|
||||
|
||||
const req = createTestRequest({
|
||||
url: `${WEBAPP_URL}/team/test`,
|
||||
});
|
||||
|
||||
const res = await callMiddleware(req);
|
||||
expect(res).toBeDefined();
|
||||
expectStatus(res, 200);
|
||||
});
|
||||
|
||||
it("should handle malformed URLs", async () => {
|
||||
const req = createTestRequest({
|
||||
url: `${WEBAPP_URL}//double-slash`,
|
||||
});
|
||||
|
||||
const res = await callMiddleware(req);
|
||||
expect(res).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+47
-22
@@ -5,7 +5,7 @@ import { NextResponse } from "next/server";
|
||||
|
||||
import { extendEventData, nextCollectBasicSettings } from "@calcom/lib/telemetry";
|
||||
|
||||
import { csp } from "./lib/csp";
|
||||
import { getCspHeader, getCspNonce } from "@lib/csp";
|
||||
|
||||
const safeGet = async <T = any>(key: string): Promise<T | undefined> => {
|
||||
try {
|
||||
@@ -38,6 +38,17 @@ export function checkStaticFiles(pathname: string) {
|
||||
}
|
||||
}
|
||||
|
||||
const isPagePathRequest = (url: URL) => {
|
||||
const isNonPagePathPrefix = /^\/(?:_next|api)\//;
|
||||
const isFile = /\..*$/;
|
||||
const { pathname } = url;
|
||||
return !isNonPagePathPrefix.test(pathname) && !isFile.test(pathname);
|
||||
};
|
||||
|
||||
const shouldEnforceCsp = (url: URL) => {
|
||||
return url.pathname.startsWith("/auth/login") || url.pathname.startsWith("/login");
|
||||
};
|
||||
|
||||
const middleware = async (req: NextRequest): Promise<NextResponse<unknown>> => {
|
||||
const postCheckResult = checkPostMethod(req);
|
||||
if (postCheckResult) return postCheckResult;
|
||||
@@ -46,7 +57,8 @@ const middleware = async (req: NextRequest): Promise<NextResponse<unknown>> => {
|
||||
if (isStaticFile) return isStaticFile;
|
||||
|
||||
const url = req.nextUrl;
|
||||
const requestHeaders = new Headers(req.headers);
|
||||
const reqWithEnrichedHeaders = enrichRequestWithHeaders({ req });
|
||||
const requestHeaders = new Headers(reqWithEnrichedHeaders.headers);
|
||||
|
||||
if (!url.pathname.startsWith("/api")) {
|
||||
//
|
||||
@@ -59,14 +71,14 @@ const middleware = async (req: NextRequest): Promise<NextResponse<unknown>> => {
|
||||
const isInMaintenanceMode = await safeGet<boolean>("isInMaintenanceMode");
|
||||
// If is in maintenance mode, point the url pathname to the maintenance page
|
||||
if (isInMaintenanceMode) {
|
||||
req.nextUrl.pathname = `/maintenance`;
|
||||
return NextResponse.rewrite(req.nextUrl);
|
||||
reqWithEnrichedHeaders.nextUrl.pathname = `/maintenance`;
|
||||
return NextResponse.rewrite(reqWithEnrichedHeaders.nextUrl);
|
||||
}
|
||||
}
|
||||
|
||||
const routingFormRewriteResponse = routingForms.handleRewrite(url);
|
||||
if (routingFormRewriteResponse) {
|
||||
return responseWithHeaders({ url, res: routingFormRewriteResponse, req });
|
||||
return responseWithHeaders({ url, res: routingFormRewriteResponse, req: reqWithEnrichedHeaders });
|
||||
}
|
||||
|
||||
if (url.pathname.startsWith("/api/auth/signup")) {
|
||||
@@ -78,16 +90,13 @@ const middleware = async (req: NextRequest): Promise<NextResponse<unknown>> => {
|
||||
}
|
||||
}
|
||||
|
||||
if (url.pathname.startsWith("/auth/login") || url.pathname.startsWith("/login")) {
|
||||
// Use this header to actually enforce CSP, otherwise it is running in Report Only mode on all pages.
|
||||
requestHeaders.set("x-csp-enforce", "true");
|
||||
}
|
||||
|
||||
if (url.pathname.startsWith("/apps/installed")) {
|
||||
const returnTo = req.cookies.get("return-to");
|
||||
const returnTo = reqWithEnrichedHeaders.cookies.get("return-to");
|
||||
|
||||
if (returnTo?.value) {
|
||||
const response = NextResponse.redirect(new URL(returnTo.value, req.url), { headers: requestHeaders });
|
||||
const response = NextResponse.redirect(new URL(returnTo.value, reqWithEnrichedHeaders.url), {
|
||||
headers: requestHeaders,
|
||||
});
|
||||
response.cookies.delete("return-to");
|
||||
return response;
|
||||
}
|
||||
@@ -103,7 +112,7 @@ const middleware = async (req: NextRequest): Promise<NextResponse<unknown>> => {
|
||||
res.cookies.delete("next-auth.session-token");
|
||||
}
|
||||
|
||||
return responseWithHeaders({ url, res, req });
|
||||
return responseWithHeaders({ url, res, req: reqWithEnrichedHeaders });
|
||||
};
|
||||
|
||||
const routingForms = {
|
||||
@@ -138,18 +147,29 @@ const embeds = {
|
||||
|
||||
const contentSecurityPolicy = {
|
||||
addResponseHeaders: ({ res, req }: { res: NextResponse; req: NextRequest }) => {
|
||||
const { nonce } = csp(req, res ?? null);
|
||||
|
||||
if (!process.env.CSP_POLICY) {
|
||||
res.headers.set("x-csp", "not-opted-in");
|
||||
} else if (!res.headers.get("x-csp")) {
|
||||
// If x-csp not set by gSSP, then it's initialPropsOnly
|
||||
res.headers.set("x-csp", "initialPropsOnly");
|
||||
} else {
|
||||
res.headers.set("x-csp", nonce ?? "");
|
||||
const nonce = req.headers.get("x-csp-nonce");
|
||||
if (!nonce) {
|
||||
res.headers.set("x-csp-status", "not-opted-in");
|
||||
return res;
|
||||
}
|
||||
const cspHeader = getCspHeader({ shouldEnforceCsp: shouldEnforceCsp(req.nextUrl), nonce });
|
||||
if (cspHeader) {
|
||||
res.headers.set(cspHeader.name, cspHeader.value);
|
||||
}
|
||||
return res;
|
||||
},
|
||||
addRequestHeaders: ({ req }: { req: NextRequest }) => {
|
||||
if (!process.env.CSP_POLICY) {
|
||||
return req;
|
||||
}
|
||||
const isCspApplicable = isPagePathRequest(req.nextUrl);
|
||||
if (!isCspApplicable) {
|
||||
return req;
|
||||
}
|
||||
const nonce = getCspNonce();
|
||||
req.headers.set("x-csp-nonce", nonce);
|
||||
return req;
|
||||
},
|
||||
};
|
||||
|
||||
function responseWithHeaders({ url, res, req }: { url: URL; res: NextResponse; req: NextRequest }) {
|
||||
@@ -158,6 +178,11 @@ function responseWithHeaders({ url, res, req }: { url: URL; res: NextResponse; r
|
||||
return resWithEmbeds;
|
||||
}
|
||||
|
||||
function enrichRequestWithHeaders({ req }: { req: NextRequest }) {
|
||||
const reqWithCSP = contentSecurityPolicy.addRequestHeaders({ req });
|
||||
return reqWithCSP;
|
||||
}
|
||||
|
||||
export const config = {
|
||||
// Next.js Doesn't support spread operator in config matcher, so, we must list all paths explicitly here.
|
||||
// https://github.com/vercel/next.js/discussions/42458
|
||||
|
||||
@@ -25,7 +25,6 @@ import { Button } from "@calcom/ui/components/button";
|
||||
import { EmailField, PasswordField } from "@calcom/ui/components/form";
|
||||
|
||||
import type { inferSSRProps } from "@lib/types/inferSSRProps";
|
||||
import type { WithNonceProps } from "@lib/withNonce";
|
||||
|
||||
import AddToHomescreen from "@components/AddToHomescreen";
|
||||
import BackupCode from "@components/auth/BackupCode";
|
||||
@@ -53,8 +52,7 @@ export default function Login({
|
||||
samlTenantID,
|
||||
samlProductID,
|
||||
totpEmail,
|
||||
}: // eslint-disable-next-line @typescript-eslint/ban-types
|
||||
PageProps & WithNonceProps<{}>) {
|
||||
}: PageProps) {
|
||||
const searchParams = useCompatSearchParams();
|
||||
const { t } = useLocale();
|
||||
const router = useRouter();
|
||||
|
||||
@@ -1,35 +1,16 @@
|
||||
import type { IncomingMessage } from "http";
|
||||
import { dir } from "i18next";
|
||||
import type { NextPageContext } from "next";
|
||||
import type { DocumentContext, DocumentProps } from "next/document";
|
||||
import Document, { Head, Html, Main, NextScript } from "next/document";
|
||||
import { z } from "zod";
|
||||
|
||||
import { IS_PRODUCTION } from "@calcom/lib/constants";
|
||||
|
||||
import { csp } from "@lib/csp";
|
||||
|
||||
import { applyTheme } from "./_applyThemeForDocument";
|
||||
|
||||
type Props = Record<string, unknown> & DocumentProps & { newLocale: string };
|
||||
function setHeader(ctx: NextPageContext, name: string, value: string) {
|
||||
try {
|
||||
ctx.res?.setHeader(name, value);
|
||||
} catch (e) {
|
||||
// Getting "Error [ERR_HTTP_HEADERS_SENT]: Cannot set headers after they are sent to the client" when revalidate calendar cache
|
||||
console.log(`Error setting header ${name}=${value} for ${ctx.asPath || "unknown asPath"}`, e);
|
||||
}
|
||||
}
|
||||
|
||||
class MyDocument extends Document<Props> {
|
||||
static async getInitialProps(ctx: DocumentContext) {
|
||||
const { nonce } = csp(ctx.req || null, ctx.res || null);
|
||||
if (!process.env.CSP_POLICY) {
|
||||
setHeader(ctx, "x-csp", "not-opted-in");
|
||||
} else if (!ctx.res?.getHeader("x-csp")) {
|
||||
// If x-csp not set by gSSP, then it's initialPropsOnly
|
||||
setHeader(ctx, "x-csp", "initialPropsOnly");
|
||||
}
|
||||
|
||||
const getLocaleModule = ctx.req ? await import("@calcom/features/auth/lib/getLocale") : null;
|
||||
|
||||
const newLocale =
|
||||
@@ -48,7 +29,7 @@ class MyDocument extends Document<Props> {
|
||||
!isEmbedSnippetGeneratorPath;
|
||||
const embedColorScheme = parsedUrl.searchParams.get("ui.color-scheme");
|
||||
const initialProps = await Document.getInitialProps(ctx);
|
||||
return { isEmbed, embedColorScheme, nonce, ...initialProps, newLocale };
|
||||
return { isEmbed, embedColorScheme, ...initialProps, newLocale };
|
||||
}
|
||||
|
||||
render() {
|
||||
@@ -56,17 +37,13 @@ class MyDocument extends Document<Props> {
|
||||
const newLocale = this.props.newLocale || "en";
|
||||
const newDir = dir(newLocale);
|
||||
|
||||
const nonceParsed = z.string().safeParse(this.props.nonce);
|
||||
const nonce = nonceParsed.success ? nonceParsed.data : "";
|
||||
|
||||
return (
|
||||
<Html
|
||||
lang={newLocale}
|
||||
dir={newDir}
|
||||
style={embedColorScheme ? { colorScheme: embedColorScheme as string } : undefined}>
|
||||
<Head nonce={nonce}>
|
||||
<Head>
|
||||
<script
|
||||
nonce={nonce}
|
||||
id="newLocale"
|
||||
// eslint-disable-next-line react/no-danger
|
||||
dangerouslySetInnerHTML={{
|
||||
@@ -108,7 +85,7 @@ class MyDocument extends Document<Props> {
|
||||
: {}
|
||||
}>
|
||||
<Main />
|
||||
<NextScript nonce={nonce} />
|
||||
<NextScript />
|
||||
</body>
|
||||
</Html>
|
||||
);
|
||||
|
||||
@@ -82,6 +82,15 @@ const workspaces = packagedEmbedTestsOnly
|
||||
name: "@calcom/lib",
|
||||
setupFiles: ["setupVitest.ts"],
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
"@lib": new URL("./apps/web/lib", import.meta.url).pathname,
|
||||
"@server": new URL("./apps/web/server", import.meta.url).pathname,
|
||||
"@components": new URL("./apps/web/components", import.meta.url).pathname,
|
||||
"@pages": new URL("./apps/web/pages", import.meta.url).pathname,
|
||||
"~": new URL("./apps/web/modules", import.meta.url).pathname,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
test: {
|
||||
|
||||
Reference in New Issue
Block a user