mirror of
https://github.com/coder/coder.git
synced 2026-06-06 06:28:20 +00:00
66abd8a271
The terminal page auto-executed commands from the `?command=` query
parameter
on page load without user confirmation. Because session auth uses
`SameSite=Lax`
cookies, an attacker could craft a link (phishing email, Slack DM,
external page)
that executes arbitrary commands in a victim's workspace when clicked.
Adds a `ConfirmDialog` that shows the exact command and requires
explicit user
approval before it is passed to the terminal WebSocket. Canceling
removes the
`command` parameter from the URL and opens a plain terminal.
<details>
<summary>Implementation details</summary>
### Data flow (before)
`TerminalPage.tsx` reads `searchParams.get("command")` and passes it
directly
as `initialCommand` to `WorkspaceTerminal`, which embeds it in the
WebSocket
URL. `proxy.go` forwards it to the agent, which runs `bash -c
"<command>"`
immediately.
### Fix
- Added `commandConfirmed` state and `commandPendingConfirmation` flag
in
`TerminalPage.tsx`.
- The `loading` prop passed to `WorkspaceTerminal` includes
`commandPendingConfirmation`, keeping the terminal in loading state
until
the user confirms or cancels.
- The command is only passed as `initialCommand` after the user clicks
"Run command" in the confirmation dialog.
- Trusted `?app=` commands (resolved from agent apps) bypass the dialog.
- Cancel removes the `?command=` parameter from the URL entirely.
- No backend changes needed; the frontend gates the command before it
reaches the WebSocket.
### Terminal focus after dialog
`WorkspaceTerminal`'s autoFocus effect previously depended on
`[terminal, isVisible, autoFocus]` but not `loading`. It fired while the
Radix dialog's focus trap was active, so `terminal.focus()` was
intercepted. When `loading` became false after confirming the dialog,
the
effect did not re-fire. Fixed by adding `loading` to the effect deps and
skipping focus while `loading` is true.
### Files changed
| File | Change |
|------|--------|
| `site/src/pages/TerminalPage/TerminalPage.tsx` | Confirmation dialog,
`commandPendingConfirmation` in loading prop |
| `site/src/pages/TerminalPage/TerminalCommandConsentDialog.tsx` | New
dialog component |
| `site/src/pages/TerminalPage/TerminalCommandConsentDialog.stories.tsx`
| Storybook story for dialog |
| `site/src/pages/TerminalPage/TerminalPage.stories.tsx` |
`CommandConfirmation` story |
| `site/src/pages/TerminalPage/TerminalPage.test.tsx` | 4 new dialog
tests, `renderTerminalRaw` helper for non-blocking render |
| `site/src/modules/terminal/WorkspaceTerminal.tsx` | Add `loading` to
autoFocus effect deps |
| `site/e2e/helpers.ts` | Dismiss dialog in `openTerminalWindow` helper
|
| `site/e2e/tests/webTerminal.spec.ts` | Wait for
`data-status="connected"` + click terminal for focus |
</details>
> 🤖 Generated by Coder Agents
---------
Co-authored-by: Jakub Domeracki <jakub@coder.com>
76 lines
1.8 KiB
TypeScript
76 lines
1.8 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
import { test } from "@playwright/test";
|
|
import {
|
|
createTemplate,
|
|
createWorkspace,
|
|
login,
|
|
openTerminalWindow,
|
|
startAgent,
|
|
stopAgent,
|
|
} from "../helpers";
|
|
import { beforeCoderTest } from "../hooks";
|
|
|
|
test.beforeEach(async ({ page }) => {
|
|
beforeCoderTest(page);
|
|
await login(page);
|
|
});
|
|
|
|
test("web terminal", async ({ context, page }) => {
|
|
const token = randomUUID();
|
|
const template = await createTemplate(page, {
|
|
graph: [
|
|
{
|
|
graph: {
|
|
resources: [
|
|
{
|
|
agents: [
|
|
{
|
|
token,
|
|
displayApps: { webTerminal: true },
|
|
order: 0,
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
},
|
|
],
|
|
});
|
|
const workspaceName = await createWorkspace(page, template);
|
|
const agent = await startAgent(page, token);
|
|
const terminal = await openTerminalWindow(page, context, workspaceName);
|
|
|
|
await terminal.waitForSelector('[data-status="connected"]', {
|
|
state: "visible",
|
|
timeout: 30_000,
|
|
});
|
|
|
|
// Wait for xterm to render its row container and click to ensure
|
|
// the terminal has keyboard focus after the confirmation dialog.
|
|
const xtermRows = terminal.locator("div.xterm-rows");
|
|
await xtermRows.waitFor({ state: "visible" });
|
|
await xtermRows.click();
|
|
|
|
// Ensure that we can type in it
|
|
await terminal.keyboard.type("echo he${justabreak}llo123456");
|
|
await terminal.keyboard.press("Enter");
|
|
|
|
// Check if "echo" command was executed
|
|
// try-catch is used temporarily to find the root cause: https://github.com/coder/coder/actions/runs/6176958762/job/16767089943
|
|
try {
|
|
await terminal.waitForSelector(
|
|
'div.xterm-rows span:text-matches("hello123456")',
|
|
{
|
|
state: "visible",
|
|
timeout: 10 * 1000,
|
|
},
|
|
);
|
|
} catch (error) {
|
|
const pageContent = await terminal.content();
|
|
console.error("Unable to find echoed text:", pageContent);
|
|
throw error;
|
|
}
|
|
|
|
await stopAgent(agent);
|
|
});
|