Skip to content

osc copy writes OSC 52 to stale $SSH_TTY — silent clipboard leak to other users on shared hosts #19

@schickling-assistant

Description

@schickling-assistant

osc copy (v0.4.8 and main) trusts the SSH_TTY environment variable without checking that it matches the caller's controlling terminal. When SSH_TTY is stale, osc writes the OSC 52 sequence to a different pty than the user's terminal. On a shared/multi-user host that pty often belongs to a different logged-in user, who silently receives the clipboard payload in their terminal emulator (and the original user's clipboard is never updated).

How I hit this

  • macOS client with ~/.ssh/config:
    Host *
      ControlMaster auto
      ControlPath ~/.ssh/sockets/%r@%h-%p
    
  • Multi-user Linux dev box (NixOS, ~4 active SSH users).
  • Inside an SSH session: tty reports /dev/pts/1543, but echo $SSH_TTY reports /dev/pts/0 — the master/first session's tty, which now belongs to a different user.
  • echo hello | osc copy silently sends \x1b]52;c;<base64>\x1b\\ to /dev/pts/0 (the other user's terminal). My local clipboard is never updated.

The same shape of bug applies to mosh-resumed sessions (already noted in #7) and tmux re-attach (#17).

Reproduction

https://github.com/schickling-repros/2026-04-osc-stale-ssh-tty

git clone https://github.com/schickling-repros/2026-04-osc-stale-ssh-tty
cd 2026-04-osc-stale-ssh-tty
./repro.sh

The script forces a stale SSH_TTY (a real but unrelated pts on the same host) and shows that osc copy -v writes OSC 52 to that unrelated pts rather than to the caller's controlling terminal.

Sample output on a host with /dev/pts/1543 as the real tty and /dev/pts/0 available:

Caller's controlling tty: /dev/pts/1543
Stale SSH_TTY (simulated):   /dev/pts/0
...
DEBUG Using tty device: /dev/pts/0
DEBUG tty write: 45 <nil> "\x1b]52;c;b3NjLXNzaC10dHktYnVnLTE3NzcxOTIxNDAK\x1b\\"
osc wrote OSC 52 to: /dev/pts/0

Source

main.go:524-534 (v0.4.8):

func ttyDevice() string {
    if deviceFlag != "" {
        return deviceFlag
    } else if isScreen {
        return "/dev/tty"
    } else if sshtty := os.Getenv("SSH_TTY"); sshtty != "" {
        return sshtty   // trusted blindly
    } else {
        return "/dev/tty"
    }
}

Suggested fix

Either:

  1. Drop the SSH_TTY branch entirely. /dev/tty (the controlling terminal) is correct in all the cases I can construct, including plain SSH, tmux, and screen.
  2. Or stat() SSH_TTY and only use it when it is owned by the caller and matches the controlling terminal. Otherwise fall back to /dev/tty.

Option (1) is the simpler change and would also subsume #17. Happy to send a PR if you'd like.

Workarounds for users

  • osc copy -d /dev/tty
  • SSH_TTY= osc copy

Versions

  • osc: 0.4.8 (also reproduces on main as of 2026-04-26)
  • OS: NixOS, Linux 6.18, multi-user
  • Client: macOS 25.2, OpenSSH with ControlMaster auto

Related


Filed by an AI assistant on behalf of @schickling

Posted on behalf of @schickling
field value
agent_name 🐤 cl1-finch
agent_session_id fb1c07ee-69fb-4b7d-a948-95f8a524e8c5
agent_tool Claude Code
agent_tool_version 2.1.118 (Claude Code)
agent_runtime Claude Code 2.1.118 (Claude Code)
agent_model claude-opus-4-7
worktree dotfiles/schickling/2026-04-26-misc
machine mbp2025
tooling_profile dotfiles@cda3c8e

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions