Skip to content

client: defer large SASL initial response instead of inlining it (#301) - #307

Open
youdie006 wants to merge 2 commits into
emersion:masterfrom
youdie006:fix/301-defer-large-sasl-initial-response
Open

client: defer large SASL initial response instead of inlining it (#301)#307
youdie006 wants to merge 2 commits into
emersion:masterfrom
youdie006:fix/301-defer-large-sasl-initial-response

Conversation

@youdie006

Copy link
Copy Markdown

Fixes #301.

Problem

Client.Auth always appends the base64 initial response to the AUTH command line:

code, msg64, err := c.cmd(0, "%s", strings.TrimSpace(fmt.Sprintf("AUTH %s %s", mech, resp64)))

For SASL mechanisms whose initial response is large (e.g. GSSAPI, whose first token can be 1-2 KB with a PAC), that line exceeds the 512-octet command-line limit of RFC 5321 section 4.5.3.1.4. Strict servers (e.g. Dovecot submission) then reject it with 500 5.5.2 Line too long before authentication can proceed.

Fix

When inlining the initial response would overflow the 512-octet limit, send AUTH <mech> alone and provide the initial response as the reply to the first server challenge instead, as permitted by RFC 4954 section 4. Mechanisms whose initial response fits (the common case) are unchanged and still inline it.

Test

Added TestClientAuthLargeInitialResponse: a SASL client returning a 600-byte initial response, driven through the existing faker mock. Red-green verified with go test -run TestClientAuth ./...: before the change the AUTH command line is 813 octets (initial response inlined, over the 512 limit); after, the command line is AUTH LARGE and the response is sent on its own line following the challenge. gofmt and go vet are clean.


Disclosure: I used AI assistance (Claude) while preparing this change. I verified the root cause against the RFCs, ran the tests (red-green), and take responsibility for the contribution.

Client.Auth always appended the base64 initial response to the AUTH command
line. For mechanisms with a large initial response (e.g. GSSAPI, whose first
token can be 1-2 KB), that line exceeds the 512-octet command-line limit of
RFC 5321 section 4.5.3.1.4, and strict servers (e.g. Dovecot submission)
reject it with "500 5.5.2 Line too long" before authentication can proceed.

When inlining would overflow the limit, send "AUTH <mech>" and provide the
initial response on the first server challenge instead, as allowed by
RFC 4954 section 4. Add a regression test.

Fixes emersion#301

@emersion emersion left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code structure is pretty hard to follow here. Can we send the too-large initial response outside of the loop?

@youdie006

Copy link
Copy Markdown
Author

You're right, thanks — restructured so the deferred response is sent before the loop, and the loop is byte-for-byte what it was before this PR.

authCmd := strings.TrimSpace(fmt.Sprintf("AUTH %s %s", mech, resp64))
if len(authCmd)+2 > 512 && len(resp64) > 0 {
	// The initial response does not fit in the 512-octet command line (RFC 5321
	// section 4.5.3.1.4), so send it as the reply to the first challenge instead
	// (RFC 4954 section 4).
	code, msg64, err = c.cmd(0, "AUTH %s", mech)
	if err == nil && code == 334 {
		code, msg64, err = c.cmd(0, "%s", resp64)
	}
} else {
	code, msg64, err = c.cmd(0, "%s", authCmd)
}
for err == nil {
	// unchanged

The deferredResp64 variable and the continue are gone. If the server answers the bare AUTH <mech> with something other than 334, that code falls into the loop and is handled by the existing default branch exactly as before, so the error paths are unchanged too.

Net change to client.go is +15/-1 against the merge base. go test ./... passes, go vet and gofmt -l clean. Reverting client.go and keeping the test still fails as it should:

--- FAIL: TestClientAuthLargeInitialResponse
    client_test.go:93: AUTH command line is 813 octets, exceeding the 512-octet limit
    client_test.go:98: deferred initial response was not sent

@ml1nk

ml1nk commented Sep 10, 2026

Copy link
Copy Markdown

My codebase differs a little bit, but I think this should work here too. In my understanding TrimSpace was added to remove the whitespace between %s and %s if resp64 is empty.

So if we reuse the branch we can remove the TrimSpace and extra fmt.Sprintf like this (uponusolutions@21f3d24):

	// 512 - len("AUTH") - 2 * len(" ") - len(\r\n)
	if len(mech)+len(resp64) > 504 || len(resp64) == 0 {
		// The initial response (if any) does not fit in the 512-octet command line (RFC 5321
		// section 4.5.3.1.4), so send it as the reply to the first challenge instead
		// (RFC 4954 section 4). https://github.com/emersion/go-smtp/issues/301
		status, err = c.cmd(0, "AUTH %s", mech)
		if err == nil && status.Code == 334 && len(resp64) > 0 {
			status, err = c.cmd(0, "%s", resp64)
		}
	} else {
		status, err = c.cmd(0, "AUTH %s %s", mech, resp64)
	}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Client.Auth always inlines the SASL initial response, breaking mechanisms with a large initial response

3 participants