Skip to content

auth: preserve valid authorization header matches - #245

Open
grandpig wants to merge 1 commit into
lightninglabs:masterfrom
grandpig:accept-valid-auth-header
Open

auth: preserve valid authorization header matches#245
grandpig wants to merge 1 commit into
lightninglabs:masterfrom
grandpig:accept-valid-auth-header

Conversation

@grandpig

@grandpig grandpig commented Jul 1, 2026

Copy link
Copy Markdown

Summary

  • Harden parsing of L402 authorization headers.
  • Reject empty, duplicate, malformed, or extra authentication headers.
  • Accept one LSAT, one L402, or matching LSAT and L402 credentials in either order.
  • Deduplicate macaroon and preimage decoding across supported header formats.

Details

SetHeader emits both LSAT and L402 credentials for compatibility with older Aperture versions. When both are present, FromHeader requires their encoded macaroon and encoded preimage values to be identical.

Aperture currently supports exactly one macaroon per credential. The comma-separated multiple-macaroon form allowed by the L402 specification is not supported.

An Authorization header is considered present even when its value is empty.

This prevents an empty Authorization value from bypassing validation and falling back to another macaroon header.

Testing

  • go test ./auth ./l402 -count=1
  • go test ./...

@grandpig

Copy link
Copy Markdown
Author

@starius Could you please take a look when you have a chance? Thanks!

Comment thread auth/authenticator_test.go
Comment thread l402/header.go Outdated
@litbot-9000

Copy link
Copy Markdown
Collaborator

@grandpig, remember to re-request review from reviewers when ready

@grandpig

Copy link
Copy Markdown
Author

@grandpig, remember to re-request review from reviewers when ready

@starius @starius

Thanks, good catch. The break introduced a regression when an earlier Authorization value matches the permissive regex but contains an invalid macaroon, while a later value is valid.

I changed the loop to retain the last syntactically matching LSAT/L402 value while ignoring unrelated Authorization values, preserving the parent implementation's behavior. I also added a regression test for
the invalid-then-valid ordering.

The multi-value compatibility is needed because SetHeader currently emits both LSAT and L402 Authorization values to support older Aperture clients. Supporting unrelated values such as Bearer is an additional compatibility extension, and I added coverage for that behavior as well.

@starius

starius commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

Hmm, so now it permits requests like this:

Authorization: L402 <valid>
Authorization: Bearer unrelated 

Why are they needed? Note that requests like

Authorization: L402 <valid>
Authorization: LSAT <valid> 

are already supported.

I'm worried that permitting non-conformant requests like ones adding Authorization: Bearer unrelated to a valid LSAT/L402 increases attack surface and must be avoided.

I would go in the opposite direction: reject requests with valid-after-invalid headers. This looks a valid hardening direction to me. What do you think?

I.e. permit exactly one of these forms:

  • valid LSAT
  • valid L402
  • valid LSAT + valid L402
  • valid L402 + valid LSAT
  • anything else in Authorization header => reject

Thoughts?

@grandpig

Copy link
Copy Markdown
Author

Thoughts?

Agreed. I don't have a concrete compatibility requirement for accepting an unrelated Bearer value, and I agree that allowing it would broaden the accepted request surface unnecessarily.

I tightened FromHeader so that every Authorization value must be a valid LSAT or L402 credential. It now accepts exactly one LSAT, one L402, or one of each in either order. Any malformed, unrecognized, duplicate, or extra Authorization value is rejected, including both valid-before-invalid and invalid-before-valid cases.

I also added regression tests covering these cases.

@grandpig

Copy link
Copy Markdown
Author

@starius Modified. Please review it again. Thansk

@starius

starius commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

@grandpig

There are three blocking gaps.

  1. At l402/header.go:99, each parsed credential overwrites the previous one. Only the last credential is cryptographically verified by authenticator.go:44.

Therefore this still succeeds:

Authorization: LSAT <well-formed but cryptographically invalid macaroon>:<preimage>
Authorization: L402 <valid credential>

The reverse order fails. So cryptographic invalid-before-valid remains order-dependent.

The test uses !!!!, which fails Base64 decoding. It proves malformed input is rejected, but not that a structurally valid yet unauthorized credential is rejected. The mock minter also accepts every credential.

  1. The LSAT/L402 exception should require both decoded macaroon bytes and preimages to be identical. Then validating either copy validates both. Add tests with differing but well-formed credentials in both orders.

  2. Also, the new anchored regex is an improvement, but it still rejects protocol-valid variants: authentication schemes are case-insensitive, L402 permits 1*SP, and hex can contain uppercase digits. See the L402 grammar.

@grandpig

Copy link
Copy Markdown
Author

@grandpig

There are three blocking gaps.

  1. At l402/header.go:99, each parsed credential overwrites the previous one. Only the last credential is cryptographically verified by authenticator.go:44.

Therefore this still succeeds:

Authorization: LSAT <well-formed but cryptographically invalid macaroon>:<preimage>
Authorization: L402 <valid credential>

The reverse order fails. So cryptographic invalid-before-valid remains order-dependent.

