Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,19 @@
# SSH Term Release Notes

## Next

### :lock: Security fixes

* Upgrade `github.com/pkg/sftp` to `v1.13.12` (fixing truncated packet unmarshaling panics and `Client.RemoveAll` directory symlink traversal).
* Harden SSH client and forwarded agent (`golang.org/x/crypto/ssh`):
* Implement `ssh.MultiAlgorithmSigner` on `dynSigner` and `agent.ExtendedAgent` (`SignWithFlags`) on `keyRing` and `forwardedAgent` so RSA keys and user certificates use `rsa-sha2-256` / `rsa-sha2-512` instead of falling back to deprecated SHA-1 (`ssh-rsa`).
* Reject deprecated SHA-1 (`ssh-rsa`) and DSA (`ssh-dss`) CA signatures in `checkCertificate`.
* Fix a trailing-backslash parsing bug in `shellwords.Parse` that could cause a slice-bounds panic during tab auto-completion.
* Set upstream TCP write deadlines and WebSocket `HandshakeTimeout` in `sshterm-server`.
* Harden `@xterm/xterm` terminal handling in `docroot/ssh.mjs`:
* Clamp `CSI Ps b` (`REP`) and `CSI I`, `Z`, `L`, `M`, `S`, `T` parameters to terminal dimensions to prevent `RangeError` crashes (which wedged `WriteBuffer` and deadlocked WASM writes) and `2^31 - 1` synchronous main-thread loops.
* Sanitize and truncate `OSC 0`/`OSC 2` window titles while reusing a single toast element in `_showMessage`, and reset terminal modes and pending input buffers when an SSH or SFTP session exits.

## v0.11.5

### :star: Feature improvements
Expand Down
79 changes: 73 additions & 6 deletions docroot/ssh.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,49 @@ function isTest() {
return window.location.pathname.indexOf('tests.html') !== -1;
}

export const workarounds = {
// Protects @xterm/xterm (5.5.0) InputHandler.repeatPrecedingCharacter
// (CSI Ps b) from throwing an uncaught RangeError on large Ps values and
// permanently wedging WriteBuffer.
csiRepGuard: true,

// Protects @xterm/xterm (5.5.0) InputHandler from 2^31-1 synchronous
// while(param--) loops in CSI I, Z, L, M, S, and T.
csiLoopGuard: true,
};

export function sanitizeTitle(title) {
const clean = String(title).replace(/[\x00-\x1f\x7f-\x9f]/g, ' ').trim();
return clean.length > 128 ? clean.slice(0, 128) + '…' : clean;
}

export function applyXtermWorkarounds(term, flags = workarounds) {
const disposables = [];
const registerClampCsi = (final, getMax) => {
disposables.push(
term._core.registerCsiHandler({ final }, params => {
const active = final === 'b' ? flags.csiRepGuard : flags.csiLoopGuard;
if (!active) return false;
const maxVal = Math.max(1, getMax());
if (params.length > 0 && params.params[0] > maxVal) {
params.params[0] = maxVal;
}
return false;
})
);
};

registerClampCsi('b', () => term.cols * term.rows);
registerClampCsi('I', () => term.cols);
registerClampCsi('Z', () => term.cols);
registerClampCsi('L', () => term.rows);
registerClampCsi('M', () => term.rows);
registerClampCsi('S', () => term.rows);
registerClampCsi('T', () => term.rows);

return disposables;
}

