diff --git a/CHANGELOG.md b/CHANGELOG.md index 939478d..04d3adb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docroot/ssh.mjs b/docroot/ssh.mjs index 9c6fdd1..5cc7e9d 100644 --- a/docroot/ssh.mjs +++ b/docroot/ssh.mjs @@ -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({ @@ -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(() => { @@ -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; @@ -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 = () => { diff --git a/go/build.sh b/go/build.sh index f9738e8..c70fa38 100755 --- a/go/build.sh +++ b/go/build.sh @@ -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/ diff --git a/go/go.mod b/go/go.mod index f2cfc9f..7a3b0e6 100644 --- a/go/go.mod +++ b/go/go.mod @@ -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 diff --git a/go/go.sum b/go/go.sum index fe79dff..7082aae 100644 --- a/go/go.sum +++ b/go/go.sum @@ -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= diff --git a/go/internal/app/agent.go b/go/internal/app/agent.go index a795339..d4b64c8 100644 --- a/go/internal/app/agent.go +++ b/go/internal/app/agent.go @@ -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") @@ -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() @@ -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 { @@ -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() @@ -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 } diff --git a/go/internal/app/autocomplete_test.go b/go/internal/app/autocomplete_test.go index 145550f..78c7cb8 100644 --- a/go/internal/app/autocomplete_test.go +++ b/go/internal/app/autocomplete_test.go @@ -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" `)) + } +} diff --git a/go/internal/app/cert_util.go b/go/internal/app/cert_util.go index 88d7fbc..601b0d2 100644 --- a/go/internal/app/cert_util.go +++ b/go/internal/app/cert_util.go @@ -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...) diff --git a/go/internal/app/cert_util_test.go b/go/internal/app/cert_util_test.go index b24f249..1a39741 100644 --- a/go/internal/app/cert_util_test.go +++ b/go/internal/app/cert_util_test.go @@ -26,6 +26,8 @@ package app import ( "crypto/ed25519" "crypto/rand" + "crypto/rsa" + "io" "strings" "testing" "time" @@ -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) { diff --git a/go/internal/app/keys.go b/go/internal/app/keys.go index 6b05952..3614eef 100644 --- a/go/internal/app/keys.go +++ b/go/internal/app/keys.go @@ -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()} +} diff --git a/go/internal/app/sftp.go b/go/internal/app/sftp.go index 7d8ecfb..823d731 100644 --- a/go/internal/app/sftp.go +++ b/go/internal/app/sftp.go @@ -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() diff --git a/go/internal/app/ssh.go b/go/internal/app/ssh.go index 7f8d252..35acda3 100644 --- a/go/internal/app/ssh.go +++ b/go/internal/app/ssh.go @@ -109,7 +109,10 @@ func (a *App) runSSH(ctx context.Context, target, keyName, command string, forwa go sshKeepAlive(ctx, client, cancel) t.Printf("\x1b]0;ssh %s\x07", target) - defer t.Printf("\x1b]0;sshterm\x07") + defer func() { + t.ResetSessionState() + t.Printf("\x1b]0;sshterm\x07") + }() session, err := client.NewSession() if err != nil { diff --git a/go/internal/shellwords/shellwords.go b/go/internal/shellwords/shellwords.go index 33b6742..774e9ca 100644 --- a/go/internal/shellwords/shellwords.go +++ b/go/internal/shellwords/shellwords.go @@ -157,7 +157,7 @@ func Parse(line string, opts ...Option) ([]string, []string) { if escaped { buf += "\\" - bufRaw += "\\" + got = argSingle } if got != argNo { diff --git a/go/internal/shellwords/shellwords_test.go b/go/internal/shellwords/shellwords_test.go index 0222b82..2b1064b 100644 --- a/go/internal/shellwords/shellwords_test.go +++ b/go/internal/shellwords/shellwords_test.go @@ -71,6 +71,9 @@ func TestSimple(t *testing.T) { {`foo*[a-b]?`, []string{`foo*[a-b]?`}, []string{`foo*[a-b]?`}}, {`foo*[a-b]\?`, []string{`foo*[a-b]?`}, []string{`foo*[a-b]\?`}}, {`"foo*[a-b]?"`, []string{`foo*[a-b]?`}, []string{`"foo*[a-b]?"`}}, + {`foo\`, []string{`foo\`}, []string{`foo\`}}, + {`\`, []string{`\`}, []string{`\`}}, + {`ls \`, []string{`ls`, `\`}, []string{`ls`, `\`}}, } for _, testcase := range testcases { diff --git a/go/internal/terminal/terminal.go b/go/internal/terminal/terminal.go index 23bc68f..3374258 100644 --- a/go/internal/terminal/terminal.go +++ b/go/internal/terminal/terminal.go @@ -315,6 +315,25 @@ func (t *Terminal) Clear() { t.tw.xt.Call("clear") } +// ResetSessionState resets terminal modes/colors that may have been altered by +// a remote session and drains any pending asynchronous query responses from the +// input buffer before returning to the local prompt. +func (t *Terminal) ResetSessionState() { + _, _ = t.tw.Write([]byte( + "\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1006l" + + "\x1b[?47l\x1b[!p" + + "\x1b]104\x07\x1b]110\x07\x1b]111\x07\x1b]112\x07", + )) + for { + select { + case <-t.tw.dataCh: + default: + t.tw.r = nil + return + } + } +} + func (t *Terminal) Rows() int { return t.tw.xt.Get("rows").Int() } diff --git a/go/internal/tests/remediations_test.go b/go/internal/tests/remediations_test.go new file mode 100644 index 0000000..b9bfbcd --- /dev/null +++ b/go/internal/tests/remediations_test.go @@ -0,0 +1,232 @@ +// MIT License +// +// Copyright (c) 2026 TTBT Enterprises LLC +// Copyright (c) 2026 Robin Thellend +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +//go:build wasm + +package tests + +import ( + "bytes" + "net/http" + "strings" + "syscall/js" + "testing" + "time" + + "golang.org/x/crypto/ssh" + + "github.com/c2FmZQ/sshterm/internal/app" + "github.com/c2FmZQ/sshterm/internal/jsutil" +) + +func TestXtermWorkarounds(t *testing.T) { + sshApp := js.Global().Get("sshApp") + termCtor := sshApp.Get("Terminal") + workarounds := sshApp.Get("workarounds") + applyWorkarounds := sshApp.Get("applyXtermWorkarounds") + if termCtor.IsUndefined() || workarounds.IsUndefined() || applyWorkarounds.IsUndefined() { + t.Fatalf("missing sshApp.Terminal / workarounds / applyXtermWorkarounds exports") + } + doc := js.Global().Get("document") + + createTestTerm := func(t *testing.T, flags map[string]any) (js.Value, func()) { + t.Helper() + container := doc.Call("createElement", "div") + container.Get("style").Set("width", "400px") + container.Get("style").Set("height", "200px") + doc.Get("body").Call("appendChild", container) + term := termCtor.New(map[string]any{"cols": 40, "rows": 10}) + term.Call("open", container) + disposables := applyWorkarounds.Invoke(term, flags) + cleanup := func() { + for i := 0; i < disposables.Length(); i++ { + disposables.Index(i).Call("dispose") + } + term.Call("dispose") + doc.Get("body").Call("removeChild", container) + } + return term, cleanup + } + + writeTerm := func(t *testing.T, term js.Value, data string) { + t.Helper() + doneCh := make(chan struct{}, 1) + cb := js.FuncOf(func(this js.Value, args []js.Value) any { + select { + case doneCh <- struct{}{}: + default: + } + return nil + }) + defer cb.Release() + var writeErr any + jsutil.TryCatch(func() { + term.Call("write", data, cb) + }, func(err any) { + writeErr = err + }) + if writeErr != nil { + t.Fatalf("term.write threw: %v", writeErr) + } + select { + case <-doneCh: + case <-time.After(2 * time.Second): + t.Fatalf("term.write callback timed out (WriteBuffer wedged)") + } + } + + // 1. CSI Ps b (REP / repeatPrecedingCharacter) + t.Run("CSIRepGuard", func(t *testing.T) { + t.Run("VulnerabilityWithoutRemediation", func(t *testing.T) { + term, cleanup := createTestTerm(t, map[string]any{ + "csiRepGuard": false, + "csiLoopGuard": false, + }) + defer cleanup() + writeTerm(t, term, "a") + + paramsArr := js.Global().Get("Int32Array").New(1) + paramsArr.SetIndex(0, 2147483647) + var caught any + jsutil.TryCatch(func() { + term.Get("_core").Get("_inputHandler").Call("repeatPrecedingCharacter", map[string]any{ + "length": 1, + "params": paramsArr, + }) + }, func(err any) { + caught = err + }) + if caught == nil { + t.Fatalf("expected @xterm/xterm repeatPrecedingCharacter(2147483647) to throw RangeError without csiRepGuard; if fixed upstream, remove csiRepGuard") + } + }) + + t.Run("RemediationEnabled", func(t *testing.T) { + term, cleanup := createTestTerm(t, map[string]any{ + "csiRepGuard": true, + "csiLoopGuard": true, + }) + defer cleanup() + writeTerm(t, term, "a\x1b[2147483647bOK") + }) + }) + + // 2. CSI Ps I/Z/L/M/S/T unbounded loop parameter clamping + t.Run("CSILoopGuard", func(t *testing.T) { + t.Run("VulnerabilityWithoutRemediation", func(t *testing.T) { + term, cleanup := createTestTerm(t, map[string]any{ + "csiRepGuard": true, + "csiLoopGuard": false, + }) + defer cleanup() + + seenParam := 0 + cb := js.FuncOf(func(this js.Value, args []js.Value) any { + seenParam = args[0].Get("params").Index(0).Int() + return true // intercept before built-in 2B loop freezes browser + }) + defer cb.Release() + disp := term.Get("_core").Call("registerCsiHandler", map[string]any{"final": "L"}, cb) + writeTerm(t, term, "\x1b[2147483647L") + disp.Call("dispose") + if seenParam != 2147483647 { + t.Fatalf("expected unclamped CSI L param 2147483647 without csiLoopGuard, got %d", seenParam) + } + }) + + t.Run("RemediationEnabled", func(t *testing.T) { + term, cleanup := createTestTerm(t, map[string]any{ + "csiRepGuard": true, + "csiLoopGuard": true, + }) + defer cleanup() + start := time.Now() + writeTerm(t, term, "\x1b[2147483647I\x1b[2147483647Z\x1b[2147483647L\x1b[2147483647M\x1b[2147483647S\x1b[2147483647T") + if elapsed := time.Since(start); elapsed > time.Second { + t.Fatalf("CSI loop guard took too long: %v", elapsed) + } + }) + }) + + // 3. Title sanitization and _showMessage toast DOM reuse + t.Run("TitleOverlay", func(t *testing.T) { + screen := doc.Call("querySelector", `[id^="screen-"]`) + if screen.IsNull() || screen.IsUndefined() { + t.Fatalf("screen element not found") + } + longTitle := strings.Repeat("A", 300) + "\nSPOOF" + + appConfig.Term.Call("showMessage", longTitle) + appConfig.Term.Call("showMessage", longTitle) + toasts := screen.Call("querySelectorAll", ".sshterm-toast") + if got := toasts.Length(); got != 1 { + t.Fatalf("expected exactly 1 .sshterm-toast element, got %d", got) + } + txt := toasts.Index(0).Get("textContent").String() + if len([]rune(txt)) > 129 || strings.Contains(txt, "\n") { + t.Fatalf("expected toast text to be truncated and sanitized, got len=%d %q", len(txt), txt) + } + }) +} + +func TestRSASHA2ForwardedAgent(t *testing.T) { + a, err := app.New(appConfig) + if err != nil { + t.Fatalf("app.New: %v", err) + } + result := make(chan error) + go func() { + result <- a.Run() + }() + t.Cleanup(a.Stop) + + script(t, []line{ + {Expect: prompt}, + {Type: "db wipe\n", Expect: `Continue\?`}, + {Type: "Y\n", Expect: prompt}, + {Type: "ep add test-server websocket\n", Expect: prompt}, + {Type: "keys generate -t rsa rsatest\n", Expect: "Enter a passphrase"}, + {Type: "foobar\n", Expect: "Re-enter the same passphrase"}, + {Type: "foobar\n", Expect: prompt}, + {Type: "keys list\n", Expect: "ssh-rsa .* rsatest\r\n", Do: func(m []string) { + pub, _, _, _, err := ssh.ParseAuthorizedKey([]byte(m[0])) + if err != nil { + t.Fatalf("ssh.ParseAuthorizedKey: %v", err) + } + if _, err := http.Post("/addkey", "text/pem", bytes.NewReader(pub.Marshal())); err != nil { + t.Fatalf("http.Post: %v", err) + } + }}, + {Type: "agent add rsatest\n", Expect: "Enter the passphrase for rsatest:"}, + {Type: "foobar\n", Expect: prompt}, + {Type: "ssh -A testuser@test-server agent-test\n", Expect: `(?s)Host key for test-server.*Choice>`}, + {Type: "3\n", Expect: `\[agent\] test-server requested a signature with key "rsatest"`}, + {Expect: `(?s)agent-test: sign: ok \(ssh-rsa\).*agent-test: sign-sha512: ok \(rsa-sha2-512\).*agent-test: removeall: agent: failure`}, + {Wait: time.Second, Type: "\n\n", Expect: prompt}, + {Type: "exit\n"}, + }) + + if err := <-result; err != nil { + t.Fatalf("Run(): %v", err) + } +} diff --git a/go/internal/testserver/main_test.go b/go/internal/testserver/main_test.go index d8e64e1..43a17cf 100644 --- a/go/internal/testserver/main_test.go +++ b/go/internal/testserver/main_test.go @@ -638,10 +638,15 @@ func (s *sshServer) agentTest(w io.Writer, sconn *ssh.ServerConn, forwarded bool return } fmt.Fprintf(w, "agent-test: list: %d keys\n", len(keys)) - if _, err := client.Sign(keys[0], []byte("data")); err != nil { + if sig, err := client.Sign(keys[0], []byte("data")); err != nil { fmt.Fprintf(w, "agent-test: sign: %v\n", err) } else { - fmt.Fprintf(w, "agent-test: sign: ok\n") + fmt.Fprintf(w, "agent-test: sign: ok (%s)\n", sig.Format) + } + if sig, err := client.SignWithFlags(keys[0], []byte("data"), agent.SignatureFlagRsaSha512); err != nil { + fmt.Fprintf(w, "agent-test: sign-sha512: %v\n", err) + } else { + fmt.Fprintf(w, "agent-test: sign-sha512: ok (%s)\n", sig.Format) } fmt.Fprintf(w, "agent-test: lock: %v\n", client.Lock([]byte("x"))) fmt.Fprintf(w, "agent-test: removeall: %v\n", client.RemoveAll()) diff --git a/go/sshterm-server/internal/server.go b/go/sshterm-server/internal/server.go index 6e088ac..4ea4bc6 100644 --- a/go/sshterm-server/internal/server.go +++ b/go/sshterm-server/internal/server.go @@ -206,8 +206,9 @@ func New(opts Options) (*Server, error) { endpoints: epMap, configJSON: cfgBytes, upgrader: websocket.Upgrader{ - ReadBufferSize: 8192, - WriteBufferSize: 8192, + HandshakeTimeout: dialTimeout, + ReadBufferSize: 8192, + WriteBufferSize: 8192, }, mux: http.NewServeMux(), logger: logger, @@ -382,7 +383,7 @@ func (s *Server) proxyConn(wsConn *websocket.Conn, tcpConn net.Conn) { ) return } - if _, err := io.Copy(tcpConn, r); err != nil { + if _, err := io.Copy(&tcpDeadlineWriter{conn: tcpConn}, r); err != nil { return } } @@ -410,6 +411,15 @@ func (s *Server) proxyConn(wsConn *websocket.Conn, tcpConn net.Conn) { wg.Wait() } +type tcpDeadlineWriter struct { + conn net.Conn +} + +func (w *tcpDeadlineWriter) Write(p []byte) (int, error) { + _ = w.conn.SetWriteDeadline(time.Now().Add(writeWait)) + return w.conn.Write(p) +} + // Addr returns the network address the server is listening on, or nil if it has // not started listening yet. func (s *Server) Addr() net.Addr { diff --git a/go/sshterm-server/internal/server_test.go b/go/sshterm-server/internal/server_test.go index 07e9759..d8bee11 100644 --- a/go/sshterm-server/internal/server_test.go +++ b/go/sshterm-server/internal/server_test.go @@ -45,6 +45,7 @@ import ( "os" "path/filepath" "strings" + "sync" "testing" "time" @@ -652,3 +653,70 @@ func (c *wsNetConn) RemoteAddr() net.Addr { return c.conn.RemoteAd func (c *wsNetConn) SetDeadline(t time.Time) error { return nil } func (c *wsNetConn) SetReadDeadline(t time.Time) error { return c.conn.SetReadDeadline(t) } func (c *wsNetConn) SetWriteDeadline(t time.Time) error { return c.conn.SetWriteDeadline(t) } + +type stallTrackConn struct { + net.Conn + mu sync.Mutex + deadlineSet bool +} + +func (s *stallTrackConn) SetWriteDeadline(t time.Time) error { + s.mu.Lock() + if !t.IsZero() { + s.deadlineSet = true + } + s.mu.Unlock() + return s.Conn.SetWriteDeadline(t) +} + +func (s *stallTrackConn) Write(p []byte) (int, error) { + s.mu.Lock() + hasDeadline := s.deadlineSet + s.mu.Unlock() + if hasDeadline { + return 0, os.ErrDeadlineExceeded + } + return len(p), nil +} + +func TestWSProxyWriteDeadline(t *testing.T) { + c1, s1 := net.Pipe() + defer c1.Close() + defer s1.Close() + tracked := &stallTrackConn{Conn: c1} + + srv, err := New(Options{ + Endpoints: []Endpoint{{Name: "stall", Address: "127.0.0.1:22"}}, + DialContext: func(ctx context.Context, network, address string) (net.Conn, error) { + return tracked, nil + }, + Logger: log.New(io.Discard, "", 0), + }) + if err != nil { + t.Fatalf("New: %v", err) + } + ts := httptest.NewServer(srv.Handler()) + defer ts.Close() + + wsURL := "ws" + strings.TrimPrefix(ts.URL, "http") + "/_ws/stall" + wsConn, _, err := websocket.DefaultDialer.Dial(wsURL, nil) + if err != nil { + t.Fatalf("Dial: %v", err) + } + defer wsConn.Close() + + if err := wsConn.WriteMessage(websocket.BinaryMessage, []byte("ping")); err != nil { + t.Fatalf("WriteMessage: %v", err) + } + _ = wsConn.SetReadDeadline(time.Now().Add(150 * time.Millisecond)) + _, _, readErr := wsConn.ReadMessage() + tracked.mu.Lock() + dlSet := tracked.deadlineSet + tracked.mu.Unlock() + if !dlSet { + t.Fatal("expected WriteDeadline to be set on upstream TCP connection") + } + if readErr == nil { + t.Fatal("expected proxyConn to close connection when upstream TCP write times out") + } +}