The test uses !!!!, which fails Base64 decoding. It proves malformed input is rejected, but not that a structurally valid yet unauthorized credential is rejected. The mock minter also accepts every credential.

  1. The LSAT/L402 exception should require both decoded macaroon bytes and preimages to be identical. Then validating either copy validates both. Add tests with differing but well-formed credentials in both orders.
  2. Also, the new anchored regex is an improvement, but it still rejects protocol-valid variants: authentication schemes are case-insensitive, L402 permits 1*SP, and hex can contain uppercase digits. See the L402 grammar.

Thanks, these were good catches.

I updated FromHeader so that when both LSAT and L402 are present, their decoded macaroon bytes and decoded preimages must be identical. A credential is no longer allowed to overwrite a previous credential, so mismatched credentials are rejected in either order.

I also added tests using structurally valid but cryptographically unauthorized macaroons, including both invalid-before-valid and valid-before-invalid ordering.

Finally, I relaxed the parser according to the L402 grammar: authentication schemes are case-insensitive, 1*SP is accepted, and uppercase hexadecimal preimages are accepted.

@starius starius left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The changes look good. It is a reasonable hardening. Here are some nits in the code. Please fix and squash all the commits and sync PR title and description to the final shape of the PR.

Comment thread l402/header.go Outdated
Comment thread auth/authenticator_test.go
Comment thread l402/header.go Outdated
Comment thread l402/header.go
@grandpig
grandpig force-pushed the accept-valid-auth-header branch from f674c74 to d5a3a46 Compare August 21, 2026 09:08
@grandpig

Copy link
Copy Markdown
Author

The changes look good. It is a reasonable hardening. Here are some nits in the code. Please fix and squash all the commits and sync PR title and description to the final shape of the PR.

SetHeader emits matching LSAT and L402 credentials for compatibility with older Aperture versions. When both are present, FromHeader now requires their decoded macaroon bytes and preimages to be identical. This ensures that
cryptographically validating either credential validates both copies.

Please review it again. Thnaks.

@starius starius left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thank you for updates!

I found another hardening vector in this function and also a way to deduplicate some code. Could you fix it, while we are on this, please?

Comment thread l402/header.go Outdated
Comment thread l402/header.go
Signed-off-by: grandpig <grandpig@outlook.com>
@grandpig
grandpig force-pushed the accept-valid-auth-header branch from d5a3a46 to 057a008 Compare August 24, 2026 10:07
@grandpig

Copy link
Copy Markdown
Author

Thank you for updates!

I found another hardening vector in this function and also a way to deduplicate some code. Could you fix it, while we are on this, please?

Thanks, fixed.

FromHeader now uses header.Values to distinguish an absent header from an empty value. Any supplied empty Authorization value is rejected instead of falling back to Macaroon or Grpc-Metadata-Macaroon.

Multiple metadata macaroon headers and multiple Macaroon headers are also rejected.

I deduplicated the decoding and validation logic across all supported header formats. When both LSAT and L402 are present, their encoded macaroon and encoded preimage values must match exactly.

Please review it again.

@starius starius left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Found a bug and a test coverage gap.

Also could you update the PR title?
"auth: preserve valid authorization header matches" -> "l402: harden authorization header parsing"

Comment thread l402/header.go
var matches []string
authHeaders := header.Values(HeaderAuthorization)
case len(authHeaders) > 0:
var scheme string

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Duplicate second scheme is accepted. var scheme remembers only the first scheme. It therefore accepts:

Authorization: L402 M:P
Authorization: LSAT M:P
Authorization: LSAT M:P

The third value is compared only against the first scheme (L402), so it is not detected as a duplicate. The inverse also works:

Authorization: LSAT M:P
Authorization: L402 M:P
Authorization: L402 M:P

This contradicts "exactly one LSAT, one L402, or one of each." Use a seenSchemes map or reject len(authHeaders) > 2.

Add both three-value cases as tests:

diff --git a/auth/authenticator_test.go b/auth/authenticator_test.go
index 48886da..0d8925b 100644
--- a/auth/authenticator_test.go
+++ b/auth/authenticator_test.go
@@ -379,6 +379,34 @@ func TestL402Authenticator(t *testing.T) {
 				},
 				result: false,
 			},