class TerminalManager {
constructor(elem, setTitle, onBell) {
this.term = new Terminal({
Expand All @@ -42,7 +85,10 @@ class TerminalManager {
this.term.open(elem);
this.fitAddon.fit();

const workaroundDisposables = applyXtermWorkarounds(this.term, workarounds);

this.disposables = [
...workaroundDisposables,
this.term.onTitleChange(setTitle),
this.term.onBell(onBell),
this.term.onSelectionChange(() => {
Expand Down Expand Up @@ -177,7 +223,8 @@ export class TabManager {
this.screens[b.id] = { e, b };
this.screens[b.id].title = 'sshterm';

const setTitle = title => {
const setTitle = rawTitle => {
const title = sanitizeTitle(rawTitle) || 'sshterm';
this.screens[b.id].title = title;
this.screens[b.id].b.title = title;
document.querySelector('head title').textContent = title;
Expand Down Expand Up @@ -231,15 +278,35 @@ export class TabManager {
}

_showMessage(element, text) {
const msg = document.createElement('div');
msg.style = 'position: absolute; bottom: 0; right: 0; padding: 0.5rem; background-color: white; color: black; font-family: monospace; border: solid 1px black;';
msg.textContent = text;
element.appendChild(msg);
setTimeout(() => element.removeChild(msg), 3000);
const safeText = sanitizeTitle(text);
let msg = element._toastMsg;
if (!msg || msg.parentNode !== element) {
msg = document.createElement('div');
msg.className = 'sshterm-toast';
msg.style = 'position: absolute; bottom: 0; right: 0; max-width: 80%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; pointer-events: none; padding: 0.5rem; background-color: white; color: black; font-family: monospace; border: solid 1px black; z-index: 10;';
element.appendChild(msg);
element._toastMsg = msg;
}
msg.textContent = safeText;
if (element._toastTimer) {
clearTimeout(element._toastTimer);
}
element._toastTimer = setTimeout(() => {
if (msg.parentNode === element) {
element.removeChild(msg);
}
element._toastMsg = null;
element._toastTimer = null;
}, 3000);
}
}

window.sshApp = {};
if (isTest()) {
window.sshApp.workarounds = workarounds;
window.sshApp.applyXtermWorkarounds = applyXtermWorkarounds;
window.sshApp.Terminal = Terminal;
}
window.sshApp.exited = null;
window.sshApp.ready = new Promise(resolve => {
window.sshApp.sshIsReady = () => {
Expand Down
2 changes: 2 additions & 0 deletions go/build.sh
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

cd $(dirname $0)

export GOTOOLCHAIN="go$(cat ../.goversion)"

GOOS=js GOARCH=wasm go build -ldflags="-extldflags=-s -w" -o ../docroot/ssh.wasm .
GOOS=js GOARCH=wasm go test -c -o ../docroot/tests.wasm ./internal/tests
cp -f $(go env GOROOT)/lib/wasm/wasm_exec.js ../docroot/
Expand Down
4 changes: 2 additions & 2 deletions go/go.mod
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
module github.com/c2FmZQ/sshterm

go 1.26.0
go 1.27.3

require (
github.com/chromedp/cdproto v0.0.0-20250803210736-d308e07a266d
github.com/chromedp/chromedp v0.14.2
github.com/fxamacker/cbor/v2 v2.9.6
github.com/gorilla/websocket v1.5.3
github.com/pkg/sftp v1.13.11
github.com/pkg/sftp v1.13.12
github.com/urfave/cli/v2 v2.27.7
golang.org/x/crypto v0.58.0
golang.org/x/term v0.47.0
Expand Down
4 changes: 2 additions & 2 deletions go/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,8 @@ github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kUL
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs=
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde h1:x0TT0RDC7UhAVbbWWBzr41ElhJx5tXPWkIHA2HWPRuw=
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0=
github.com/pkg/sftp v1.13.11 h1:0N92SLTB8JqASJB14ZLHHzFnBV8mG9zw4K7jghEFWuE=
github.com/pkg/sftp v1.13.11/go.mod h1:uNkH9roSXglNJqM+glJJi+TQXQUm0fXFWqCFmT8hsN0=
github.com/pkg/sftp v1.13.12 h1:Myhzy/ZzLPnCtE2F2WtDHsgs8RfgMnH2gfbHuCJhkMU=
github.com/pkg/sftp v1.13.12/go.mod h1:uNkH9roSXglNJqM+glJJi+TQXQUm0fXFWqCFmT8hsN0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
Expand Down
39 changes: 36 additions & 3 deletions go/internal/app/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -212,7 +212,7 @@ type keyRing struct {
pp []byte
}

var _ agent.Agent = (*keyRing)(nil)
var _ agent.ExtendedAgent = (*keyRing)(nil)

var errAgentLocked = errors.New("agent is locked")
var errAgentNotLocked = errors.New("agent is not locked")
Expand Down Expand Up @@ -272,6 +272,10 @@ func (r *keyRing) List() ([]*agent.Key, error) {
}

func (r *keyRing) Sign(key ssh.PublicKey, data []byte) (*ssh.Signature, error) {
return r.SignWithFlags(key, data, 0)
}

func (r *keyRing) SignWithFlags(key ssh.PublicKey, data []byte, flags agent.SignatureFlags) (*ssh.Signature, error) {
r.mu.Lock()
if r.locked {
r.mu.Unlock()
Expand All @@ -289,7 +293,25 @@ func (r *keyRing) Sign(key ssh.PublicKey, data []byte) (*ssh.Signature, error) {
// Don't hold the lock while signing. With WebAuthn keys, Sign waits for
// the user to touch the security key.
r.mu.Unlock()
return signer.Sign(rand.Reader, data)
if flags == 0 {
return signer.Sign(rand.Reader, data)
}
as, ok := signer.(ssh.AlgorithmSigner)
if !ok {
return nil, fmt.Errorf("agent: key does not support custom signature algorithms")
}
switch flags {
case agent.SignatureFlagRsaSha256:
return as.SignWithAlgorithm(rand.Reader, data, ssh.KeyAlgoRSASHA256)
case agent.SignatureFlagRsaSha512:
return as.SignWithAlgorithm(rand.Reader, data, ssh.KeyAlgoRSASHA512)
default:
return nil, fmt.Errorf("agent: unsupported signature flags: %d", flags)
}
}

func (r *keyRing) Extension(extensionType string, contents []byte) ([]byte, error) {
return nil, agent.ErrExtensionUnsupported
}

func (r *keyRing) AddSigner(signer ssh.Signer, comment string) error {
Expand Down Expand Up @@ -454,13 +476,17 @@ type forwardedAgent struct {
notify func(format string, args ...any)
}

var _ agent.Agent = (*forwardedAgent)(nil)
var _ agent.ExtendedAgent = (*forwardedAgent)(nil)

func (f *forwardedAgent) List() ([]*agent.Key, error) {
return f.agent.List()
}

func (f *forwardedAgent) Sign(key ssh.PublicKey, data []byte) (*ssh.Signature, error) {
return f.SignWithFlags(key, data, 0)
}

func (f *forwardedAgent) SignWithFlags(key ssh.PublicKey, data []byte, flags agent.SignatureFlags) (*ssh.Signature, error) {
name := ssh.FingerprintSHA256(key)
if keys, err := f.agent.List(); err == nil {
v := key.Marshal()
Expand All @@ -472,9 +498,16 @@ func (f *forwardedAgent) Sign(key ssh.PublicKey, data []byte) (*ssh.Signature, e
}
}
f.notify("[agent] %s requested a signature with key %q\n", f.host, name)
if ea, ok := f.agent.(agent.ExtendedAgent); ok {
return ea.SignWithFlags(key, data, flags)
}
return f.agent.Sign(key, data)
}

func (f *forwardedAgent) Extension(extensionType string, contents []byte) ([]byte, error) {
return nil, agent.ErrExtensionUnsupported
}

func (f *forwardedAgent) Add(key agent.AddedKey) error {
return errForwardedAgent
}
Expand Down
29 changes: 29 additions & 0 deletions go/internal/app/autocomplete_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -129,3 +129,32 @@ func TestMaybeQuote(t *testing.T) {
}
}
}

func TestAutoCompleteTrailingBackslash(t *testing.T) {
ac := &autoCompleter{
cmds: []*cli.App{
{Name: `al\pha`},
{
Name: `\cmd`,
Commands: []*cli.Command{
{Name: `\sub`},
},
},
},
}

nl, np, _, ok := ac.autoComplete(`al\`, 3, '\t')
if !ok || nl != `"al\\pha" ` || np != len(nl) {
t.Fatalf("autoComplete(\"al\\\\\", 3, '\\t') = %q, %d, %v; want %q, %d, true", nl, np, ok, `"al\\pha" `, len(`"al\\pha" `))
}

nl, np, _, ok = ac.autoComplete(`\`, 1, '\t')
if !ok || nl != `"\\cmd" ` || np != len(nl) {
t.Fatalf("autoComplete(\"\\\\\", 1, '\\t') = %q, %d, %v; want %q, %d, true", nl, np, ok, `"\\cmd" `, len(`"\\cmd" `))
}

nl, np, _, ok = ac.autoComplete(`"\\cmd" \`, 9, '\t')
if !ok || nl != `"\\cmd" "\\sub" ` || np != len(nl) {
t.Fatalf("autoComplete(\"\\\"\\\\\\\\cmd\\\" \\\\\", 9, '\\t') = %q, %d, %v; want %q, %d, true", nl, np, ok, `"\\cmd" "\\sub" `, len(`"\\cmd" "\\sub" `))
}
}
4 changes: 3 additions & 1 deletion go/internal/app/cert_util.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,9 @@ func checkCertificate(cert *ssh.Certificate, certType uint32) error {
c2 := *cert
c2.Signature = nil
signBytes := c2.Marshal()
if err := cert.SignatureKey.Verify(signBytes[:len(signBytes)-4], cert.Signature); err != nil {
if cert.Signature == nil || cert.Signature.Format == ssh.KeyAlgoRSA || cert.Signature.Format == ssh.KeyAlgoDSA {
errs = append(errs, fmt.Errorf("certificate signature is invalid"))
} else if err := cert.SignatureKey.Verify(signBytes[:len(signBytes)-4], cert.Signature); err != nil {
errs = append(errs, fmt.Errorf("certificate signature is invalid"))
}
return errors.Join(errs...)
Expand Down
42 changes: 42 additions & 0 deletions go/internal/app/cert_util_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ package app
import (
"crypto/ed25519"
"crypto/rand"
"crypto/rsa"
"io"
"strings"
"testing"
"time"
Expand Down Expand Up @@ -87,6 +89,46 @@ func TestCheckCertificate(t *testing.T) {
}
t.Logf("[#%d] OK checkCertificate(): %v", i, err)
}

// Verify that SHA-1 (ssh-rsa) CA signatures are rejected while RSA-SHA2-512 is accepted.
rsaPriv, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("rsa.GenerateKey: %v", err)
}
rsaSigner, err := ssh.NewSignerFromKey(rsaPriv)
if err != nil {
t.Fatalf("ssh.NewSignerFromKey: %v", err)
}
algSigner := rsaSigner.(ssh.AlgorithmSigner)

sha1Cert := &ssh.Certificate{Key: sshPub, CertType: ssh.HostCert, ValidBefore: ssh.CertTimeInfinity}
if err := sha1Cert.SignCert(rand.Reader, &fixedAlgoSigner{AlgorithmSigner: algSigner, algo: ssh.KeyAlgoRSA}); err != nil {
t.Fatalf("SignCert(ssh-rsa): %v", err)
}
if err := checkCertificate(sha1Cert, ssh.HostCert); err == nil {
t.Errorf("expected checkCertificate to reject ssh-rsa (SHA-1) CA signature")
}

sha512Cert := &ssh.Certificate{Key: sshPub, CertType: ssh.HostCert, ValidBefore: ssh.CertTimeInfinity}
if err := sha512Cert.SignCert(rand.Reader, &fixedAlgoSigner{AlgorithmSigner: algSigner, algo: ssh.KeyAlgoRSASHA512}); err != nil {
t.Fatalf("SignCert(rsa-sha2-512): %v", err)
}
if err := checkCertificate(sha512Cert, ssh.HostCert); err != nil {
t.Errorf("expected checkCertificate to accept rsa-sha2-512 CA signature, got: %v", err)
}
}

type fixedAlgoSigner struct {
ssh.AlgorithmSigner
algo string
}

func (s *fixedAlgoSigner) Sign(rand io.Reader, data []byte) (*ssh.Signature, error) {
return s.AlgorithmSigner.SignWithAlgorithm(rand, data, s.algo)
}

func (s *fixedAlgoSigner) SignWithAlgorithm(rand io.Reader, data []byte, _ string) (*ssh.Signature, error) {
return s.AlgorithmSigner.SignWithAlgorithm(rand, data, s.algo)
}

func TestCheckHostCertPrincipal(t *testing.T) {
Expand Down
19 changes: 19 additions & 0 deletions go/internal/app/keys.go
Original file line number Diff line number Diff line change
Expand Up @@ -821,9 +821,28 @@ type dynSigner struct {
key *key
}

var _ ssh.MultiAlgorithmSigner = (*dynSigner)(nil)

func (s *dynSigner) PublicKey() ssh.PublicKey {
if cert := s.key.Certificate(); cert != nil {
return cert
}
return s.Signer.PublicKey()
}

func (s *dynSigner) SignWithAlgorithm(rand io.Reader, data []byte, algorithm string) (*ssh.Signature, error) {
if as, ok := s.Signer.(ssh.AlgorithmSigner); ok {
return as.SignWithAlgorithm(rand, data, algorithm)
}
if algorithm == "" || algorithm == s.Signer.PublicKey().Type() {
return s.Signer.Sign(rand, data)
}
return nil, fmt.Errorf("ssh: unsupported signature algorithm %q", algorithm)
}

func (s *dynSigner) Algorithms() []string {
if mas, ok := s.Signer.(ssh.MultiAlgorithmSigner); ok {
return mas.Algorithms()
}
return []string{s.Signer.PublicKey().Type()}
}
5 changes: 4 additions & 1 deletion go/internal/app/sftp.go
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,10 @@ func (a *App) runSFTP(ctx context.Context, target, keyName, jumpHosts string) (e
defer client.Close()

a.term.Printf("\x1b]0;sftp %s\x07", target)
defer a.term.Printf("\x1b]0;sshterm\x07")
defer func() {
a.term.ResetSessionState()
a.term.Printf("\x1b]0;sshterm\x07")
}()

raw := a.term.Raw()

Expand Down
Loading
Loading