mirror of
https://github.com/gravitational/teleport.git
synced 2026-10-12 02:15:01 +00:00
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:
@@ -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
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user