diff --git a/.gitignore b/.gitignore index 1ade5a28c..8bea82447 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ *.untracked.* node-compile-cache/ + *.cpuprofile diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/domains/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/domains/page-client.tsx index eeb48166b..035f82c5a 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/domains/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/domains/page-client.tsx @@ -3,14 +3,15 @@ import { FormDialog } from "@/components/form-dialog"; import { InputField, SwitchField } from "@/components/form-fields"; import { SettingCard, SettingSwitch } from "@/components/settings"; import { AdminDomainConfig, AdminProject } from "@stackframe/stack"; -import { urlSchema } from "@stackframe/stack-shared/dist/schema-fields"; -import { isValidUrl } from "@stackframe/stack-shared/dist/utils/urls"; +import { createUrlIfValid, isValidUrl } from "@stackframe/stack-shared/dist/utils/urls"; import { Accordion, AccordionContent, AccordionItem, AccordionTrigger, ActionCell, ActionDialog, Alert, Button, Table, TableBody, TableCell, TableHead, TableHeader, TableRow, Typography } from "@stackframe/stack-ui"; import React from "react"; import * as yup from "yup"; import { PageLayout } from "../page-layout"; import { useAdminApp } from "../use-admin-app"; +const DOMAIN_REGEX = /^(((?!-))(xn--|_)?[a-z0-9-]{0,61}[a-z0-9]{1,1}\.)*(xn--)?([a-z0-9][a-z0-9\-]{0,60}|[a-z0-9-]{1,30}\.[a-z]{2,})$/; + function EditDialog(props: { open?: boolean, onOpenChange?: (open: boolean) => void, @@ -30,9 +31,13 @@ function EditDialog(props: { } )) { const domainFormSchema = yup.object({ - domain: urlSchema - .url("Invalid URL") - .transform((value) => 'https://' + value) + domain: yup.string() + .test('is-domain', "Invalid Domain", (domain) => { + if (!domain) { + return true; + } + const urlIfValid = createUrlIfValid(`https://${domain}`); + return !!urlIfValid && urlIfValid.hostname === domain; }) .notOneOf( props.domains .filter((_, i) => (props.type === 'update' && i !== props.editIndex) || props.type === 'create') @@ -44,6 +49,7 @@ function EditDialog(props: { .matches(/^\//, "Handler path must start with /") .defined(), addWww: yup.boolean(), + allowInsecureHttp: yup.boolean(), }); const canAddWww = (domain: string | undefined) => { @@ -70,6 +76,7 @@ function EditDialog(props: { addWww: props.type === 'create', domain: props.type === 'update' ? props.defaultDomain.replace(/^https:\/\//, "") : undefined, handlerPath: props.type === 'update' ? props.defaultHandlerPath : "/handler", + allowInsecureHttp: false, }} onOpenChange={props.onOpenChange} trigger={props.trigger} @@ -83,11 +90,11 @@ function EditDialog(props: { domains: [ ...props.domains, { - domain: values.domain, + domain: (values.allowInsecureHttp ? 'http' : 'https') + `://` + values.domain, handlerPath: values.handlerPath, }, - ...(canAddWww(values.domain.slice(8)) && values.addWww ? [{ - domain: 'https://www.' + values.domain.slice(8), + ...(canAddWww(values.domain) && values.addWww ? [{ + domain: `${values.allowInsecureHttp ? 'http' : 'https'}://www.` + values.domain, handlerPath: values.handlerPath, }] : []), ], @@ -118,7 +125,7 @@ function EditDialog(props: { label="Domain" name="domain" control={form.control} - prefixItem='https://' + prefixItem={form.getValues('allowInsecureHttp') ? 'http://' : 'https://'} placeholder='example.com' /> @@ -144,6 +151,16 @@ function EditDialog(props: { only modify this if you changed the default handler path in your app +
+ +
+ + Warning: HTTP domains are insecure and should only be used for development / internal networks. + diff --git a/apps/e2e/tests/backend/endpoints/api/v1/projects.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/projects.test.ts index ddfb1fe88..dcec6e653 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/projects.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/projects.test.ts @@ -274,6 +274,106 @@ it("is not allowed to have two identical domains", async ({ expect }) => { `); }); +it("should allow insecure HTTP domains", async ({ expect }) => { + await Auth.Otp.signIn(); + const { adminAccessToken } = await Project.createAndGetAdminToken(); + const { updateProjectResponse: response } = await Project.updateCurrent(adminAccessToken, { + config: { + domains: [{ + domain: 'http://insecure-domain.stack-test.example.com', + handler_path: '/handler' + }] + }, + }); + expect(response).toMatchInlineSnapshot(` + NiceResponse { + "status": 200, + "body": { + "config": { + "allow_localhost": true, + "client_team_creation_enabled": false, + "client_user_deletion_enabled": false, + "create_team_on_sign_up": false, + "credential_enabled": true, + "domains": [ + { + "domain": "http://insecure-domain.stack-test.example.com", + "handler_path": "/handler", + }, + ], + "email_config": { "type": "shared" }, + "enabled_oauth_providers": [], + "id": "", + "legacy_global_jwt_signing": false, + "magic_link_enabled": false, + "oauth_providers": [], + "passkey_enabled": false, + "sign_up_enabled": true, + "team_creator_default_permissions": [{ "id": "admin" }], + "team_member_default_permissions": [{ "id": "member" }], + }, + "created_at_millis": , + "description": "", + "display_name": "New Project", + "id": "", + "is_production_mode": false, + "user_count": 0, + }, + "headers": Headers {