diff --git a/internal/server/auth/auth.go b/internal/server/auth/auth.go index 80af189..2c82d09 100644 --- a/internal/server/auth/auth.go +++ b/internal/server/auth/auth.go @@ -47,13 +47,13 @@ func buildCapabilities(deps ServerDeps, isTLS bool) []string { capabilities := []string{"IMAP4rev1"} if isTLS { - capabilities = append(capabilities, "AUTH=PLAIN", "LOGIN") + capabilities = append(capabilities, "AUTH=PLAIN", "LOGIN", "SASL-IR") } else { capabilities = append(capabilities, "STARTTLS", "LOGINDISABLED") } if oauthSASLReady(deps) { - capabilities = append(capabilities, "AUTH=OAUTHBEARER", "AUTH=XOAUTH2", "SASL-IR") + capabilities = append(capabilities, "AUTH=OAUTHBEARER", "AUTH=XOAUTH2") } capabilities = append(capabilities, @@ -149,31 +149,46 @@ func HandleAuthenticate(deps ServerDeps, conn net.Conn, tag string, parts []stri return } - // Send continuation request - deps.SendResponse(conn, "+ ") + var authData string + if len(parts) >= 4 { + // Client sent the initial response inline, e.g. "AUTHENTICATE PLAIN ". + // No continuation round-trip. + ir := strings.TrimSpace(parts[3]) + if ir == "=" { + // "=" denotes an explicit zero-length initial response, distinct from + // omitting the argument entirely. + authData = "" + } else { + authData = ir + } + } else { + // Send continuation request + deps.SendResponse(conn, "+ ") - // Read the authentication data - buf := make([]byte, 8192) - _ = conn.SetReadDeadline(time.Now().Add(30 * time.Second)) - n, err := conn.Read(buf) - if err != nil { - deps.SendResponse(conn, fmt.Sprintf("%s NO Authentication failed", tag)) - return - } + // Read the authentication data + buf := make([]byte, 8192) + _ = conn.SetReadDeadline(time.Now().Add(30 * time.Second)) + n, err := conn.Read(buf) + if err != nil { + deps.SendResponse(conn, fmt.Sprintf("%s NO Authentication failed", tag)) + return + } - authData := strings.TrimSpace(string(buf[:n])) + authData = strings.TrimSpace(string(buf[:n])) - // Client may cancel authentication with a single "*" - if authData == "*" { - deps.SendResponse(conn, fmt.Sprintf("%s BAD Authentication exchange cancelled", tag)) - return + // Client may cancel authentication with a single "*". This only applies to + // a reply to the "+ " challenge above -- an inline value has no challenge + // to cancel. + if authData == "*" { + deps.SendResponse(conn, fmt.Sprintf("%s BAD Authentication exchange cancelled", tag)) + return + } } log.Printf("AUTHENTICATE PLAIN: received %d bytes of auth data", len(authData)) // Decode base64 as per SASL challenge/response (PLAIN uses base64 here) - var decoded []byte - decoded, err = base64.StdEncoding.DecodeString(authData) + decoded, err := base64.StdEncoding.DecodeString(authData) if err != nil { log.Printf("AUTHENTICATE PLAIN: base64 decode failed: %v, treating as plain", err) // If decode fails, fall back to treating the input as plain (some test-clients may do this) diff --git a/internal/server/auth/authenticate_test.go b/internal/server/auth/authenticate_test.go index 4bce626..7b7e392 100644 --- a/internal/server/auth/authenticate_test.go +++ b/internal/server/auth/authenticate_test.go @@ -308,6 +308,49 @@ func TestAuthenticatePlainEmptyCredentialsResponse(t *testing.T) { } } +// TestAuthenticatePlainSASLIRInline covers PLAIN with the initial response sent inline +// instead of via a "+" continuation. No data is queued on the mock conn's read side, so a +// regression back to the two-step flow would hang/timeout instead of failing cleanly. +func TestAuthenticatePlainSASLIRInline(t *testing.T) { + s, cleanup := server.SetupTestServer(t) + defer cleanup() + + conn := server.NewMockTLSConn() + state := &models.ClientState{Authenticated: false} + inline := base64.StdEncoding.EncodeToString([]byte("\x00testuser\x00testpass")) + + s.HandleAuthenticate(conn, "A006", []string{"A006", "AUTHENTICATE", "PLAIN", inline}, state) + + response := conn.GetWrittenData() + if strings.Contains(response, "+ ") { + t.Errorf("Inline SASL-IR response should not send a continuation request, got: %s", response) + } + if !strings.Contains(response, "A006") { + t.Errorf("Expected tagged response for A006, got: %s", response) + } +} + +// TestAuthenticatePlainSASLIRExplicitEmpty covers the "=" inline argument, which means an +// explicit empty response rather than "no response given" -- it must not trigger a "+ " +// continuation, and since PLAIN needs real credentials it should fail auth. +func TestAuthenticatePlainSASLIRExplicitEmpty(t *testing.T) { + s, cleanup := server.SetupTestServer(t) + defer cleanup() + + conn := server.NewMockTLSConn() + state := &models.ClientState{Authenticated: false} + + s.HandleAuthenticate(conn, "A007", []string{"A007", "AUTHENTICATE", "PLAIN", "="}, state) + + response := conn.GetWrittenData() + if strings.Contains(response, "+ ") { + t.Errorf("Explicit empty SASL-IR (\"=\") should not send a continuation request, got: %s", response) + } + if !strings.Contains(response, "A007 NO") { + t.Errorf("Expected auth failure for empty credentials, got: %s", response) + } +} + // TestAuthenticatePlainTwoPartFallback tests the non-standard username\x00password fallback path. func TestAuthenticatePlainTwoPartFallback(t *testing.T) { s, cleanup := server.SetupTestServer(t) diff --git a/internal/server/auth/capability_test.go b/internal/server/auth/capability_test.go index b8cb4b0..8e18fe1 100644 --- a/internal/server/auth/capability_test.go +++ b/internal/server/auth/capability_test.go @@ -49,6 +49,7 @@ func TestCapabilityCommand_RFCCompliance(t *testing.T) { forbidCaps: []string{ "AUTH=PLAIN", "LOGIN", + "SASL-IR", }, }, { @@ -58,6 +59,7 @@ func TestCapabilityCommand_RFCCompliance(t *testing.T) { "IMAP4rev1", "AUTH=PLAIN", "LOGIN", + "SASL-IR", "UIDPLUS", "IDLE", "NAMESPACE", @@ -96,9 +98,9 @@ func TestCapabilityCommand_RFCCompliance(t *testing.T) { capLine := lines[0] if oauthReady { - tt.expectCaps = append(tt.expectCaps, "AUTH=OAUTHBEARER", "AUTH=XOAUTH2", "SASL-IR") + tt.expectCaps = append(tt.expectCaps, "AUTH=OAUTHBEARER", "AUTH=XOAUTH2") } else { - tt.forbidCaps = append(tt.forbidCaps, "AUTH=OAUTHBEARER", "AUTH=XOAUTH2", "SASL-IR") + tt.forbidCaps = append(tt.forbidCaps, "AUTH=OAUTHBEARER", "AUTH=XOAUTH2") } // Check required capabilities using exact token matching diff --git a/internal/server/server.go b/internal/server/server.go index a8c051d..b9689c7 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -82,13 +82,13 @@ func (s *IMAPServer) greetingCapabilities(isTLS bool) string { capabilities := []string{"IMAP4rev1"} if isTLS { - capabilities = append(capabilities, "AUTH=PLAIN", "LOGIN") + capabilities = append(capabilities, "AUTH=PLAIN", "LOGIN", "SASL-IR") } else { capabilities = append(capabilities, "STARTTLS", "LOGINDISABLED") } if s.oauthSASLReady() { - capabilities = append(capabilities, "AUTH=OAUTHBEARER", "AUTH=XOAUTH2", "SASL-IR") + capabilities = append(capabilities, "AUTH=OAUTHBEARER", "AUTH=XOAUTH2") } capabilities = append(capabilities, "UIDPLUS", "IDLE", "LITERAL+") diff --git a/test/integration/server/imap_integration_test.go b/test/integration/server/imap_integration_test.go index 729bee5..08d4e04 100644 --- a/test/integration/server/imap_integration_test.go +++ b/test/integration/server/imap_integration_test.go @@ -1,6 +1,7 @@ package server_test import ( + "encoding/base64" "raven/internal/db" "strings" "testing" @@ -62,6 +63,42 @@ func TestIMAPServerToClient_Login(t *testing.T) { _ = client.Logout() } +// TestIMAPServerToClient_AuthenticatePlainInlineIR tests AUTHENTICATE PLAIN with the +// initial response sent inline on the command line, instead of via a "+ " continuation. +func TestIMAPServerToClient_AuthenticatePlainInlineIR(t *testing.T) { + dbManager := helpers.SetupTestDatabase(t) + defer helpers.TeardownTestDatabase(t, dbManager) + + _ = helpers.CreateTestUser(t, dbManager.DBManager, "alice@example.com") + + imapServer := helpers.StartTestIMAPServer(t, dbManager.DBManager) + defer imapServer.Stop(t) + + client := helpers.ConnectIMAP(t, imapServer.Address) + defer func() { _ = client.Close() }() + + creds := "\x00alice@example.com\x00password" + inline := base64.StdEncoding.EncodeToString([]byte(creds)) + + responses, err := client.SendCommand("AUTHENTICATE PLAIN " + inline) + if err != nil { + t.Fatalf("AUTHENTICATE PLAIN failed: %v", err) + } + + for _, line := range responses { + if strings.HasPrefix(line, "+ ") { + t.Errorf("Inline initial response should not trigger a continuation prompt, got: %s", line) + } + } + + last := responses[len(responses)-1] + if !strings.Contains(last, "OK") { + t.Errorf("Expected OK response, got: %s", last) + } + + _ = client.Logout() +} + // TestIMAPServerToClient_ListMailboxes tests IMAP LIST command func TestIMAPServerToClient_ListMailboxes(t *testing.T) { // Setup database