mirror of
https://github.com/coder/coder.git
synced 2026-06-03 04:58:23 +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>
173 lines
4.6 KiB
TypeScript
173 lines
4.6 KiB
TypeScript
import { toast } from "sonner";
|
|
import type {
|
|
Workspace,
|
|
WorkspaceAgent,
|
|
WorkspaceApp,
|
|
} from "#/api/typesGenerated";
|
|
|
|
// This is a magic undocumented string that is replaced
|
|
// with a brand-new session token from the backend.
|
|
// This only exists for external URLs, and should only
|
|
// be used internally, and is highly subject to break.
|
|
export const SESSION_TOKEN_PLACEHOLDER = "$SESSION_TOKEN";
|
|
|
|
// This is a list of external app protocols that we
|
|
// allow to be opened in a new window. This is
|
|
// used to prevent phishing attacks where a user
|
|
// is tricked into clicking a link that opens
|
|
// a malicious app using the Coder session token.
|
|
const ALLOWED_EXTERNAL_APP_PROTOCOLS = [
|
|
"vscode:",
|
|
"vscode-insiders:",
|
|
"windsurf:",
|
|
"cursor:",
|
|
"jetbrains-gateway:",
|
|
"jetbrains:",
|
|
"kiro:",
|
|
"positron:",
|
|
"antigravity:",
|
|
];
|
|
|
|
type GetVSCodeHrefParams = {
|
|
owner: string;
|
|
workspace: string;
|
|
token: string;
|
|
agent?: string;
|
|
folder?: string;
|
|
chatId?: string;
|
|
};
|
|
|
|
export const getVSCodeHref = (
|
|
app: "vscode" | "vscode-insiders" | "cursor",
|
|
{ owner, workspace, token, agent, folder, chatId }: GetVSCodeHrefParams,
|
|
) => {
|
|
const query = new URLSearchParams({
|
|
owner,
|
|
workspace,
|
|
url: location.origin,
|
|
token,
|
|
openRecent: "true",
|
|
});
|
|
if (agent) {
|
|
query.set("agent", agent);
|
|
}
|
|
if (folder) {
|
|
query.set("folder", folder);
|
|
}
|
|
if (chatId) {
|
|
query.set("chatId", chatId);
|
|
}
|
|
return `${app}://coder.coder-remote/open?${query}`;
|
|
};
|
|
|
|
type GetTerminalHrefParams = {
|
|
username: string;
|
|
workspace: string;
|
|
agent?: string;
|
|
container?: string;
|
|
};
|
|
|
|
export const getTerminalHref = ({
|
|
username,
|
|
workspace,
|
|
agent,
|
|
container,
|
|
}: GetTerminalHrefParams) => {
|
|
const params = new URLSearchParams();
|
|
if (container) {
|
|
params.append("container", container);
|
|
}
|
|
// Always use the primary for the terminal link. This is a relative link.
|
|
return `/@${username}/${workspace}${
|
|
agent ? `.${agent}` : ""
|
|
}/terminal?${params}`;
|
|
};
|
|
|
|
// Open `about:blank` first to detect a popup blocker. If it opens, we
|
|
// null out `opener` (durable on the opened window); and navigate `popup`
|
|
// to the target URL. The Coder UI keeps access to `popup`s handle
|
|
export const openAppInNewWindow = (href: string) => {
|
|
const popup = window.open("about:blank", "_blank", "width=900,height=600");
|
|
if (!popup) {
|
|
toast.error("Failed to open app in new window.", {
|
|
description: "Popup blocked. Allow popups to open this app.",
|
|
});
|
|
return;
|
|
}
|
|
try {
|
|
// Setting the opener to null persists in the `popup` window over refresh
|
|
// and navigation. The opening window retains its connection to `popup`
|
|
popup.opener = null;
|
|
} catch {
|
|
// Electron can throw
|
|
}
|
|
popup.location.href = href;
|
|
};
|
|
|
|
type GetAppHrefParams = {
|
|
path: string;
|
|
host: string;
|
|
workspace: Workspace;
|
|
agent: WorkspaceAgent;
|
|
token?: string;
|
|
};
|
|
|
|
export const getAppHref = (
|
|
app: WorkspaceApp,
|
|
{ path, token, workspace, agent, host }: GetAppHrefParams,
|
|
): string => {
|
|
if (isExternalApp(app)) {
|
|
const appProtocol = new URL(app.url).protocol;
|
|
const isAllowedProtocol =
|
|
ALLOWED_EXTERNAL_APP_PROTOCOLS.includes(appProtocol);
|
|
|
|
return needsSessionToken(app) && isAllowedProtocol
|
|
? app.url.replaceAll(SESSION_TOKEN_PLACEHOLDER, token ?? "")
|
|
: app.url;
|
|
}
|
|
|
|
if (app.command) {
|
|
// Pass the app slug instead of the raw command. The terminal
|
|
// page resolves the command from the workspace agent's app
|
|
// list, which avoids exposing the command in the URL and
|
|
// lets us skip the confirmation dialog for trusted,
|
|
// admin-configured template apps.
|
|
return `/@${workspace.owner_name}/${workspace.name}.${
|
|
agent.name
|
|
}/terminal?app=${encodeURIComponent(app.slug)}`;
|
|
}
|
|
|
|
if (host && app.subdomain && app.subdomain_name) {
|
|
const baseUrl = `${window.location.protocol}//${host.replace(/\*/g, app.subdomain_name)}`;
|
|
const url = new URL(baseUrl);
|
|
url.pathname = "/";
|
|
return url.toString();
|
|
}
|
|
|
|
// The backend redirects if the trailing slash isn't included, so we add it
|
|
// here to avoid extra roundtrips.
|
|
return `${path}/@${workspace.owner_name}/${workspace.name}.${
|
|
agent.name
|
|
}/apps/${encodeURIComponent(app.slug)}/`;
|
|
};
|
|
|
|
type ExternalWorkspaceApp = WorkspaceApp & {
|
|
external: true;
|
|
url: string;
|
|
};
|
|
|
|
export const isExternalApp = (
|
|
app: WorkspaceApp,
|
|
): app is ExternalWorkspaceApp => {
|
|
return app.external && app.url !== undefined;
|
|
};
|
|
|
|
export const needsSessionToken = (app: ExternalWorkspaceApp) => {
|
|
// HTTP links should never need the session token, since Cookies
|
|
// handle sharing it when you access the Coder Dashboard. We should
|
|
// never be forwarding the bare session token to other domains!
|
|
const isHttp = app.url.startsWith("http");
|
|
const requiresSessionToken = app.url.includes(SESSION_TOKEN_PLACEHOLDER);
|
|
return requiresSessionToken && !isHttp;
|
|
};
|