diff --git a/client.go b/client.go index 7e0a2b7..cec5883 100644 --- a/client.go +++ b/client.go @@ -5,6 +5,7 @@ import ( "fmt" "io" "net" + "strings" "sync" ) @@ -49,6 +50,8 @@ func NewClientConn(conn net.Conn, config *ClientConfig) (*Client, error) { func newClientConn(conn net.Conn, config clientConfig) (*Client, error) { transport := newTransport(conn, config.transportConfig()) + autoLogin := config.User != "" || config.Password != "" + transport.setTraceRedaction(autoLogin) var reader io.Reader = transport var writer io.Writer = transport if config.WrapStream != nil { @@ -62,21 +65,24 @@ func newClientConn(conn net.Conn, config clientConfig) (*Client, error) { // Keep the same buffered reader for login and the interactive session. Login // reads only through the matched prompt, so bytes that arrived in the same // transport read after that prompt remain available to Client.Read. - bufferedReader := bufio.NewReaderSize(reader, 4096) + bufferedReader := bufio.NewReaderSize(reader, loginReadBufferSize) client := &Client{ conn: conn, transport: transport, reader: bufferedReader, writer: writer, } - if config.User != "" || config.Password != "" { - transport.setTraceRedaction(true) - err := login(conn, bufferedReader, writer, config, transport.trace) - transport.setTraceRedaction(false) + if autoLogin { + replay, err := login(conn, bufferedReader, writer, config, transport.trace) if err != nil { + // Keep redaction enabled while a stream wrapper flushes on Close. _ = client.Close() return nil, fmt.Errorf("telnet: handshake failed: %w", err) } + if replay != "" { + client.reader = io.MultiReader(strings.NewReader(replay), bufferedReader) + } + transport.setTraceRedaction(false) } return client, nil } diff --git a/decoder.go b/decoder.go index 2cc8b31..01aeec1 100644 --- a/decoder.go +++ b/decoder.go @@ -11,6 +11,8 @@ const ( decoderSubnegotiationOption decoderSubnegotiationData decoderSubnegotiationIAC + decoderDiscardSubnegotiation + decoderDiscardSubnegotiationIAC ) type eventType uint8 @@ -43,6 +45,7 @@ func newDecoder(maxSubnegotiation int) *decoder { func (d *decoder) feed(input []byte) ([]byte, []telnetEvent, error) { data := make([]byte, 0, len(input)) events := make([]telnetEvent, 0, 2) + var decodeErr error for _, value := range input { switch d.state { @@ -87,37 +90,54 @@ func (d *decoder) feed(input []byte) ([]byte, []telnetEvent, error) { d.state = decoderSubnegotiationIAC continue } - if err := d.appendSubByte(value); err != nil { - return data, events, err + if err := d.appendSubByte(value); err != nil && decodeErr == nil { + decodeErr = err } case decoderSubnegotiationIAC: switch value { case se: events = append(events, telnetEvent{ - typ: subnegotiationEvent, - option: d.subOption, + typ: subnegotiationEvent, + option: d.subOption, parameters: append([]byte(nil), d.subData...), }) d.subData = d.subData[:0] d.state = decoderData case iac: if err := d.appendSubByte(iac); err != nil { - return data, events, err + if decodeErr == nil { + decodeErr = err + } + continue } d.state = decoderSubnegotiationData default: // Ignore malformed commands inside SB while keeping the stream framed. d.state = decoderSubnegotiationData } + case decoderDiscardSubnegotiation: + if value == iac { + d.state = decoderDiscardSubnegotiationIAC + } + case decoderDiscardSubnegotiationIAC: + // IAC IAC is escaped payload even while discarding. Only the + // actual IAC SE terminator restores the application stream. + if value == se { + d.state = decoderData + } else { + d.state = decoderDiscardSubnegotiation + } } } - return data, events, nil + return data, events, decodeErr } func (d *decoder) appendSubByte(value byte) error { if len(d.subData) >= d.maxSubnegotiation { - d.state = decoderData + // Report once, but continue scanning this chunk and subsequent reads + // through IAC SE. Never expose an oversized SB's tail as terminal data. + d.state = decoderDiscardSubnegotiation d.subData = d.subData[:0] return fmt.Errorf("%w: limit %d", ErrSubnegotiationTooLong, d.maxSubnegotiation) } diff --git a/decoder_recovery_test.go b/decoder_recovery_test.go new file mode 100644 index 0000000..1f41ce6 --- /dev/null +++ b/decoder_recovery_test.go @@ -0,0 +1,92 @@ +package tclientlib + +import ( + "bytes" + "errors" + "fmt" + "testing" +) + +func TestOversizedSubnegotiationKeepsFramingEverySplit(t *testing.T) { + // The escaped IAC followed by SE is payload, not an SB terminator. + wire := []byte{'a', iac, sb, terminalType, 1, 2, 3, 'X', iac, iac, se, 'Y', iac, se, + 'b', iac, will, echoOption, iac, sb, terminalType, 1, iac, se} + for split := 0; split <= len(wire); split++ { + t.Run(fmt.Sprint(split), func(t *testing.T) { + d := newDecoder(2) + var output []byte + var events []telnetEvent + errorsSeen := 0 + for _, part := range [][]byte{wire[:split], wire[split:]} { + data, ev, err := d.feed(part) + output = append(output, data...) + events = append(events, ev...) + if err != nil { + if !errors.Is(err, ErrSubnegotiationTooLong) { + t.Fatal(err) + } + errorsSeen++ + } + if len(d.subData) > 2 { + t.Fatal("unbounded subnegotiation") + } + } + if !bytes.Equal(output, []byte("ab")) { + t.Fatalf("application data = %q", output) + } + if errorsSeen != 1 { + t.Fatalf("errors = %d, want 1", errorsSeen) + } + if len(events) != 2 || events[0].typ != negotiationEvent || events[1].typ != subnegotiationEvent || !bytes.Equal(events[1].parameters, []byte{1}) { + t.Fatalf("events = %#v", events) + } + }) + } +} + +func TestOversizedEscapedIACEntersDiscardState(t *testing.T) { + d := newDecoder(1) + _, _, err := d.feed([]byte{iac, sb, terminalType, 1, iac, iac}) + if !errors.Is(err, ErrSubnegotiationTooLong) { + t.Fatalf("got %v", err) + } + data, ev, err := d.feed([]byte{'X', iac, se, 'z'}) + if err != nil || len(ev) != 0 || string(data) != "z" { + t.Fatalf("data=%q events=%v err=%v", data, ev, err) + } +} + +func FuzzOversizedSubnegotiationRecovery(f *testing.F) { + f.Add([]byte{iac, se, iac, iac}, uint8(1)) + f.Add([]byte("ignored-data"), uint8(7)) + f.Fuzz(func(t *testing.T, payload []byte, size uint8) { + if len(payload) > 4096 { + payload = payload[:4096] + } + wire := []byte{'a', iac, sb, terminalType, 1, 2, 3} + wire = appendEscapedIAC(wire, payload) + wire = append(wire, iac, se, 'b') + d := newDecoder(2) + var output []byte + errorsSeen := 0 + for _, chunk := range streamChunks(wire, int(size)+1) { + data, events, err := d.feed(chunk) + output = append(output, data...) + if len(events) != 0 { + t.Fatalf("oversized SB produced events: %v", events) + } + if err != nil { + if !errors.Is(err, ErrSubnegotiationTooLong) { + t.Fatal(err) + } + errorsSeen++ + } + if len(d.subData) > 2 { + t.Fatal("unbounded SB data") + } + } + if string(output) != "ab" || errorsSeen != 1 { + t.Fatalf("data=%q errors=%d", output, errorsSeen) + } + }) +} diff --git a/docs/login-stream.md b/docs/login-stream.md new file mode 100644 index 0000000..69f4b66 --- /dev/null +++ b/docs/login-stream.md @@ -0,0 +1,82 @@ +# Login stream boundaries + +`Dial` and `NewClientConn` keep the existing public API. Login and the session +share a 4 KiB buffered application reader, after Telnet framing and any +`WrapStream` charset conversion. + +## Reads are not messages + +The login loop fills the buffer only when it is empty, then scans the bytes +already available using `Peek` and `Discard`. It does not call `ReadByte` in the +login loop, wait for a full 4 KiB block, or add sleeps. A short `Password:` prompt +can therefore receive a response immediately. + +Text normalization still examines bytes to carry ANSI, UTF-8, CR/LF, and +backspace state across chunks. It does not copy the entire current line or run +all regular expressions after each byte. + +Completed CRLF/LF lines are evaluated once. At the end of an available chunk, +only interactive prompt matching is allowed, and only for a candidate of at +most 512 bytes. Failure messages and password-expiry narratives wait for a line +ending, EOF, or the overall login timeout. For example: + +```text +Authentication failed count: 0 +Password expired policy: disabled +New password policy: 90 days +``` + +These remain banners even when a read stops immediately after `failed`, +`expired`, or `New password`. An unterminated explicit failure can be reported +at EOF or timeout; finalization never sends credentials or declares success on +a closed connection. + +An unfinished password-change input must end in `:` or `:` before being +classified. `New password:` is rejected without submitting the managed old +password. Custom `PasswordChangePrompt` and `FailurePrompt` still replace their +respective defaults; their narrative matches obey the same completion rules. +Other custom prompts remain full matches against the current normalized line. + +Overlong logical lines are ignored until a line reset or ending. They are not +converted into a sliding suffix that could accidentally look like a prompt. + +## Preserving session data + +Only the consumed portion of a chunk is discarded. Bytes after the accepted +shell prompt remain in the same reader. When CR/LF was consumed to establish a +line boundary, that one- or two-byte terminator is replayed before the buffered +session data. There is no second unbounded transcript or replay buffer. + +For `router#\r\nALARM`, the next `Client.Read` still sees `\r\nALARM`; the accepted +prompt is not replayed. ANSI formatting already consumed with the prompt is +not reconstructed. The interactive stream itself is not sanitized. + +A delimiter-less textual prompt can always be the prefix of a longer message. +No TCP buffer size or fixed delay can remove that ambiguity. The defaults use +bounded prompt shapes and known-banner filtering; unusual assets should use +precise platform-specific prompt expressions. This is not a protocol-level +proof of authentication. + +## Diagnostics and malformed subnegotiations + +Trace redaction is applied centrally to wire/application bytes and to both +received and generated `SB` payloads. Option names, lengths, and login-state +events remain visible. Redaction starts before `WrapStream` is invoked and +stays enabled through failed-login cleanup, including writer flushes. After a +successful login, normal trace behavior resumes; post-login traffic and the +standalone monitor can still contain secrets. + +An oversized `SB` reports `ErrSubnegotiationTooLong` and discards its payload +until the real `IAC SE`, including across reads. Escaped `IAC IAC` does not end +discarding. Following application bytes and valid negotiations in the same +chunk are retained. A caller that continues reading after the error never +receives the oversized subnegotiation's tail as terminal data. + +## Regression checks + +The added tests exercise deterministic one-byte, split-point, 1 KiB, and 4 KiB +reads, interactive `net.Pipe` prompts without newlines, EOF/timeout results, +UTF-8 and ANSI fragments, long banners, environment subnegotiation logs, and +cleanup writes. Fuzz targets check diagnostic-banner chunking and oversized +subnegotiation recovery. `BenchmarkLoginLongBanner` measures the 1 KiB, 4 KiB, +and 64 KiB banner paths without adding timing assertions to unit tests. diff --git a/login.go b/login.go index 779fbb2..07fc03a 100644 --- a/login.go +++ b/login.go @@ -3,6 +3,7 @@ package tclientlib import ( "bufio" "bytes" + "errors" "fmt" "io" "net" @@ -10,12 +11,15 @@ import ( "strings" "sync" "time" + "unicode" "unicode/utf8" ) const ( - maxContinueResponses = 3 + maxContinueResponses = 3 maxDefaultPromptBytes = 128 + loginReadBufferSize = 4096 + maxPartialPromptBytes = 512 ) var ( @@ -156,20 +160,48 @@ const ( loginPasswordChangeRequired ) +// Narrative results are only authoritative on a completed logical line. A +// Read boundary is not an end-of-line marker. +func (m *loginMachine) messageResult(line []byte, trace *tracer) loginResult { + if len(bytes.TrimSpace(line)) == 0 { + return loginPending + } + if fullLineMatch(m.patterns.passwordChange, line) { + trace.printf("login password-change message matched phase=%s", m.phase) + return loginPasswordChangeRequired + } + if fullLineMatch(m.patterns.failure, line) { + trace.printf("login failure message matched phase=%s", m.phase) + return loginFailed + } + return loginPending +} + func (m *loginMachine) handleLine(writer io.Writer, line []byte, trace *tracer) (loginResult, error) { + if result := m.messageResult(line, trace); result != loginPending { + return result, nil + } + return m.handlePrompt(writer, line, trace) +} + +// Interactive prompts normally have no newline. Check them only at the end of +// the currently available chunk, never after every character of a banner. +func (m *loginMachine) handlePrompt(writer io.Writer, line []byte, trace *tracer) (loginResult, error) { + if len(line) > maxPartialPromptBytes || !utf8.Valid(line) { + return loginPending, nil + } line = bytes.TrimSpace(line) if len(line) == 0 { return loginPending, nil } - if fullLineMatch(m.patterns.passwordChange, line) { - trace.printf("login password-change prompt matched phase=%s", m.phase) + // Require input punctuation for an incomplete password-change prompt. + // "New password policy: ..." must not match the prefix "New password". + if (bytes.HasSuffix(line, []byte(":")) || bytes.HasSuffix(line, []byte(":"))) && + fullLineMatch(m.patterns.passwordChange, line) { + trace.printf("login password-change input prompt matched phase=%s", m.phase) return loginPasswordChangeRequired, nil } - if fullLineMatch(m.patterns.failure, line) { - trace.printf("login failure prompt matched phase=%s", m.phase) - return loginFailed, nil - } if fullLineMatch(m.patterns.continuePrompt, line) { if m.continueResponses >= maxContinueResponses { trace.printf("login continue prompt ignored after limit phase=%s", m.phase) @@ -229,10 +261,13 @@ func defaultShellPromptMatches(line []byte) bool { if len(line) == 0 || len(line) > maxDefaultPromptBytes { return false } + if isBannerLikeShellPrompt(string(line)) { + return false + } if defaultMarkedBracketShellPrompt.Match(line) { return true } - if defaultTokenShellPrompt.Match(line) && !isBannerLikeShellPrompt(string(line)) { + if defaultTokenShellPrompt.Match(line) { return true } @@ -259,11 +294,12 @@ func isBannerLabel(value string) bool { func isBannerLikeShellPrompt(value string) bool { value = strings.TrimSpace(strings.TrimRight(value, "#$>%")) fields := strings.FieldsFunc(strings.ToLower(value), func(r rune) bool { - return !(r >= 'a' && r <= 'z') && !(r >= '0' && r <= '9') + return !unicode.IsLetter(r) && !unicode.IsNumber(r) }) for _, field := range fields { switch field { - case "login", "successful", "failed", "failure", "password", "warning", "notice", "error", "press", "hit", "unauthorized", "denied", "attempt", "attempts", "continue": + case "login", "successful", "failed", "failure", "password", "warning", "notice", "error", "press", "hit", "unauthorized", "denied", "attempt", "attempts", "continue", + "登录成功", "登录失败", "认证成功", "认证失败", "密码错误", "密码过期", "系统警告", "警告", "错误": return true } } @@ -279,52 +315,76 @@ func fullLineMatch(pattern *regexp.Regexp, line []byte) bool { return match != nil && match[0] == 0 && match[1] == len(line) } -func login(conn net.Conn, reader *bufio.Reader, writer io.Writer, config clientConfig, trace *tracer) error { +// login returns only a line terminator already consumed while confirming a +// prompt. All other bytes after the match stay in the shared buffered reader. +func login(conn net.Conn, reader *bufio.Reader, writer io.Writer, config clientConfig, trace *tracer) (replay string, err error) { machine := newLoginMachine(config) text := newLoginText(config.maxLoginBuffer) trace.printf("login start user=%t password=%t timeout=%s", config.User != "", config.Password != "", config.Timeout) guard := startLoginGuard(conn, config.Timeout) - defer guard.stop() + defer func() { + guard.stop() + // Do not return a usable Client if the timer won the completion race. + if guard.didExpire() && err == nil { + replay = "" + err = fmt.Errorf("%w after %s", ErrLoginTimeout, config.Timeout) + } + }() for { - value, readErr := reader.ReadByte() - if readErr == nil { - lines, current := text.feed([]byte{value}) - for _, line := range lines { - result, err := machine.handleLine(writer, line, trace) - if err != nil { - trace.printf("login write error: %v", err) - return err - } - if done, resultErr := resolveLoginResult(result, trace); done { - return resultErr + // Peek(1) fills the 4 KiB buffer without waiting for a full block. Scan + // what is already available, then discard only the bytes we consumed. + if _, readErr := reader.Peek(1); readErr != nil { + // A peer may close (or stop responding) after an error without a + // newline. Classify that final message, but never send credentials + // or infer success on a closed/timed-out connection. + if errors.Is(readErr, io.EOF) || guard.didExpire() { + if done, resultErr := resolveLoginResult(machine.messageResult(text.current(), trace), trace); done { + return "", resultErr } } - - if len(bytes.TrimSpace(current)) > 0 { - result, err := machine.handleLine(writer, current, trace) - if err != nil { - trace.printf("login write error: %v", err) - return err - } - if isLoginAction(result) { - text.consumeCurrent() - } - if done, resultErr := resolveLoginResult(result, trace); done { - return resultErr - } + if guard.didExpire() { + return "", fmt.Errorf("%w after %s", ErrLoginTimeout, config.Timeout) } - continue + return "", readErr + } + chunk, _ := reader.Peek(reader.Buffered()) + consumed, result, ending, scanErr := machine.consumeChunk(writer, text, chunk, trace) + _, _ = reader.Discard(consumed) + if scanErr != nil { + if guard.didExpire() { + return "", fmt.Errorf("%w after %s", ErrLoginTimeout, config.Timeout) + } + return "", scanErr + } + if done, resultErr := resolveLoginResult(result, trace); done { + return ending, resultErr } + } +} - if guard.didExpire() { - trace.printf("login timeout after %s", config.Timeout) - return fmt.Errorf("%w after %s", ErrLoginTimeout, config.Timeout) +func (m *loginMachine) consumeChunk(writer io.Writer, text *loginText, chunk []byte, trace *tracer) (int, loginResult, string, error) { + for index, value := range chunk { + line, ending := text.feedByte(value) + if ending == "" { + continue } - trace.printf("login read error: %v", readErr) - return readErr + result, err := m.handleLine(writer, line, trace) + if err != nil || result == loginSuccess || result == loginFailed || result == loginPasswordChangeRequired { + return index + 1, result, ending, err + } + } + + result, err := m.handlePrompt(writer, text.current(), trace) + ending := "" + if result == loginSuccess && text.pendingCR { + ending = "\r" } + if isLoginAction(result) { + text.consumeCurrent() + } + return len(chunk), result, ending, err } func isLoginAction(result loginResult) bool { @@ -374,136 +434,3 @@ func writeFull(writer io.Writer, data []byte) error { } return nil } - -type loginEscapeState uint8 - -const ( - loginEscapeNone loginEscapeState = iota - loginEscapeStart - loginEscapeCSI - loginEscapeOSC - loginEscapeOSCEscape -) - -type loginText struct { - line []byte - maxLine int - pendingCR bool - escape loginEscapeState -} - -func newLoginText(maxLine int) *loginText { - return &loginText{maxLine: maxLine} -} - -func (t *loginText) feed(input []byte) ([][]byte, []byte) { - lines := make([][]byte, 0, 2) - for _, value := range input { - if t.consumeEscape(value) { - continue - } - - if t.pendingCR { - t.pendingCR = false - if value == '\n' { - lines = append(lines, t.finishLine()) - continue - } - // A bare carriage return moves the terminal cursor back to column 0. - t.line = t.line[:0] - } - - switch value { - case 0, '\a': - continue - case '\x1b': - t.escape = loginEscapeStart - case '\r': - t.pendingCR = true - case '\n': - lines = append(lines, t.finishLine()) - case '\b', 0x7f: - t.removeLastRune() - case '\t': - t.appendByte(' ') - default: - if value < 0x20 { - continue - } - t.appendByte(value) - } - } - return lines, append([]byte(nil), t.line...) -} - -func (t *loginText) consumeEscape(value byte) bool { - switch t.escape { - case loginEscapeNone: - return false - case loginEscapeStart: - switch value { - case '[': - t.escape = loginEscapeCSI - case ']': - t.escape = loginEscapeOSC - default: - t.escape = loginEscapeNone - } - return true - case loginEscapeCSI: - if value >= 0x40 && value <= 0x7e { - t.escape = loginEscapeNone - } - return true - case loginEscapeOSC: - switch value { - case '\a': - t.escape = loginEscapeNone - case '\x1b': - t.escape = loginEscapeOSCEscape - } - return true - case loginEscapeOSCEscape: - if value == '\\' { - t.escape = loginEscapeNone - } else { - t.escape = loginEscapeOSC - } - return true - default: - t.escape = loginEscapeNone - return true - } -} - -func (t *loginText) finishLine() []byte { - line := append([]byte(nil), t.line...) - t.line = t.line[:0] - return line -} - -func (t *loginText) consumeCurrent() { - t.line = t.line[:0] - t.pendingCR = false -} - -func (t *loginText) appendByte(value byte) { - t.line = append(t.line, value) - if len(t.line) <= t.maxLine { - return - } - drop := len(t.line) - t.maxLine - copy(t.line, t.line[drop:]) - t.line = t.line[:t.maxLine] -} - -func (t *loginText) removeLastRune() { - if len(t.line) == 0 { - return - } - _, size := utf8.DecodeLastRune(t.line) - if size <= 0 { - size = 1 - } - t.line = t.line[:len(t.line)-size] -} diff --git a/login_stream_test.go b/login_stream_test.go new file mode 100644 index 0000000..662546d --- /dev/null +++ b/login_stream_test.go @@ -0,0 +1,289 @@ +package tclientlib + +import ( + "bytes" + "errors" + "fmt" + "io" + "net" + "strings" + "sync/atomic" + "testing" + "time" +) + +// streamTestConn makes Read boundaries deterministic without treating them as +// message boundaries. Separate net.Pipe tests exercise interactive blocking. +type streamTestConn struct { + chunks [][]byte + writes bytes.Buffer + closed atomic.Bool + lastError error +} + +func (c *streamTestConn) Read(p []byte) (int, error) { + if c.closed.Load() { + return 0, net.ErrClosed + } + for len(c.chunks) > 0 && len(c.chunks[0]) == 0 { + c.chunks = c.chunks[1:] + } + if len(c.chunks) == 0 { + return 0, io.EOF + } + n := copy(p, c.chunks[0]) + c.chunks[0] = c.chunks[0][n:] + if len(c.chunks[0]) == 0 { + c.chunks = c.chunks[1:] + if len(c.chunks) == 0 && c.lastError != nil { + return n, c.lastError + } + } + return n, nil +} + +func (c *streamTestConn) Write(p []byte) (int, error) { + if c.closed.Load() { + return 0, net.ErrClosed + } + return c.writes.Write(p) +} +func (c *streamTestConn) Close() error { c.closed.Store(true); return nil } +func (c *streamTestConn) LocalAddr() net.Addr { return streamTestAddr("local") } +func (c *streamTestConn) RemoteAddr() net.Addr { return streamTestAddr("remote") } +func (c *streamTestConn) SetDeadline(time.Time) error { return nil } +func (c *streamTestConn) SetReadDeadline(time.Time) error { return nil } +func (c *streamTestConn) SetWriteDeadline(time.Time) error { return nil } + +type streamTestAddr string + +func (a streamTestAddr) Network() string { return "test" } +func (a streamTestAddr) String() string { return string(a) } + +func streamChunks(data []byte, size int) [][]byte { + var chunks [][]byte + for len(data) > 0 { + n := size + if n > len(data) { + n = len(data) + } + chunks = append(chunks, data[:n]) + data = data[n:] + } + return chunks +} + +func checkBannerStream(t *testing.T, parts [][]byte) { + t.Helper() + conn := &streamTestConn{chunks: parts, lastError: io.EOF} + client, err := NewClientConn(conn, &ClientConfig{User: "admin", Password: "secret", Timeout: 5 * time.Second}) + if err != nil { + t.Fatalf("login: %v", err) + } + defer client.Close() + got, err := io.ReadAll(client) + if err != nil { + t.Fatal(err) + } + if string(got) != "\r\nTAIL" { + t.Fatalf("unconsumed data = %q, want CRLF+TAIL", got) + } + if conn.writes.String() != "admin\r\nsecret\r\n" { + t.Fatalf("writes = %q", conn.writes.String()) + } +} + +func TestLoginStreamDoesNotClassifyMessagePrefixes(t *testing.T) { + for _, banner := range []string{ + "Authentication failed count: 0", "Authentication failure count: 0", + "Login failed attempts: 5", "Password expired policy: disabled", + "New password policy: 90 days", "密码已过期检查:正常", + "登录成功 >", "密码错误>", "系统警告 >", "[Warning]#", + } { + t.Run(banner, func(t *testing.T) { + data := []byte("Login:\r\nPassword:\r\n" + banner + "\r\nrouter#\r\nTAIL") + for _, size := range []int{1, 2, 7, 1024, 4096} { + t.Run(fmt.Sprint(size), func(t *testing.T) { + checkBannerStream(t, streamChunks(data, size)) + }) + } + }) + } +} + +func TestLoginStreamEverySplitPreservesTail(t *testing.T) { + data := []byte("Login:\r\nPassword:\r\nPassword expired policy: disabled\r\nrouter#\r\nTAIL") + for split := 0; split <= len(data); split++ { + t.Run(fmt.Sprint(split), func(t *testing.T) { + checkBannerStream(t, [][]byte{data[:split], data[split:]}) + }) + } +} + +func TestLoginStreamFinalMessages(t *testing.T) { + for _, tc := range []struct { + message string + want error + }{ + {"Login incorrect", ErrLoginFailed}, + {"Authentication failed", ErrLoginFailed}, + {"Your password has expired", ErrPasswordChangeRequired}, + {"New password:", ErrPasswordChangeRequired}, + } { + for _, ending := range []string{"", "\r\n"} { + t.Run(tc.message+fmt.Sprintf("/%q", ending), func(t *testing.T) { + conn := &streamTestConn{chunks: streamChunks([]byte("Password:\r\n"+tc.message+ending), 2), lastError: io.EOF} + _, err := NewClientConn(conn, &ClientConfig{Password: "secret", Timeout: time.Second}) + if !errors.Is(err, tc.want) { + t.Fatalf("got %v, want %v", err, tc.want) + } + if conn.writes.String() != "secret\r\n" { + t.Fatalf("writes = %q", conn.writes.String()) + } + }) + } + } +} + +func TestLoginStreamPercentFailureIsNotShell(t *testing.T) { + conn := &streamTestConn{chunks: [][]byte{[]byte("Password:"), []byte("\r\n% Bad passwords\r\n")}} + _, err := NewClientConn(conn, &ClientConfig{Password: "secret", Timeout: time.Second}) + if !errors.Is(err, ErrLoginFailed) { + t.Fatalf("got %v, want ErrLoginFailed", err) + } +} + +func TestLoginStreamLongLineCannotBecomePrompt(t *testing.T) { + data := []byte("Login:\r\nPassword:\r\n" + strings.Repeat("x", 65537) + "router#\r\nrouter#\r\nTAIL") + checkBannerStream(t, streamChunks(data, 4096)) +} + +func TestLoginStreamInteractivePromptsWithoutNewlines(t *testing.T) { + server, raw := net.Pipe() + t.Cleanup(func() { server.Close(); raw.Close() }) + done := make(chan error, 1) + go func() { + defer server.Close() + _ = server.SetDeadline(time.Now().Add(2 * time.Second)) + steps := []struct{ prompt, reply string }{ + {"Lo", ""}, {"gin:", "admin\r\n"}, + {"\x1b[31mPass", ""}, {"word:\x1b[0m", "secret\r\n"}, + {"Press ENTER to continue", "\r\n"}, + } + for _, step := range steps { + if _, err := io.WriteString(server, step.prompt); err != nil { + done <- err + return + } + if step.reply != "" { + got := make([]byte, len(step.reply)) + _, err := io.ReadFull(server, got) + if err != nil { + done <- err + return + } + if string(got) != step.reply { + done <- fmt.Errorf("reply %q, want %q", got, step.reply) + return + } + } + } + _, err := io.WriteString(server, "核心交换机#\r\nALARM") + done <- err + }() + client, err := NewClientConn(raw, &ClientConfig{User: "admin", Password: "secret", Timeout: time.Second}) + if err != nil { + t.Fatal(err) + } + defer client.Close() + output, err := io.ReadAll(client) + if err != nil { + t.Fatal(err) + } + if string(output) != "\r\nALARM" { + t.Fatalf("tail=%q", output) + } + if err := <-done; err != nil { + t.Fatal(err) + } +} + +func TestLoginStreamUnterminatedMessageAtTimeout(t *testing.T) { + server, raw := net.Pipe() + t.Cleanup(func() { server.Close(); raw.Close() }) + done := make(chan struct{}) + go func() { + defer close(done) + defer server.Close() + _ = server.SetDeadline(time.Now().Add(time.Second)) + _, _ = io.WriteString(server, "Your password has expired") + var one [1]byte + _, _ = server.Read(one[:]) + }() + _, err := NewClientConn(raw, &ClientConfig{Password: "secret", Timeout: 50 * time.Millisecond}) + if !errors.Is(err, ErrPasswordChangeRequired) { + t.Fatalf("got %v", err) + } + <-done +} + +func TestLoginStreamColorAndUTF8Fragments(t *testing.T) { + data := []byte("Login:\r\nPassword:\r\n\x1b]0;title\a\x1b[32m核心交换机#\x1b[0m\r\nTAIL") + for _, size := range []int{1, 2, 5, 4096} { + conn := &streamTestConn{chunks: streamChunks(data, size)} + client, err := NewClientConn(conn, &ClientConfig{User: "admin", Password: "secret", Timeout: time.Second}) + if err != nil { + t.Fatal(err) + } + got, err := io.ReadAll(client) + client.Close() + if err != nil { + t.Fatal(err) + } + // A color reset arriving after the prompt is valid session data. + got = bytes.ReplaceAll(got, []byte("\x1b[0m"), nil) + if string(got) != "\r\nTAIL" { + t.Fatalf("chunk %d tail=%q", size, got) + } + } +} + +func FuzzLoginDiagnosticChunking(f *testing.F) { + f.Add(uint16(1), uint8(0), "0") + f.Add(uint16(4096), uint8(1), "disabled") + f.Fuzz(func(t *testing.T, size uint16, choice uint8, suffix string) { + if len(suffix) > 2048 { + suffix = suffix[:2048] + } + suffix = strings.Map(func(r rune) rune { + if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == ' ' { + return r + } + return -1 + }, suffix) + prefixes := []string{"Authentication failed count: ", "Password expired policy: ", "New password policy: "} + data := []byte("Login:\r\nPassword:\r\n" + prefixes[int(choice)%len(prefixes)] + suffix + "\r\nrouter#\r\nTAIL") + checkBannerStream(t, streamChunks(data, int(size)%4096+1)) + }) +} + +func BenchmarkLoginLongBanner(b *testing.B) { + for _, size := range []int{1024, 4096, 65536} { + b.Run(fmt.Sprint(size), func(b *testing.B) { + data := []byte(strings.Repeat("x", size) + "\r\nrouter#") + config, _ := prepareConfig(&ClientConfig{Password: "secret"}) + b.SetBytes(int64(len(data))) + b.ReportAllocs() + for n := 0; n < b.N; n++ { + machine := newLoginMachine(config) + text := newLoginText(config.maxLoginBuffer) + for _, chunk := range streamChunks(data, 4096) { + _, _, _, err := machine.consumeChunk(io.Discard, text, chunk, nil) + if err != nil { + b.Fatal(err) + } + } + } + }) + } +} diff --git a/login_text.go b/login_text.go new file mode 100644 index 0000000..b1c30cf --- /dev/null +++ b/login_text.go @@ -0,0 +1,149 @@ +package tclientlib + +import "unicode/utf8" + +type loginEscapeState uint8 + +const ( + loginEscapeNone loginEscapeState = iota + loginEscapeStart + loginEscapeCSI + loginEscapeOSC + loginEscapeOSCEscape +) + +type loginText struct { + line []byte + maxLine int + pendingCR bool + escape loginEscapeState + overflow bool +} + +func newLoginText(maxLine int) *loginText { + return &loginText{maxLine: maxLine} +} + +// feedByte performs only bounded, linear text normalization. Returned line +// slices alias the scanner and are valid until the next call. CRLF/LF complete +// a line; a bare CR overwrites it. Read/chunk boundaries never complete a line. +func (t *loginText) feedByte(value byte) ([]byte, string) { + if t.consumeEscape(value) { + return nil, "" + } + if value == 0 || value == '\a' { + return nil, "" + } + if value == '\x1b' { + t.escape = loginEscapeStart + return nil, "" + } + if t.pendingCR { + t.pendingCR = false + if value == '\n' { + return t.finishLine(), "\r\n" + } + t.line = t.line[:0] + t.overflow = false + } + switch value { + case '\r': + t.pendingCR = true + case '\n': + return t.finishLine(), "\n" + case '\b', 0x7f: + t.removeLastRune() + case '\t': + t.appendByte(' ') + default: + if value >= 0x20 { + t.appendByte(value) + } + } + return nil, "" +} + +// Incomplete escape sequences survive chunk boundaries. UTF-8 validation is +// performed only on bounded prompt candidates, not on every long-line prefix. +func (t *loginText) current() []byte { + if t.overflow || t.escape != loginEscapeNone { + return nil + } + return t.line +} + +func (t *loginText) consumeEscape(value byte) bool { + switch t.escape { + case loginEscapeNone: + return false + case loginEscapeStart: + switch value { + case '[': + t.escape = loginEscapeCSI + case ']': + t.escape = loginEscapeOSC + default: + t.escape = loginEscapeNone + } + return true + case loginEscapeCSI: + if value >= 0x40 && value <= 0x7e { + t.escape = loginEscapeNone + } + return true + case loginEscapeOSC: + switch value { + case '\a': + t.escape = loginEscapeNone + case '\x1b': + t.escape = loginEscapeOSCEscape + } + return true + case loginEscapeOSCEscape: + if value == '\\' { + t.escape = loginEscapeNone + } else { + t.escape = loginEscapeOSC + } + return true + default: + t.escape = loginEscapeNone + return true + } +} + +func (t *loginText) finishLine() []byte { + line := t.current() + t.line = t.line[:0] + t.overflow = false + return line +} + +func (t *loginText) consumeCurrent() { + t.line = t.line[:0] + t.pendingCR = false + t.overflow = false +} + +func (t *loginText) appendByte(value byte) { + if len(t.line) >= t.maxLine { + // Do not slide a truncated banner's suffix into a synthetic prompt. + // Ignore this logical line until its next reset or line boundary. + t.overflow = true + return + } + if !t.overflow { + t.line = append(t.line, value) + } +} + +func (t *loginText) removeLastRune() { + if len(t.line) == 0 { + return + } + _, size := utf8.DecodeLastRune(t.line) + if size <= 0 { + size = 1 + } + t.line = t.line[:len(t.line)-size] +} diff --git a/trace.go b/trace.go index 44998ce..aaceb02 100644 --- a/trace.go +++ b/trace.go @@ -4,13 +4,15 @@ import ( "fmt" "io" "sync" + "sync/atomic" ) const maxTraceBytes = 256 type tracer struct { - writer io.Writer - mu sync.Mutex + writer io.Writer + mu sync.Mutex + redactPayloads atomic.Bool } func newTracer(writer io.Writer) *tracer { @@ -20,6 +22,14 @@ func newTracer(writer io.Writer) *tracer { return &tracer{writer: writer} } +// Redaction lives at the logging boundary so no wire, application, or SB +// payload path can accidentally bypass it during authentication. +func (t *tracer) setRedaction(enabled bool) { + if t != nil { + t.redactPayloads.Store(enabled) + } +} + func (t *tracer) printf(format string, args ...interface{}) { if t == nil { return @@ -33,6 +43,10 @@ func (t *tracer) bytes(scope, direction string, data []byte) { if t == nil || len(data) == 0 { return } + if t.redactPayloads.Load() { + t.redacted(scope, direction, len(data)) + return + } sample, suffix := boundedTraceData(data) t.printf("%s %s len=%d data=% x%s", scope, direction, len(data), sample, suffix) } @@ -53,9 +67,7 @@ func (t *tracer) event(direction string, event telnetEvent) { t.printf("event %s %s %s", direction, telnetCommandName(event.command), telnetOptionName(event.option)) case subnegotiationEvent: - sample, suffix := boundedTraceData(event.parameters) - t.printf("event %s SB %s len=%d data=% x%s", direction, - telnetOptionName(event.option), len(event.parameters), sample, suffix) + t.subnegotiation("event", direction, event.option, event.parameters) case commandEvent: t.printf("event %s %s", direction, telnetCommandName(event.command)) } @@ -66,15 +78,24 @@ func (t *tracer) packet(direction string, packet optionPacket) { return } if packet.command == sb { - sample, suffix := boundedTraceData(packet.parameters) - t.printf("packet %s SB %s len=%d data=% x%s", direction, - telnetOptionName(packet.option), len(packet.parameters), sample, suffix) + t.subnegotiation("packet", direction, packet.option, packet.parameters) return } t.printf("packet %s %s %s", direction, telnetCommandName(packet.command), telnetOptionName(packet.option)) } +func (t *tracer) subnegotiation(kind, direction string, option byte, data []byte) { + if t.redactPayloads.Load() { + t.printf("%s %s SB %s len=%d data=", kind, direction, + telnetOptionName(option), len(data)) + return + } + sample, suffix := boundedTraceData(data) + t.printf("%s %s SB %s len=%d data=% x%s", kind, direction, + telnetOptionName(option), len(data), sample, suffix) +} + func boundedTraceData(data []byte) ([]byte, string) { if len(data) <= maxTraceBytes { return data, "" diff --git a/trace_secrecy_test.go b/trace_secrecy_test.go new file mode 100644 index 0000000..1406d65 --- /dev/null +++ b/trace_secrecy_test.go @@ -0,0 +1,75 @@ +package tclientlib + +import ( + "bytes" + "errors" + "fmt" + "io" + "strings" + "testing" + "time" +) + +func assertNoTracePayload(t *testing.T, output string, values ...string) { + t.Helper() + for _, value := range values { + if strings.Contains(output, value) || strings.Contains(output, fmt.Sprintf("% x", []byte(value))) { + t.Fatalf("trace contains protected payload %q:\n%s", value, output) + } + } +} + +func TestLoginTraceRedactsSubnegotiationParameters(t *testing.T) { + var trace bytes.Buffer + wire := []byte{iac, do, newEnvironment, iac, sb, newEnvironment, 1, iac, se} + wire = append(wire, (optionPacket{command: sb, option: newEnvironment, parameters: []byte("\x00received-secret")}).bytes()...) + wire = append(wire, []byte("Login:\r\nPassword:\r\nrouter#")...) + conn := &streamTestConn{chunks: [][]byte{wire}} + client, err := NewClientConn(conn, &ClientConfig{User: "private-user", Password: "private-pass", Trace: &trace, Timeout: time.Second}) + if err != nil { + t.Fatal(err) + } + defer client.Close() + assertNoTracePayload(t, trace.String(), "private-user", "private-pass", "received-secret") + for _, want := range []string{"event recv SB NEW_ENVIRON", "packet send SB NEW_ENVIRON", "login success", ""} { + if !strings.Contains(trace.String(), want) { + t.Fatalf("missing %q:\n%s", want, trace.String()) + } + } + trace.Reset() + if _, err := client.Write([]byte("show")); err != nil { + t.Fatal(err) + } + if !strings.Contains(trace.String(), "73 68 6f 77") { + t.Fatalf("post-login trace not restored: %s", trace.String()) + } +} + +type traceFlushWriter struct{ io.Writer } + +func (w traceFlushWriter) Close() error { + _, err := w.Write([]byte("cleanup-secret")) + return err +} + +func TestFailedLoginTraceStaysRedactedThroughWrapperClose(t *testing.T) { + var trace bytes.Buffer + conn := &streamTestConn{chunks: [][]byte{[]byte("Password:\r\nLogin incorrect\r\n")}} + _, err := NewClientConn(conn, &ClientConfig{ + Password: "private-pass", Trace: &trace, Timeout: time.Second, + WrapStream: func(r io.Reader, w io.Writer) (io.Reader, io.Writer) { + _, _ = w.Write([]byte("setup-secret")) + return r, traceFlushWriter{w} + }, + }) + if !errors.Is(err, ErrLoginFailed) { + t.Fatalf("got %v", err) + } + if !conn.closed.Load() { + t.Fatal("connection not closed") + } + assertNoTracePayload(t, trace.String(), "private-pass", "setup-secret", "cleanup-secret") + if !strings.Contains(conn.writes.String(), "cleanup-secret") { + t.Fatal("close did not exercise buffered write") + } +} diff --git a/transport.go b/transport.go index 2bed404..d22f0bb 100644 --- a/transport.go +++ b/transport.go @@ -6,7 +6,6 @@ import ( "io" "net" "sync" - "sync/atomic" ) type transport struct { @@ -22,7 +21,6 @@ type transport struct { pending []byte pendingErr error - traceRedacted atomic.Bool localOptions [256]bool remoteOptions [256]bool } @@ -38,7 +36,7 @@ func newTransport(conn net.Conn, config transportConfig) *transport { } func (t *transport) setTraceRedaction(enabled bool) { - t.traceRedacted.Store(enabled) + t.trace.setRedaction(enabled) } func (t *transport) Read(p []byte) (int, error) { @@ -67,17 +65,9 @@ func (t *transport) Read(p []byte) (int, error) { n, readErr := t.conn.Read(t.readBuffer) if n > 0 { raw := t.readBuffer[:n] - if t.traceRedacted.Load() { - t.trace.redacted("wire", "recv", len(raw)) - } else { - t.trace.bytes("wire", "recv", raw) - } + t.trace.bytes("wire", "recv", raw) data, events, decodeErr := t.decoder.feed(raw) - if t.traceRedacted.Load() { - t.trace.redacted("application", "recv", len(data)) - } else { - t.trace.bytes("application", "recv", data) - } + t.trace.bytes("application", "recv", data) t.pending = append(t.pending, data...) for _, event := range events { t.trace.event("recv", event) @@ -101,11 +91,7 @@ func (t *transport) Read(p []byte) (int, error) { } func (t *transport) Write(p []byte) (int, error) { - if t.traceRedacted.Load() { - t.trace.redacted("application", "send", len(p)) - } else { - t.trace.bytes("application", "send", p) - } + t.trace.bytes("application", "send", p) encoded := appendEscapedIAC(make([]byte, 0, len(p)), p) written, err := t.writeRaw(encoded) if err != nil { @@ -307,11 +293,7 @@ func (t *transport) writeRaw(data []byte) (int, error) { for len(data) > 0 { n, err := t.conn.Write(data) if n > 0 { - if t.traceRedacted.Load() { - t.trace.redacted("wire", "send", n) - } else { - t.trace.bytes("wire", "send", data[:n]) - } + t.trace.bytes("wire", "send", data[:n]) } total += n data = data[n:]