+			{
+				id: "duplicate LSAT after matching L402 and LSAT",
+				header: &http.Header{
+					l402.HeaderAuthorization: []string{
+						"L402 " + testMacBase64 + ":" +
+							testPreimage,
+						"LSAT " + testMacBase64 + ":" +
+							testPreimage,
+						"LSAT " + testMacBase64 + ":" +
+							testPreimage,
+					},
+				},
+				result: false,
+			},
+			{
+				id: "duplicate L402 after matching LSAT and L402",
+				header: &http.Header{
+					l402.HeaderAuthorization: []string{
+						"LSAT " + testMacBase64 + ":" +
+							testPreimage,
+						"L402 " + testMacBase64 + ":" +
+							testPreimage,
+						"L402 " + testMacBase64 + ":" +
+							testPreimage,
+					},
+				},
+				result: false,
+			},
 			{
 				id: "extra auth header value",
 				header: &http.Header{

Comment on lines +305 to +348
{
id: "invalid base64 followed by valid credential",
header: &http.Header{
l402.HeaderAuthorization: []string{
"LSAT A:" + testPreimage,
"L402 " + testMacBase64 + ":" +
testPreimage,
},
},
result: false,
},
{
id: "valid credential followed by invalid base64",
header: &http.Header{
l402.HeaderAuthorization: []string{
"L402 " + testMacBase64 + ":" +
testPreimage,
"LSAT A:" + testPreimage,
},
},
result: false,
},
{
id: "invalid encoded macaroon followed by valid credential",
header: &http.Header{
l402.HeaderAuthorization: []string{
"LSAT YQ==:" + testPreimage,
"L402 " + testMacBase64 + ":" +
testPreimage,
},
},
result: false,
},
{
id: "valid credential followed by invalid encoded macaroon",
header: &http.Header{
l402.HeaderAuthorization: []string{
"L402 " + testMacBase64 + ":" +
testPreimage,
"LSAT YQ==:" + testPreimage,
},
},
result: false,
},

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

These test cases now fail at encoded credential comparison:

  currentMacBase64 != macBase64

They never reach Base64 decoding or Authorization macaroon unmarshalling.

Coverage confirms:

  • FromHeader: 96.1%
  • Base64 decoder error at header.go:138: uncovered
  • Missing-preimage-caveat error: uncovered, but pre-existing
  • Macaroon unmarshal block is covered through the empty metadata-header case, not through Authorization: L402 YQ==:...

Add single-header cases:

L402 A:<valid-preimage>
L402 YQ==:<valid-preimage>
diff --git a/auth/authenticator_test.go b/auth/authenticator_test.go
index 0d8925b..9b23dbb 100644
--- a/auth/authenticator_test.go
+++ b/auth/authenticator_test.go
@@ -39,6 +39,25 @@ func createDummyMacHexWithID(preimage, id string) string {
 	return hex.EncodeToString(macBytes)
 }
 
+// createDummyMacHexWithoutPreimage creates a valid macaroon without a
+// preimage caveat.
+func createDummyMacHexWithoutPreimage() string {
+	dummyMac, err := macaroon.New(
+		[]byte("aabbccddeeff00112233445566778899"), []byte("AA=="),
+		"aperture", macaroon.LatestVersion,
+	)
+	if err != nil {
+		panic(err)
+	}
+
+	macBytes, err := dummyMac.MarshalBinary()
+	if err != nil {
+		panic(err)
+	}
+
+	return hex.EncodeToString(macBytes)
+}
+
 // TestL402Authenticator tests that the authenticator properly handles auth
 // headers and the tokens contained in them.
 func TestL402Authenticator(t *testing.T) {
@@ -47,10 +66,11 @@ func TestL402Authenticator(t *testing.T) {
 			"9787b609f088c8df09bacc7b4bd21b39"
 		otherPreimage = "59349dfea4abed3cd14f6d356afa83de" +
 			"9787b609f088c8df09bacc7b4bd21b39"
-		testMacHex      = createDummyMacHex(testPreimage)
-		otherIDMacHex   = createDummyMacHexWithID(testPreimage, "BB==")
-		testMacBytes, _ = hex.DecodeString(testMacHex)
-		testMacBase64   = base64.StdEncoding.EncodeToString(
+		testMacHex       = createDummyMacHex(testPreimage)
+		noPreimageMacHex = createDummyMacHexWithoutPreimage()
+		otherIDMacHex    = createDummyMacHexWithID(testPreimage, "BB==")
+		testMacBytes, _  = hex.DecodeString(testMacHex)
+		testMacBase64    = base64.StdEncoding.EncodeToString(
 			testMacBytes,
 		)
 		otherIDMacBytes, _ = hex.DecodeString(otherIDMacHex)
@@ -112,6 +132,13 @@ func TestL402Authenticator(t *testing.T) {
 				},
 				result: false,
 			},
+			{
+				id: "macaroon header without preimage caveat",
+				header: &http.Header{
+					l402.HeaderMacaroon: []string{noPreimageMacHex},
+				},
+				result: false,
+			},
 			{
 				id: "empty auth header with valid fallback macaroon",
 				header: &http.Header{
@@ -302,6 +329,24 @@ func TestL402Authenticator(t *testing.T) {
 				},
 				result: false,
 			},
+			{
+				id: "invalid base64 credential",
+				header: &http.Header{
+					l402.HeaderAuthorization: []string{
+						"L402 A:" + testPreimage,
+					},
+				},
+				result: false,
+			},
+			{
+				id: "invalid encoded macaroon credential",
+				header: &http.Header{
+					l402.HeaderAuthorization: []string{
+						"L402 YQ==:" + testPreimage,
+					},
+				},
+				result: false,
+			},
 			{
 				id: "invalid base64 followed by valid credential",
 				header: &http.Header{

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.

3 participants