From 025456b48d48aa4473e3ab47d577789a42572ba2 Mon Sep 17 00:00:00 2001 From: Toddr Bot Date: Tue, 14 Apr 2026 10:30:58 +0000 Subject: [PATCH] fix: parse Link rel="alternate" in any parameter position The _parse_link_alternates regex required rel="alternate" to appear immediately after the first semicolon following the URI-Reference. This meant Link headers with other parameters before rel (e.g., title, type) would silently fail to match, violating RFC 8288 section 3 which allows parameters in any order. Split the match into two steps: check for rel="alternate" anywhere in the link value, then extract the URI. Also makes the match case-insensitive per RFC 8288 section 2.1.1. Co-Authored-By: Claude Opus 4.6 --- lib/Net/ACME2.pm | 5 ++- t/Net-ACME2-link-alternates.t | 68 +++++++++++++++++++++++++++++++---- 2 files changed, 64 insertions(+), 9 deletions(-) diff --git a/lib/Net/ACME2.pm b/lib/Net/ACME2.pm index 4b00d20..06623f4 100644 --- a/lib/Net/ACME2.pm +++ b/lib/Net/ACME2.pm @@ -923,9 +923,8 @@ sub _parse_link_alternates { my @alt_urls; for my $link (@links) { - if ($link =~ m{<([^>]+)>\s*;\s*rel="alternate"}i) { - push @alt_urls, $1; - } + next unless $link =~ m{;\s*rel="alternate"}i; + push @alt_urls, $1 if $link =~ m{<([^>]+)>}; } return @alt_urls; diff --git a/t/Net-ACME2-link-alternates.t b/t/Net-ACME2-link-alternates.t index 9f51c1c..d57c0aa 100644 --- a/t/Net-ACME2-link-alternates.t +++ b/t/Net-ACME2-link-alternates.t @@ -7,8 +7,9 @@ use Test::More; use Test::FailWarnings; # _parse_link_alternates is a private function in Net::ACME2. -# We test it directly to verify RFC 8288 compliance (case-insensitive -# relation types). +# We test it directly to verify RFC 8288 compliance: +# - relation types are case-insensitive (section 2.1.1) +# - link parameters may appear in any order (section 3) use Net::ACME2 (); @@ -33,7 +34,7 @@ use Net::ACME2 (); is( scalar @urls, 0, 'no Link header returns empty list' ); } -# --- Single alternate link (lowercase) --- +# --- Single alternate link --- { my $resp = MockResponse->new( link => ';rel="alternate"', @@ -42,7 +43,7 @@ use Net::ACME2 (); is_deeply( \@urls, ['https://ca.example/cert/alt1'], - 'single lowercase alternate link', + 'single alternate link', ); } @@ -87,7 +88,7 @@ use Net::ACME2 (); ); } -# --- RFC 8288: relation types are case-insensitive --- +# --- RFC 8288 section 2.1.1: relation types are case-insensitive --- { my $resp = MockResponse->new( link => ';rel="Alternate"', @@ -112,7 +113,7 @@ use Net::ACME2 (); ); } -# --- Whitespace variations in Link header --- +# --- Whitespace variations --- { my $resp = MockResponse->new( link => ' ; rel="alternate"', @@ -125,4 +126,59 @@ use Net::ACME2 (); ); } +# --- RFC 8288 section 3: parameters may appear in any order --- +# The rel parameter does not need to be the first parameter after the URI. +{ + my $resp = MockResponse->new( + link => '; title="cross-signed"; rel="alternate"', + ); + my @urls = Net::ACME2::_parse_link_alternates($resp); + is_deeply( + \@urls, + ['https://ca.example/cert/alt1'], + 'rel after other params (title before rel)', + ); +} + +{ + my $resp = MockResponse->new( + link => '; type="application/pem-certificate-chain"; rel="alternate"', + ); + my @urls = Net::ACME2::_parse_link_alternates($resp); + is_deeply( + \@urls, + ['https://ca.example/cert/alt1'], + 'rel after type param', + ); +} + +{ + my $resp = MockResponse->new( + link => '; rel="alternate"; title="ISRG Root X2"', + ); + my @urls = Net::ACME2::_parse_link_alternates($resp); + is_deeply( + \@urls, + ['https://ca.example/cert/alt1'], + 'rel before other params (rel then title)', + ); +} + +# --- rel in any position with mixed links --- +{ + my $resp = MockResponse->new( + link => [ + '; rel="index"', + '; title="cross-signed"; rel="alternate"', + '; rel="alternate"; title="ISRG Root X2"', + ], + ); + my @urls = Net::ACME2::_parse_link_alternates($resp); + is_deeply( + \@urls, + ['https://ca.example/cert/alt1', 'https://ca.example/cert/alt2'], + 'mixed links with rel in various positions', + ); +} + done_testing();