block copying in the web terminal if role option is present (#66944)

* block copying in the web terminal if role option is present

* fix tests
This commit is contained in:
Yassine Bounekhla
2026-05-29 18:47:20 +00:00
committed by GitHub
parent ee94d298fa
commit 0ec35fb646
12 changed files with 239 additions and 3 deletions
+38
View File
@@ -44,4 +44,42 @@ export class TerminalPage {
timeout: TERMINAL_TIMEOUT,
});
}
/**
* Selects all of the text rendered in the terminal and copies it.
*/
async copyAllText() {
await this.page.evaluate(() => {
const xterm = document.querySelector('.xterm');
if (!xterm) {
throw new Error('xterm element not found');
}
// Blur the active element to avoid copying from the hidden textarea xterm creates for capturing keystrokes
(document.activeElement as HTMLElement | null)?.blur();
const selection = window.getSelection();
if (!selection) {
throw new Error('window.getSelection() returned null');
}
const range = document.createRange();
range.selectNodeContents(xterm);
selection.removeAllRanges();
selection.addRange(range);
});
await this.page.keyboard.press('ControlOrMeta+C');
}
async writeClipboard(text: string) {
await this.page.evaluate(
value => navigator.clipboard.writeText(value),
text
);
}
async readClipboard(): Promise<string> {
return this.page.evaluate(() => navigator.clipboard.readText());
}
}
+13
View File
@@ -0,0 +1,13 @@
kind: role
metadata:
name: web-terminal-no-copy
spec:
allow:
logins:
- root
node_labels:
'*': '*'
options:
max_session_ttl: 8h0m0s
web_terminal_clipboard_mode: no-copy
version: v7
@@ -0,0 +1,104 @@
/**
* Teleport
* Copyright (C) 2026 Gravitational, Inc.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { expect, test } from '@gravitational/e2e/helpers/test';
// Text in the terminal that the test will attempt to copy to the clipboard
const textToCopy = 'text-to-copy';
// Text that is pre-populated in the clipboard before the test's copy attempt
const preCopiedText = 'pre-copied-text';
test.describe('web terminal clipboard mode', () => {
// The clipboard-read/clipboard-write permissions and readText() method
// needed for this test only work on Chromium, so only run this test for Chromium.
test.skip(
({ browserName }) => browserName !== 'chromium',
'clipboard permissions and readText() are Chromium-only in Playwright'
);
test.use({
fixtures: ['ssh-node'],
permissions: ['clipboard-read', 'clipboard-write'],
});
test.describe('with web_terminal_clipboard_mode: no-copy', () => {
test.use({
user: {
roles: [
'access',
'editor',
{ file: '@gravitational/e2e/roles/web-terminal-no-copy.yaml' },
],
},
});
test('cannot copy terminal text to the clipboard', async ({
unifiedResourcesPage,
}) => {
await unifiedResourcesPage.goto();
const terminal = await unifiedResourcesPage.connect(
'docker-node',
'root'
);
await terminal.waitForReady();
await terminal.exec(`echo ${textToCopy}`);
await terminal.waitForText(textToCopy);
// Pre-populate the clipboard with the preCopiedText
await terminal.writeClipboard(preCopiedText);
await terminal.copyAllText();
const clipboard = await terminal.readClipboard();
expect(clipboard).toBe(preCopiedText);
expect(clipboard).not.toContain(textToCopy);
});
});
test.describe('with unrestricted clipboard', () => {
test.use({
user: {
roles: ['access', 'editor'],
},
});
test('can copy terminal text to the clipboard', async ({
unifiedResourcesPage,
}) => {
await unifiedResourcesPage.goto();
const terminal = await unifiedResourcesPage.connect(
'docker-node',
'root'
);
await terminal.waitForReady();
await terminal.exec(`echo ${textToCopy}`);
await terminal.waitForText(textToCopy);
// Reset the clipboard so we know the copy is what populated it.
await terminal.writeClipboard('');
await terminal.copyAllText();
await expect.poll(() => terminal.readClipboard()).toContain(textToCopy);
});
});
});
@@ -51,6 +51,7 @@ export default function DocumentSshWrapper(props: PropTypes) {
function DocumentSsh({ doc, visible }: PropTypes) {
const ctx = useConsoleContext();
const hasFileTransferAccess = ctx.storeUser.hasFileTransferAccess();
const disableCopy = ctx.storeUser.isWebTerminalCopyBlocked();
const terminalRef = useRef<TerminalRef>(undefined);
const { tty, status, closeDocument, session } = useSshSession(doc);
const [showSearch, setShowSearch] = useState(false);
@@ -102,6 +103,7 @@ function DocumentSsh({ doc, visible }: PropTypes) {
tty={tty}
fontFamily={theme.fonts.mono}
theme={theme.colors.terminal}
disableCopy={disableCopy}
terminalAddons={ref => (
<>
<TerminalSearch
@@ -45,6 +45,8 @@ export interface TerminalProps {
terminalAddons?: (terminalRef: XTermCtrl) => React.JSX.Element;
disableCtrlC?: boolean;
disableAutoFocus?: boolean;
// disableCopy blocks copying terminal text to clipboard.
disableCopy?: boolean;
}
export const Terminal = forwardRef<TerminalRef, TerminalProps>((props, ref) => {
@@ -69,6 +71,7 @@ export const Terminal = forwardRef<TerminalRef, TerminalProps>((props, ref) => {
fontSize,
theme: props.theme,
convertEol: props.convertEol,
disableCopy: props.disableCopy,
});
termCtrlRef.current = termCtrl;
@@ -51,7 +51,43 @@ describe('Alt+Arrow sends escape sequences for word navigation', () => {
});
});
function createTerminal() {
describe('copy blocking', () => {
it('blocks copying when disableCopy is true', () => {
const { terminal, el } = createTerminal({ disableCopy: true });
expect(dispatchCopy(el).defaultPrevented).toBe(true);
terminal.destroy();
});
it('allows copying when disableCopy is not set', () => {
const { terminal, el } = createTerminal();
expect(dispatchCopy(el).defaultPrevented).toBe(false);
terminal.destroy();
});
it('runs before a descendant copy handler (capture phase)', () => {
const { terminal, el } = createTerminal({ disableCopy: true });
const descendant = document.createElement('div');
el.appendChild(descendant);
const descendantHandler = jest.fn();
descendant.addEventListener('copy', descendantHandler);
expect(dispatchCopy(descendant).defaultPrevented).toBe(true);
expect(descendantHandler).not.toHaveBeenCalled();
terminal.destroy();
});
it('stops blocking after the terminal is destroyed', () => {
const { terminal, el } = createTerminal({ disableCopy: true });
terminal.destroy();
expect(dispatchCopy(el).defaultPrevented).toBe(false);
});
});
function createTerminal(options: { disableCopy?: boolean } = {}) {
const el = document.createElement('div');
document.body.appendChild(el);
@@ -61,12 +97,19 @@ function createTerminal() {
const terminal = new TtyTerminal(tty, {
el,
theme: {},
...options,
});
terminal.open();
return { terminal, sendFn, el };
}
function dispatchCopy(el: HTMLElement) {
const event = new Event('copy', { bubbles: true, cancelable: true });
el.dispatchEvent(event);
return event;
}
function dispatchKeydown(
terminal: TtyTerminal,
opts: { key: string; altKey?: boolean; ctrlKey?: boolean }
+19 -1
View File
@@ -61,11 +61,18 @@ export default class TtyTerminal implements TerminalSearcher {
private customKeyEventHandlers = new Set<(event: KeyboardEvent) => boolean>();
private _disableCopy: boolean;
private _blockCopy = (e: ClipboardEvent) => {
e.preventDefault();
e.stopImmediatePropagation();
};
constructor(
tty: Tty,
private options: Options
) {
const { el, scrollBack, fontFamily, fontSize, convertEol } = options;
const { el, scrollBack, fontFamily, fontSize, convertEol, disableCopy } =
options;
this._el = el;
this._fontFamily = fontFamily || undefined;
this._fontSize = fontSize || 14;
@@ -73,6 +80,7 @@ export default class TtyTerminal implements TerminalSearcher {
// Default to the config when not passed anything, which is the normal usecase
this._scrollBack = scrollBack || cfg.ui.scrollbackLines;
this._convertEol = convertEol || false;
this._disableCopy = !!disableCopy;
this.tty = tty;
this.term = null;
@@ -135,6 +143,11 @@ export default class TtyTerminal implements TerminalSearcher {
this.term.open(this._el);
this._fitAddon.fit();
if (this._disableCopy) {
// Intercept copy events if disableCopy is true to block copying to the clipboard.
this._el.addEventListener('copy', this._blockCopy, true);
}
this.term.onData(data => {
this.tty.send(data);
});
@@ -222,6 +235,9 @@ export default class TtyTerminal implements TerminalSearcher {
this._imageAddon?.dispose();
this._searchAddon?.dispose();
this._webglAddon?.dispose();
if (this._disableCopy) {
this._el.removeEventListener('copy', this._blockCopy, true);
}
this._el.innerHTML = null;
this.term?.dispose();
@@ -306,4 +322,6 @@ type Options = {
fontFamily?: string;
fontSize?: number;
convertEol?: boolean;
// disableCopy blocks copying terminal text to clipboard.
disableCopy?: boolean;
};
@@ -60,6 +60,7 @@ export const allAccessAcl: Acl = {
directorySharingEnabled: true,
reviewRequests: true,
fileTransferAccess: true,
webTerminalClipboardMode: '',
license: fullAccess,
download: fullAccess,
plugins: fullAccess,
@@ -16,7 +16,7 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { Acl } from './types';
import { Acl, WebTerminalClipboardMode } from './types';
export function makeAcl(json): Acl {
json = json || {};
@@ -45,6 +45,8 @@ export function makeAcl(json): Acl {
// doesn't return the valid permission. If they don't have access, the action will
// still fail with an error, so this is merely a UX improvment.
const fileTransferAccess = json.fileTransferAccess ?? true; // use nullish coalescing to prevent default from overriding a strictly false value
const webTerminalClipboardMode: WebTerminalClipboardMode =
json.webTerminalClipboardMode ?? '';
const connectionDiagnostic = json.connectionDiagnostic || defaultAccess;
// Defaults to true, see RFD 0049
// https://github.com/gravitational/teleport/blob/master/rfd/0049-desktop-clipboard.md#security
@@ -142,6 +144,7 @@ export function makeAcl(json): Acl {
discoverConfigs,
contacts,
fileTransferAccess,
webTerminalClipboardMode,
gitServers,
accessGraphSettings,
botInstances,
@@ -21,6 +21,8 @@ import { MfaChallengeResponse } from 'teleport/services/mfa';
export type AuthType = 'local' | 'sso' | 'passwordless';
export type WebTerminalClipboardMode = '' | 'unrestricted' | 'no-copy';
export interface AccessStrategy {
type: 'optional' | 'always' | 'reason';
prompt: string;
@@ -110,6 +112,10 @@ export interface Acl {
accessMonitoringRule: Access;
contacts: Access;
fileTransferAccess: boolean;
/**
* webTerminalClipboardMode determines clipboard behavior in the Web UI terminal.
*/
webTerminalClipboardMode: WebTerminalClipboardMode;
gitServers: Access;
accessGraphSettings: Access;
botInstances: Access;
@@ -296,6 +296,7 @@ test('undefined values in context response gives proper default values', async (
desktopSessionRecordingEnabled: true,
directorySharingEnabled: true,
fileTransferAccess: true,
webTerminalClipboardMode: '',
gitServers: {
list: false,
read: false,
@@ -172,6 +172,10 @@ export default class StoreUserContext extends Store<UserContext> {
return this.state.acl.fileTransferAccess;
}
isWebTerminalCopyBlocked() {
return this.state.acl.webTerminalClipboardMode === 'no-copy';
}
// hasPrereqAccessToAddAgents checks if user meets the prerequisite
// access to add an agent:
// - user should be able to create provisioning tokens