diff options
Diffstat (limited to 'internal/nntp')
| -rw-r--r-- | internal/nntp/client.go | 983 | ||||
| -rw-r--r-- | internal/nntp/client_test.go | 357 |
2 files changed, 1340 insertions, 0 deletions
diff --git a/internal/nntp/client.go b/internal/nntp/client.go new file mode 100644 index 0000000..5958628 --- /dev/null +++ b/internal/nntp/client.go @@ -0,0 +1,983 @@ +package nntp + +import ( + "bufio" + "compress/flate" + "context" + "crypto/tls" + "encoding/base64" + "errors" + "fmt" + "io" + "net" + "strconv" + "strings" + "sync" + "time" + "unicode/utf8" + + "golang.org/x/net/proxy" +) + +const ( + defaultTimeout = 30 * time.Second + maxResponseLine = 1 << 20 + maxMultilineBytes = 64 << 20 + maxMultilineLines = 1_000_000 + maxPostBytes = 10 << 20 + maxPostLineBytes = 998 +) + +var tls12AESGCMSuites = []uint16{ + tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256, + tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384, + tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256, + tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384, +} + +type DialConfig struct { + Host string + Port string + UseTLS bool + StartTLS bool + InsecureSkipVerify bool + Username string + Password string + SASLMechanism string + UseCompression bool + ProxyType string + ProxyAddress string + Timeout time.Duration + tlsConfig *tls.Config +} + +type GroupInfo struct { + Name string + Low int64 + High int64 + EstimatedPost int64 + Posting string +} + +type GroupStatus struct { + Name string + Count int64 + Low int64 + High int64 +} + +type ArticleHeader struct { + Number int64 + Subject string + From string + Date string + MessageID string + References string + Bytes int64 + Lines int64 +} + +type HeaderValue struct { + Number int64 + Value string +} + +type GroupDescription struct { + Name string + Description string +} + +type ServerDate struct { + Date string + Time string +} + +type ResponseError struct { + Code int + Message string +} + +func (e *ResponseError) Error() string { + return fmt.Sprintf("NNTP response %d: %s", e.Code, e.Message) +} + +type Client struct { + mu sync.Mutex + conn net.Conn + reader *bufio.Reader + writer *bufio.Writer + timeout time.Duration + closed bool +} + +func Dial(ctx context.Context, cfg DialConfig) (*Client, error) { + if err := validateDialConfig(cfg); err != nil { + return nil, err + } + if cfg.UseCompression && cfg.UseTLS { + return nil, errors.New("COMPRESS DEFLATE cannot be used with TLS") + } + timeout := cfg.Timeout + if timeout <= 0 { + timeout = defaultTimeout + } + target := net.JoinHostPort(cfg.Host, cfg.Port) + netDialer := &net.Dialer{Timeout: timeout, KeepAlive: 30 * time.Second} + var raw net.Conn + var err error + if strings.EqualFold(cfg.ProxyType, "SOCKS5") { + proxyDialer, proxyErr := proxy.SOCKS5("tcp", cfg.ProxyAddress, nil, netDialer) + if proxyErr != nil { + return nil, fmt.Errorf("configure SOCKS5 proxy: %w", proxyErr) + } + raw, err = proxyDialer.Dial("tcp", target) + } else { + raw, err = netDialer.DialContext(ctx, "tcp", target) + } + if err != nil { + return nil, fmt.Errorf("connect to NNTP server: %w", err) + } + conn := raw + if cfg.UseTLS && !cfg.StartTLS { + tlsConfig := makeTLSConfig(cfg) + tlsConn := tls.Client(raw, tlsConfig) + if err := tlsConn.HandshakeContext(ctx); err != nil { + raw.Close() + return nil, fmt.Errorf("complete TLS handshake: %w", err) + } + conn = tlsConn + } + client := &Client{ + conn: conn, + reader: bufio.NewReaderSize(conn, 64*1024), + writer: bufio.NewWriterSize(conn, 64*1024), + timeout: timeout, + } + if err := client.setDeadline(); err != nil { + client.Close() + return nil, err + } + code, message, err := client.readResponse() + if err != nil { + client.Close() + return nil, fmt.Errorf("read NNTP greeting: %w", err) + } + if code != 200 && code != 201 { + client.Close() + return nil, &ResponseError{Code: code, Message: message} + } + if cfg.StartTLS { + code, message, err = client.command("STARTTLS") + if err != nil { + client.Close() + return nil, err + } + if code != 382 { + client.Close() + return nil, &ResponseError{Code: code, Message: message} + } + tlsConn := tls.Client(client.conn, makeTLSConfig(cfg)) + if err := tlsConn.HandshakeContext(ctx); err != nil { + _ = client.conn.Close() + return nil, fmt.Errorf("complete STARTTLS handshake: %w", err) + } + client.conn = tlsConn + client.reader = bufio.NewReaderSize(tlsConn, 64*1024) + client.writer = bufio.NewWriterSize(tlsConn, 64*1024) + if err := client.setDeadline(); err != nil { + client.Close() + return nil, err + } + } + if cfg.Username != "" { + if err := client.authenticateWithConfig(cfg); err != nil { + client.Close() + return nil, err + } + } + if cfg.UseCompression { + if err := client.enableCompression(); err != nil { + client.Close() + return nil, err + } + } + if code, message, err = client.command("MODE READER"); err != nil { + client.Close() + return nil, err + } + if code != 200 && code != 201 && code != 500 && code != 501 { + client.Close() + return nil, &ResponseError{Code: code, Message: message} + } + _ = client.conn.SetDeadline(time.Time{}) + return client, nil +} + +func (c *Client) Capabilities() ([]string, error) { + c.mu.Lock() + defer c.mu.Unlock() + lines, err := c.capabilitiesUnlocked() + if responseCode(err) == 500 || responseCode(err) == 501 { + return nil, nil + } + return lines, err +} + +func (c *Client) capabilitiesUnlocked() ([]string, error) { + return c.multilineCommand("CAPABILITIES", 101) +} + +func (c *Client) Help() ([]string, error) { + c.mu.Lock() + defer c.mu.Unlock() + return c.multilineCommand("HELP", 100) +} + +func (c *Client) Date() (ServerDate, error) { + c.mu.Lock() + defer c.mu.Unlock() + code, message, err := c.commandUnlocked("DATE") + if err != nil { + return ServerDate{}, err + } + if code != 111 { + return ServerDate{}, &ResponseError{Code: code, Message: message} + } + fields := strings.Fields(message) + if len(fields) < 2 { + return ServerDate{}, errors.New("malformed DATE response") + } + return ServerDate{Date: fields[0], Time: fields[1]}, nil +} + +func (c *Client) ListOverviewFormat() ([]string, error) { + c.mu.Lock() + defer c.mu.Unlock() + return c.multilineCommand("LIST OVERVIEW.FMT", 215) +} + +func (c *Client) ListNewsGroups(pattern string) ([]GroupDescription, error) { + if strings.ContainsAny(pattern, "\r\n") { + return nil, errors.New("invalid LIST NEWSGROUPS pattern") + } + command := "LIST NEWSGROUPS" + if strings.TrimSpace(pattern) != "" { + command += " " + strings.TrimSpace(pattern) + } + c.mu.Lock() + defer c.mu.Unlock() + lines, err := c.multilineCommand(command, 215) + if err != nil { + return nil, err + } + groups := make([]GroupDescription, 0, len(lines)) + for _, line := range lines { + fields := strings.SplitN(line, " ", 2) + if len(fields) == 0 || !validAtom(fields[0]) { + continue + } + description := "" + if len(fields) == 2 { + description = strings.TrimSpace(fields[1]) + } + groups = append(groups, GroupDescription{Name: fields[0], Description: description}) + } + return groups, nil +} + +func (c *Client) NewGroups(date, clock, timezone string) ([]string, error) { + if !validCommandValue(date) || !validCommandValue(clock) || !validCommandValue(timezone) { + return nil, errors.New("invalid NEWGROUPS arguments") + } + c.mu.Lock() + defer c.mu.Unlock() + return c.multilineCommand("NEWGROUPS "+date+" "+clock+" "+timezone, 231) +} + +func (c *Client) NewNews(groups, date, clock, timezone string) ([]string, error) { + if !validCommandValue(groups) || !validCommandValue(date) || !validCommandValue(clock) || !validCommandValue(timezone) { + return nil, errors.New("invalid NEWNEWS arguments") + } + c.mu.Lock() + defer c.mu.Unlock() + return c.multilineCommand("NEWNEWS "+groups+" "+date+" "+clock+" "+timezone, 230) +} + +func (c *Client) ListGroup(group string) ([]int64, error) { + if !validAtom(group) { + return nil, errors.New("invalid newsgroup name") + } + c.mu.Lock() + defer c.mu.Unlock() + lines, err := c.multilineCommand("LISTGROUP "+group, 211) + if err != nil { + return nil, err + } + articles := make([]int64, 0, len(lines)) + for _, line := range lines { + for _, token := range strings.Fields(line) { + parts := strings.SplitN(token, "-", 2) + first, parseErr := strconv.ParseInt(parts[0], 10, 64) + if parseErr != nil || first < 1 { + continue + } + last := first + if len(parts) == 2 { + last, parseErr = strconv.ParseInt(parts[1], 10, 64) + if parseErr != nil || last < first || last-first > 100000 { + continue + } + } + for number := first; number <= last; number++ { + articles = append(articles, number) + } + } + } + return articles, nil +} + +func (c *Client) Head(number int64) (string, error) { + return c.singleArticlePart("HEAD", number, 221) +} + +func (c *Client) Body(number int64) (string, error) { + return c.singleArticlePart("BODY", number, 222) +} + +func (c *Client) Stat(number int64) (string, error) { + if number < 1 { + return "", errors.New("invalid article number") + } + c.mu.Lock() + defer c.mu.Unlock() + code, message, err := c.commandUnlocked("STAT " + strconv.FormatInt(number, 10)) + if err != nil { + return "", err + } + if code != 223 { + return "", &ResponseError{Code: code, Message: message} + } + return message, nil +} + +func (c *Client) Header(field string, first, last int64) ([]HeaderValue, error) { + if !validAtom(field) || first < 1 || last < first || last-first > 10000 { + return nil, errors.New("invalid HDR request") + } + c.mu.Lock() + defer c.mu.Unlock() + lines, err := c.multilineCommand("HDR "+field+" "+strconv.FormatInt(first, 10)+"-"+strconv.FormatInt(last, 10), 225) + if err != nil { + return nil, err + } + values := make([]HeaderValue, 0, len(lines)) + for _, line := range lines { + fields := strings.SplitN(line, "\t", 2) + if len(fields) != 2 { + continue + } + number, parseErr := strconv.ParseInt(fields[0], 10, 64) + if parseErr != nil || number < 1 { + continue + } + values = append(values, HeaderValue{Number: number, Value: fields[1]}) + } + return values, nil +} + +func (c *Client) singleArticlePart(command string, number int64, expected int) (string, error) { + if number < 1 { + return "", errors.New("invalid article number") + } + c.mu.Lock() + defer c.mu.Unlock() + lines, err := c.multilineCommand(command+" "+strconv.FormatInt(number, 10), expected) + if err != nil { + return "", err + } + return strings.Join(lines, "\n"), nil +} + +func (c *Client) ListActive() ([]GroupInfo, error) { + c.mu.Lock() + defer c.mu.Unlock() + lines, err := c.multilineCommand("LIST ACTIVE", 215) + if err != nil { + return nil, err + } + groups := make([]GroupInfo, 0, len(lines)) + for _, line := range lines { + fields := strings.Fields(line) + if len(fields) < 4 || !validAtom(fields[0]) { + continue + } + high, highErr := strconv.ParseInt(fields[1], 10, 64) + low, lowErr := strconv.ParseInt(fields[2], 10, 64) + if highErr != nil || lowErr != nil || high < 0 || low < 0 { + continue + } + groups = append(groups, GroupInfo{ + Name: fields[0], + Low: low, + High: high, + EstimatedPost: estimatePopulation(low, high), + Posting: fields[3], + }) + } + return groups, nil +} + +func (c *Client) SelectGroup(group string) (GroupStatus, error) { + if !validAtom(group) { + return GroupStatus{}, errors.New("invalid newsgroup name") + } + c.mu.Lock() + defer c.mu.Unlock() + return c.selectGroupUnlocked(group) +} + +func (c *Client) selectGroupUnlocked(group string) (GroupStatus, error) { + code, message, err := c.commandUnlocked("GROUP " + group) + if err != nil { + return GroupStatus{}, err + } + if code != 211 { + return GroupStatus{}, &ResponseError{Code: code, Message: message} + } + fields := strings.Fields(message) + if len(fields) < 4 { + return GroupStatus{}, errors.New("malformed GROUP response") + } + count, err := strconv.ParseInt(fields[0], 10, 64) + if err != nil { + return GroupStatus{}, errors.New("malformed GROUP article count") + } + low, err := strconv.ParseInt(fields[1], 10, 64) + if err != nil { + return GroupStatus{}, errors.New("malformed GROUP low article number") + } + high, err := strconv.ParseInt(fields[2], 10, 64) + if err != nil { + return GroupStatus{}, errors.New("malformed GROUP high article number") + } + return GroupStatus{Name: fields[3], Count: count, Low: low, High: high}, nil +} + +func (c *Client) Overview(first, last int64) ([]ArticleHeader, error) { + if first < 1 || last < first || last-first > 10_000 { + return nil, errors.New("invalid overview range") + } + c.mu.Lock() + defer c.mu.Unlock() + return c.overviewUnlocked(first, last) +} + +func (c *Client) overviewUnlocked(first, last int64) ([]ArticleHeader, error) { + rangeArg := strconv.FormatInt(first, 10) + "-" + strconv.FormatInt(last, 10) + lines, err := c.multilineCommand("OVER "+rangeArg, 224) + if code := responseCode(err); code == 500 || code == 501 { + lines, err = c.multilineCommand("XOVER "+rangeArg, 224) + } + if err != nil { + return nil, err + } + headers := make([]ArticleHeader, 0, len(lines)) + for _, line := range lines { + fields := strings.Split(line, "\t") + if len(fields) < 5 { + continue + } + number, err := strconv.ParseInt(fields[0], 10, 64) + if err != nil { + continue + } + header := ArticleHeader{ + Number: number, + Subject: fields[1], + From: fields[2], + Date: fields[3], + MessageID: fields[4], + } + if len(fields) > 5 { + header.References = fields[5] + } + if len(fields) > 6 { + header.Bytes, _ = strconv.ParseInt(fields[6], 10, 64) + } + if len(fields) > 7 { + header.Lines, _ = strconv.ParseInt(fields[7], 10, 64) + } + headers = append(headers, header) + } + return headers, nil +} + +func (c *Client) LatestOverview(group string, limit int64) (GroupStatus, []ArticleHeader, error) { + if !validAtom(group) { + return GroupStatus{}, nil, errors.New("invalid newsgroup name") + } + if limit < 1 || limit > 10_000 { + return GroupStatus{}, nil, errors.New("overview limit must be between 1 and 10000") + } + c.mu.Lock() + defer c.mu.Unlock() + status, err := c.selectGroupUnlocked(group) + if err != nil { + return GroupStatus{}, nil, err + } + if status.Count == 0 || status.High < status.Low || status.High < 1 { + return status, nil, nil + } + first := status.High - limit + 1 + if first < status.Low { + first = status.Low + } + headers, err := c.overviewUnlocked(first, status.High) + return status, headers, err +} + +func (c *Client) Article(number int64) (string, error) { + if number < 1 { + return "", errors.New("invalid article number") + } + c.mu.Lock() + defer c.mu.Unlock() + return c.articleUnlocked(number) +} + +func (c *Client) ArticleInGroup(group string, number int64) (string, error) { + if !validAtom(group) { + return "", errors.New("invalid newsgroup name") + } + if number < 1 { + return "", errors.New("invalid article number") + } + c.mu.Lock() + defer c.mu.Unlock() + if _, err := c.selectGroupUnlocked(group); err != nil { + return "", err + } + return c.articleUnlocked(number) +} + +func (c *Client) articleUnlocked(number int64) (string, error) { + lines, err := c.multilineCommand("ARTICLE "+strconv.FormatInt(number, 10), 220) + if err != nil { + return "", err + } + return strings.Join(lines, "\n"), nil +} + +func (c *Client) Post(article string) error { + lines, err := normalizedArticleLines(article) + if err != nil { + return err + } + c.mu.Lock() + defer c.mu.Unlock() + code, message, err := c.commandUnlocked("POST") + if err != nil { + return err + } + if code != 340 { + return &ResponseError{Code: code, Message: message} + } + if err := c.setDeadline(); err != nil { + return err + } + for _, line := range lines { + if strings.HasPrefix(line, ".") { + line = "." + line + } + if _, err := c.writer.WriteString(line + "\r\n"); err != nil { + return fmt.Errorf("write article: %w", err) + } + } + if _, err := c.writer.WriteString(".\r\n"); err != nil { + return fmt.Errorf("finish article: %w", err) + } + if err := c.writer.Flush(); err != nil { + return fmt.Errorf("flush article: %w", err) + } + code, message, err = c.readResponse() + if err != nil { + return err + } + if code != 240 { + return &ResponseError{Code: code, Message: message} + } + return nil +} + +// ValidateArticle checks the NNTP/MIME-safe text constraints used by Post. +// It accepts either LF or CRLF input and does not modify the article. +func ValidateArticle(article string) error { + _, err := normalizedArticleLines(article) + return err +} + +func normalizedArticleLines(article string) ([]string, error) { + if len(article) == 0 || len(article) > maxPostBytes { + return nil, fmt.Errorf("article must contain between 1 and %d bytes", maxPostBytes) + } + if !utf8.ValidString(article) { + return nil, errors.New("article is not valid UTF-8") + } + if strings.ContainsRune(article, '\x00') { + return nil, errors.New("article contains a NUL byte") + } + normalized := strings.ReplaceAll(article, "\r\n", "\n") + normalized = strings.ReplaceAll(normalized, "\r", "\n") + lines := strings.Split(normalized, "\n") + for _, line := range lines { + wireBytes := len(line) + if strings.HasPrefix(line, ".") { + wireBytes++ // NNTP dot-stuffing adds one byte on the wire. + } + if wireBytes > maxPostLineBytes { + return nil, fmt.Errorf("article contains a line longer than %d octets", maxPostLineBytes) + } + } + return lines, nil +} + +func (c *Client) Close() error { + c.mu.Lock() + defer c.mu.Unlock() + if c.closed { + return nil + } + c.closed = true + _ = c.setDeadline() + _, _, _ = c.commandUnlocked("QUIT") + return c.conn.Close() +} + +func (c *Client) authenticate(username, password string) error { + if !validCommandValue(username) || !validCommandValue(password) { + return errors.New("credentials contain invalid control characters") + } + code, message, err := c.command("AUTHINFO USER " + username) + if err != nil { + return err + } + if code == 281 { + return nil + } + if code != 381 { + return &ResponseError{Code: code, Message: message} + } + if password == "" { + return errors.New("server requires a password") + } + code, message, err = c.command("AUTHINFO PASS " + password) + if err != nil { + return err + } + if code != 281 { + return &ResponseError{Code: code, Message: message} + } + return nil +} + +func (c *Client) authenticateWithConfig(cfg DialConfig) error { + if strings.TrimSpace(cfg.SASLMechanism) == "" { + return c.authenticate(cfg.Username, cfg.Password) + } + mechanism := strings.ToUpper(strings.TrimSpace(cfg.SASLMechanism)) + if mechanism != "PLAIN" { + return fmt.Errorf("unsupported NNTP SASL mechanism %q", cfg.SASLMechanism) + } + capabilities, err := c.capabilitiesUnlocked() + if err != nil { + return fmt.Errorf("query NNTP capabilities for SASL: %w", err) + } + if !hasCapability(capabilities, "SASL", "PLAIN") { + return errors.New("NNTP server does not advertise SASL PLAIN") + } + code, message, err := c.commandUnlocked("AUTHINFO SASL PLAIN") + if err != nil { + return err + } + if code != 383 { + if code == 281 { + return nil + } + return &ResponseError{Code: code, Message: message} + } + response := base64.StdEncoding.EncodeToString([]byte("\x00" + cfg.Username + "\x00" + cfg.Password)) + if err := c.writeLineUnlocked(response); err != nil { + return err + } + code, message, err = c.readResponse() + if err != nil { + return err + } + if code != 281 { + return &ResponseError{Code: code, Message: message} + } + return nil +} + +func (c *Client) enableCompression() error { + capabilities, err := c.capabilitiesUnlocked() + if err != nil { + return fmt.Errorf("query NNTP capabilities for compression: %w", err) + } + if !hasCapability(capabilities, "COMPRESS", "DEFLATE") { + return errors.New("NNTP server does not advertise COMPRESS DEFLATE") + } + code, message, err := c.commandUnlocked("COMPRESS DEFLATE") + if err != nil { + return err + } + if code != 206 { + return &ResponseError{Code: code, Message: message} + } + compressed := newCompressedConn(c.conn) + c.conn = compressed + c.reader = bufio.NewReaderSize(compressed, 64*1024) + c.writer = bufio.NewWriterSize(compressed, 64*1024) + return nil +} + +func hasCapability(lines []string, name string, value string) bool { + for _, line := range lines { + fields := strings.Fields(line) + if len(fields) == 0 || !strings.EqualFold(fields[0], name) { + continue + } + if value == "" { + return true + } + for _, field := range fields[1:] { + if strings.EqualFold(field, value) { + return true + } + } + } + return false +} + +func (c *Client) command(line string) (int, string, error) { + c.mu.Lock() + defer c.mu.Unlock() + return c.commandUnlocked(line) +} + +func (c *Client) commandUnlocked(line string) (int, string, error) { + if c.closed && line != "QUIT" { + return 0, "", errors.New("NNTP connection is closed") + } + if !validCommandValue(line) { + return 0, "", errors.New("NNTP command contains invalid control characters") + } + if err := c.setDeadline(); err != nil { + return 0, "", err + } + if err := c.writeLineUnlocked(line); err != nil { + return 0, "", err + } + return c.readResponse() +} + +func (c *Client) writeLineUnlocked(line string) error { + if _, err := c.writer.WriteString(line + "\r\n"); err != nil { + return fmt.Errorf("write NNTP command: %w", err) + } + if err := c.writer.Flush(); err != nil { + return fmt.Errorf("flush NNTP command: %w", err) + } + return nil +} + +type compressedConn struct { + net.Conn + reader io.ReadCloser + writer *flate.Writer +} + +func newCompressedConn(conn net.Conn) *compressedConn { + writer, _ := flate.NewWriter(conn, flate.DefaultCompression) + return &compressedConn{Conn: conn, reader: flate.NewReader(conn), writer: writer} +} + +func (c *compressedConn) Read(p []byte) (int, error) { + return c.reader.Read(p) +} + +func (c *compressedConn) Write(p []byte) (int, error) { + n, err := c.writer.Write(p) + if flushErr := c.writer.Flush(); err == nil { + err = flushErr + } + return n, err +} + +func (c *compressedConn) Close() error { + _ = c.writer.Close() + _ = c.reader.Close() + return c.Conn.Close() +} + +func (c *Client) multilineCommand(command string, expectedCode int) ([]string, error) { + code, message, err := c.commandUnlocked(command) + if err != nil { + return nil, err + } + if code != expectedCode { + return nil, &ResponseError{Code: code, Message: message} + } + return c.readMultiline() +} + +func (c *Client) readResponse() (int, string, error) { + line, err := readLimitedLine(c.reader) + if err != nil { + return 0, "", err + } + if len(line) < 3 { + return 0, "", errors.New("malformed NNTP response") + } + code, err := strconv.Atoi(line[:3]) + if err != nil { + return 0, "", errors.New("malformed NNTP response code") + } + message := "" + if len(line) > 4 { + message = line[4:] + } + return code, message, nil +} + +func (c *Client) readMultiline() ([]string, error) { + lines := make([]string, 0, 1024) + total := 0 + for len(lines) < maxMultilineLines { + line, err := readLimitedLine(c.reader) + if err != nil { + return nil, err + } + if line == "." { + return lines, nil + } + if strings.HasPrefix(line, "..") { + line = line[1:] + } + total += len(line) + if total > maxMultilineBytes { + return nil, errors.New("NNTP multiline response exceeds size limit") + } + lines = append(lines, line) + } + return nil, errors.New("NNTP multiline response exceeds line limit") +} + +func (c *Client) setDeadline() error { + if err := c.conn.SetDeadline(time.Now().Add(c.timeout)); err != nil { + return fmt.Errorf("set NNTP deadline: %w", err) + } + return nil +} + +func readLimitedLine(reader *bufio.Reader) (string, error) { + buffer := make([]byte, 0, 4096) + for { + fragment, err := reader.ReadSlice('\n') + if len(buffer)+len(fragment) > maxResponseLine { + return "", errors.New("NNTP response line exceeds size limit") + } + buffer = append(buffer, fragment...) + if errors.Is(err, bufio.ErrBufferFull) { + continue + } + if err != nil { + if errors.Is(err, io.EOF) { + return "", io.ErrUnexpectedEOF + } + return "", err + } + break + } + line := string(buffer) + line = strings.TrimSuffix(line, "\n") + line = strings.TrimSuffix(line, "\r") + return line, nil +} + +func validateDialConfig(cfg DialConfig) error { + if cfg.Host == "" || !validCommandValue(cfg.Host) { + return errors.New("invalid NNTP host") + } + port, err := strconv.Atoi(cfg.Port) + if err != nil || port < 1 || port > 65535 { + return errors.New("invalid NNTP port") + } + if cfg.ProxyType != "" && !strings.EqualFold(cfg.ProxyType, "DIRECT") && !strings.EqualFold(cfg.ProxyType, "SOCKS5") { + return errors.New("unsupported proxy type") + } + if strings.EqualFold(cfg.ProxyType, "SOCKS5") { + if _, _, err := net.SplitHostPort(cfg.ProxyAddress); err != nil { + return fmt.Errorf("invalid SOCKS5 address: %w", err) + } + } + if cfg.Username != "" && !cfg.UseTLS { + return errors.New("NNTP authentication requires TLS") + } + if cfg.StartTLS && !cfg.UseTLS { + return errors.New("STARTTLS requires TLS to be enabled") + } + if cfg.SASLMechanism != "" && cfg.Username == "" { + return errors.New("SASL authentication requires a username") + } + return nil +} + +func makeTLSConfig(cfg DialConfig) *tls.Config { + tlsConfig := &tls.Config{ + MinVersion: tls.VersionTLS12, + MaxVersion: tls.VersionTLS13, + CipherSuites: append([]uint16(nil), tls12AESGCMSuites...), + ServerName: cfg.Host, + InsecureSkipVerify: cfg.InsecureSkipVerify, + } //nolint:gosec // explicit user opt-in for local or pinned deployments + if cfg.tlsConfig != nil { + tlsConfig = cfg.tlsConfig.Clone() + if tlsConfig.MinVersion < tls.VersionTLS12 { + tlsConfig.MinVersion = tls.VersionTLS12 + } + if tlsConfig.MaxVersion < tls.VersionTLS12 || tlsConfig.MaxVersion > tls.VersionTLS13 { + tlsConfig.MaxVersion = tls.VersionTLS13 + } + if tlsConfig.CipherSuites == nil { + tlsConfig.CipherSuites = append([]uint16(nil), tls12AESGCMSuites...) + } + if tlsConfig.ServerName == "" { + tlsConfig.ServerName = cfg.Host + } + } + return tlsConfig +} + +func estimatePopulation(low, high int64) int64 { + if low <= 0 || high < low { + return 0 + } + return high - low + 1 +} + +func validAtom(value string) bool { + return value != "" && validCommandValue(value) && !strings.ContainsAny(value, " ") +} + +func validCommandValue(value string) bool { + return !strings.ContainsAny(value, "\x00\r\n") +} + +func responseCode(err error) int { + var responseErr *ResponseError + if errors.As(err, &responseErr) { + return responseErr.Code + } + return 0 +} diff --git a/internal/nntp/client_test.go b/internal/nntp/client_test.go new file mode 100644 index 0000000..26354ed --- /dev/null +++ b/internal/nntp/client_test.go @@ -0,0 +1,357 @@ +package nntp + +import ( + "bufio" + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/binary" + "fmt" + "io" + "math/big" + "net" + "strings" + "sync" + "testing" + "time" +) + +type fakeServer struct { + listener net.Listener + posted string + mu sync.Mutex +} + +func startFakeServer(t *testing.T) *fakeServer { + t.Helper() + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + return startFakeServerOn(t, listener) +} + +func startFakeTLSServer(t *testing.T) (*fakeServer, *tls.Config) { + t.Helper() + privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + now := time.Now() + template := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "Aegis NNTP test"}, + NotBefore: now.Add(-time.Minute), + NotAfter: now.Add(time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + IPAddresses: []net.IP{net.ParseIP("127.0.0.1")}, + } + der, err := x509.CreateCertificate(rand.Reader, template, template, &privateKey.PublicKey, privateKey) + if err != nil { + t.Fatal(err) + } + certificate := tls.Certificate{Certificate: [][]byte{der}, PrivateKey: privateKey} + listener, err := tls.Listen("tcp", "127.0.0.1:0", &tls.Config{ + Certificates: []tls.Certificate{certificate}, + MinVersion: tls.VersionTLS12, + }) + if err != nil { + t.Fatal(err) + } + parsed, err := x509.ParseCertificate(der) + if err != nil { + t.Fatal(err) + } + roots := x509.NewCertPool() + roots.AddCert(parsed) + return startFakeServerOn(t, listener), &tls.Config{RootCAs: roots, MinVersion: tls.VersionTLS12} +} + +func startFakeServerOn(t *testing.T, listener net.Listener) *fakeServer { + t.Helper() + server := &fakeServer{listener: listener} + go server.serve(t) + t.Cleanup(func() { listener.Close() }) + return server +} + +func (s *fakeServer) address() (string, string) { + host, port, _ := net.SplitHostPort(s.listener.Addr().String()) + return host, port +} + +func (s *fakeServer) serve(t *testing.T) { + conn, err := s.listener.Accept() + if err != nil { + return + } + defer conn.Close() + reader := bufio.NewReader(conn) + writer := bufio.NewWriter(conn) + write := func(value string) { + _, _ = writer.WriteString(value) + _ = writer.Flush() + } + write("200 fake server ready\r\n") + for { + line, err := reader.ReadString('\n') + if err != nil { + return + } + line = strings.TrimSpace(line) + switch { + case line == "AUTHINFO USER reader": + write("381 password required\r\n") + case line == "AUTHINFO PASS secret": + write("281 authentication accepted\r\n") + case line == "MODE READER": + write("200 reader mode\r\n") + case line == "CAPABILITIES": + write("101 capabilities follow\r\nVERSION 2\r\nOVER\r\nPOST\r\n.\r\n") + case line == "LIST ACTIVE": + write("215 list follows\r\ncomp.lang.go 120 101 y\r\nempty.group 0 1 n\r\n.\r\n") + case line == "GROUP comp.lang.go": + write("211 19 101 120 comp.lang.go\r\n") + case line == "OVER 119-120": + write("224 overview follows\r\n119\tFirst\tAlice <a@example.org>\tMon, 1 Jan 2024 00:00:00 +0000\t<119@example>\t\t100\t5\r\n120\tSecond\tBob <b@example.org>\tMon, 1 Jan 2024 01:00:00 +0000\t<120@example>\t<119@example>\t120\t6\r\n.\r\n") + case line == "ARTICLE 120": + write("220 120 <120@example> article follows\r\nSubject: Second\r\n\r\nHello\r\n..dot line\r\n.\r\n") + case line == "POST": + write("340 send article\r\n") + var article strings.Builder + for { + postedLine, readErr := reader.ReadString('\n') + if readErr != nil { + return + } + if postedLine == ".\r\n" { + break + } + article.WriteString(postedLine) + } + s.mu.Lock() + s.posted = article.String() + s.mu.Unlock() + write("240 article received\r\n") + case line == "QUIT": + write("205 closing connection\r\n") + return + default: + write(fmt.Sprintf("500 unsupported: %s\r\n", line)) + } + } +} + +func startSOCKSProxy(t *testing.T, upstreamAddress string) string { + t.Helper() + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { listener.Close() }) + go func() { + connection, acceptErr := listener.Accept() + if acceptErr != nil { + return + } + defer connection.Close() + hello := make([]byte, 3) + if _, err := io.ReadFull(connection, hello); err != nil || hello[0] != 5 { + return + } + if _, err := connection.Write([]byte{5, 0}); err != nil { + return + } + header := make([]byte, 4) + if _, err := io.ReadFull(connection, header); err != nil || header[0] != 5 || header[1] != 1 { + return + } + switch header[3] { + case 1: + _, err = io.ReadFull(connection, make([]byte, 4)) + case 3: + length := make([]byte, 1) + if _, err = io.ReadFull(connection, length); err == nil { + _, err = io.ReadFull(connection, make([]byte, int(length[0]))) + } + case 4: + _, err = io.ReadFull(connection, make([]byte, 16)) + default: + return + } + if err != nil { + return + } + port := make([]byte, 2) + if _, err := io.ReadFull(connection, port); err != nil || binary.BigEndian.Uint16(port) == 0 { + return + } + upstream, err := net.DialTimeout("tcp", upstreamAddress, 2*time.Second) + if err != nil { + _, _ = connection.Write([]byte{5, 1, 0, 1, 0, 0, 0, 0, 0, 0}) + return + } + defer upstream.Close() + if _, err := connection.Write([]byte{5, 0, 0, 1, 127, 0, 0, 1, 0, 1}); err != nil { + return + } + done := make(chan struct{}) + go func() { + _, _ = io.Copy(upstream, connection) + _ = upstream.Close() + close(done) + }() + _, _ = io.Copy(connection, upstream) + <-done + }() + return listener.Addr().String() +} + +func TestClientWorkflow(t *testing.T) { + server, tlsConfig := startFakeTLSServer(t) + host, port := server.address() + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + client, err := Dial(ctx, DialConfig{ + Host: host, + Port: port, + UseTLS: true, + Username: "reader", + Password: "secret", + ProxyType: "DIRECT", + tlsConfig: tlsConfig, + }) + if err != nil { + t.Fatalf("Dial() error = %v", err) + } + defer client.Close() + + capabilities, err := client.Capabilities() + if err != nil || len(capabilities) != 3 { + t.Fatalf("Capabilities() = %v, %v", capabilities, err) + } + groups, err := client.ListActive() + if err != nil { + t.Fatal(err) + } + if len(groups) != 2 || groups[0].EstimatedPost != 20 || groups[1].EstimatedPost != 0 { + t.Fatalf("ListActive() = %#v", groups) + } + status, headers, err := client.LatestOverview("comp.lang.go", 2) + if err != nil || status.Count != 19 || status.High != 120 { + t.Fatalf("LatestOverview() status = %#v, error = %v", status, err) + } + if err != nil || len(headers) != 2 || headers[1].Subject != "Second" { + t.Fatalf("LatestOverview() headers = %#v, error = %v", headers, err) + } + article, err := client.ArticleInGroup("comp.lang.go", 120) + if err != nil || !strings.Contains(article, "\n.dot line") { + t.Fatalf("Article() = %q, %v", article, err) + } + if err := client.Post("From: reader@example.org\nNewsgroups: comp.lang.go\nSubject: Test\n\n.line"); err != nil { + t.Fatal(err) + } + server.mu.Lock() + posted := server.posted + server.mu.Unlock() + if !strings.Contains(posted, "\r\n..line\r\n") { + t.Fatalf("posted article was not dot-stuffed: %q", posted) + } +} + +func TestTLSCertificateVerificationCannotBeSkipped(t *testing.T) { + server, _ := startFakeTLSServer(t) + host, port := server.address() + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + client, err := Dial(ctx, DialConfig{Host: host, Port: port, UseTLS: true, ProxyType: "DIRECT"}) + if client != nil { + client.Close() + } + if err == nil { + t.Fatal("Dial() trusted an unknown TLS certificate") + } +} + +func TestRejectsCommandInjection(t *testing.T) { + if _, err := Dial(context.Background(), DialConfig{Host: "example.org\r\nQUIT", Port: "119"}); err == nil { + t.Fatal("Dial() accepted command injection in host") + } + client := &Client{} + if _, err := client.SelectGroup("comp.lang.go\r\nPOST"); err == nil { + t.Fatal("SelectGroup() accepted command injection") + } +} + +func TestValidateArticleTextConstraints(t *testing.T) { + tests := []struct { + name string + article string + wantErr bool + }{ + {name: "valid UTF-8 and CRLF", article: "Subject: café\r\n\r\ncorps UTF-8\r\n"}, + {name: "valid LF normalized by Post", article: "Subject: test\n\nbody"}, + {name: "998 octets", article: "Subject: " + strings.Repeat("a", 989)}, + {name: "oversized line", article: strings.Repeat("a", maxPostLineBytes+1), wantErr: true}, + {name: "dot-stuffed oversized line", article: "." + strings.Repeat("a", maxPostLineBytes-1), wantErr: true}, + {name: "NUL", article: "Subject: test\x00", wantErr: true}, + {name: "invalid UTF-8", article: string([]byte{'a', 0xff}), wantErr: true}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + err := ValidateArticle(test.article) + if (err != nil) != test.wantErr { + t.Fatalf("ValidateArticle() error = %v, wantErr %t", err, test.wantErr) + } + }) + } +} + +func TestRejectsAuthenticationWithoutTLS(t *testing.T) { + if _, err := Dial(context.Background(), DialConfig{ + Host: "127.0.0.1", Port: "119", Username: "reader", Password: "secret", + }); err == nil { + t.Fatal("Dial() accepted authentication without TLS") + } +} + +func TestSOCKS5Connection(t *testing.T) { + server := startFakeServer(t) + proxyAddress := startSOCKSProxy(t, server.listener.Addr().String()) + host, port := server.address() + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + client, err := Dial(ctx, DialConfig{ + Host: host, Port: port, ProxyType: "SOCKS5", ProxyAddress: proxyAddress, + }) + if err != nil { + t.Fatalf("Dial() through SOCKS5 error = %v", err) + } + defer client.Close() + groups, err := client.ListActive() + if err != nil || len(groups) != 2 { + t.Fatalf("ListActive() through SOCKS5 = %#v, %v", groups, err) + } +} + +func TestPopulationEstimate(t *testing.T) { + tests := []struct { + low, high int64 + want int64 + }{ + {101, 120, 20}, + {1, 1, 1}, + {1, 0, 0}, + {0, 0, 0}, + } + for _, test := range tests { + if got := estimatePopulation(test.low, test.high); got != test.want { + t.Errorf("estimatePopulation(%d, %d) = %d, want %d", test.low, test.high, got, test.want) + } + } +} |
