From 6ae876649b2fedce38c4b083cee9ad694bf7ee44 Mon Sep 17 00:00:00 2001 From: Toddr Bot Date: Wed, 8 Apr 2026 05:33:36 +0000 Subject: [PATCH] fix: make Link rel="alternate" matching case-insensitive per RFC 8288 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RFC 8288 ยง2.1.1 states that relation types are case-insensitive. _parse_link_alternates() was using a case-sensitive regex, which could miss alternate certificate chains from ACME servers that send Rel="Alternate" or similar variants. Co-Authored-By: Claude Opus 4.6 --- MANIFEST | 1 + lib/Net/ACME2.pm | 2 +- t/Net-ACME2-link-alternates.t | 128 ++++++++++++++++++++++++++++++++++ 3 files changed, 130 insertions(+), 1 deletion(-) create mode 100644 t/Net-ACME2-link-alternates.t diff --git a/MANIFEST b/MANIFEST index e77ce8f..9584ea4 100644 --- a/MANIFEST +++ b/MANIFEST @@ -66,6 +66,7 @@ t/Net-ACME2-get_orders.t t/Net-ACME2-HTTP.t t/Net-ACME2-JWTMaker.t t/Net-ACME2-key-change.t +t/Net-ACME2-link-alternates.t t/Net-ACME2-order-lifecycle.t t/Net-ACME2-PromiseUtil.t t/Net-ACME2-RetryAfter.t diff --git a/lib/Net/ACME2.pm b/lib/Net/ACME2.pm index 3073cda..6911bc2 100644 --- a/lib/Net/ACME2.pm +++ b/lib/Net/ACME2.pm @@ -923,7 +923,7 @@ sub _parse_link_alternates { my @alt_urls; for my $link (@links) { - if ($link =~ m{<([^>]+)>\s*;\s*rel="alternate"}) { + if ($link =~ m{<([^>]+)>\s*;\s*rel="alternate"}i) { push @alt_urls, $1; } } diff --git a/t/Net-ACME2-link-alternates.t b/t/Net-ACME2-link-alternates.t new file mode 100644 index 0000000..9f51c1c --- /dev/null +++ b/t/Net-ACME2-link-alternates.t @@ -0,0 +1,128 @@ +#!/usr/bin/env perl + +use strict; +use warnings; + +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). + +use Net::ACME2 (); + +{ + package MockResponse; + + sub new { + my ($class, %opts) = @_; + return bless \%opts, $class; + } + + sub header { + my ($self, $name) = @_; + return $self->{ lc $name }; + } +} + +# --- No Link header --- +{ + my $resp = MockResponse->new(); + my @urls = Net::ACME2::_parse_link_alternates($resp); + is( scalar @urls, 0, 'no Link header returns empty list' ); +} + +# --- Single alternate link (lowercase) --- +{ + my $resp = MockResponse->new( + link => ';rel="alternate"', + ); + my @urls = Net::ACME2::_parse_link_alternates($resp); + is_deeply( + \@urls, + ['https://ca.example/cert/alt1'], + 'single lowercase alternate link', + ); +} + +# --- Multiple alternate links (arrayref) --- +{ + my $resp = MockResponse->new( + link => [ + ';rel="alternate"', + ';rel="alternate"', + ], + ); + my @urls = Net::ACME2::_parse_link_alternates($resp); + is_deeply( + \@urls, + ['https://ca.example/cert/alt1', 'https://ca.example/cert/alt2'], + 'multiple alternate links as arrayref', + ); +} + +# --- Non-alternate link is ignored --- +{ + my $resp = MockResponse->new( + link => ';rel="index"', + ); + my @urls = Net::ACME2::_parse_link_alternates($resp); + is( scalar @urls, 0, 'non-alternate link ignored' ); +} + +# --- Mixed alternate and non-alternate --- +{ + my $resp = MockResponse->new( + link => [ + ';rel="index"', + ';rel="alternate"', + ], + ); + my @urls = Net::ACME2::_parse_link_alternates($resp); + is_deeply( + \@urls, + ['https://ca.example/cert/alt1'], + 'mixed link types: only alternate extracted', + ); +} + +# --- RFC 8288: relation types are case-insensitive --- +{ + my $resp = MockResponse->new( + link => ';rel="Alternate"', + ); + my @urls = Net::ACME2::_parse_link_alternates($resp); + is_deeply( + \@urls, + ['https://ca.example/cert/alt1'], + 'mixed-case "Alternate" matches (RFC 8288 case-insensitive)', + ); +} + +{ + my $resp = MockResponse->new( + link => ';rel="ALTERNATE"', + ); + my @urls = Net::ACME2::_parse_link_alternates($resp); + is_deeply( + \@urls, + ['https://ca.example/cert/alt1'], + 'uppercase "ALTERNATE" matches (RFC 8288 case-insensitive)', + ); +} + +# --- Whitespace variations in Link header --- +{ + my $resp = MockResponse->new( + link => ' ; rel="alternate"', + ); + my @urls = Net::ACME2::_parse_link_alternates($resp); + is_deeply( + \@urls, + ['https://ca.example/cert/alt1'], + 'extra whitespace around semicolon', + ); +} + +done_testing();