Skip to content

Commit fbe1d52

Browse files
committed
net.http.signature: strip default ports from @authority per RFC 9421 §2.2.3
1 parent d968f99 commit fbe1d52

2 files changed

Lines changed: 110 additions & 1 deletion

File tree

vlib/net/http/signature/components.v

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ fn (c Components) derived_value(name string) !string {
6666
}
6767
'@authority' {
6868
if a := c.authority {
69-
a.to_lower()
69+
normalize_authority(a, c.scheme)
7070
} else {
7171
return missing(name)
7272
}
@@ -134,6 +134,39 @@ fn missing(name string) IError {
134134
}
135135
}
136136

137+
// normalize_authority lowercases the authority and strips the port when
138+
// it equals the URI scheme's default (RFC 9421 §2.2.3 + RFC 9110 §4.2.3).
139+
// Without this, peers that emit `example.com` and peers that emit
140+
// `example.com:443` produce different signature bases for the same
141+
// resource and fail to interoperate.
142+
fn normalize_authority(authority string, scheme ?string) string {
143+
lower := authority.to_lower()
144+
port_colon := find_port_colon(lower) or { return lower }
145+
port := lower[port_colon + 1..]
146+
scheme_lower := if s := scheme { s.to_lower() } else { '' }
147+
if (scheme_lower == 'https' && port == '443')
148+
|| (scheme_lower == 'http' && port == '80') {
149+
return lower[..port_colon]
150+
}
151+
return lower
152+
}
153+
154+
// find_port_colon returns the index of the ':' that separates the port
155+
// in an authority, or `none` if no port is present. IPv6 literals embed
156+
// colons inside `[...]`; the port colon (if any) is the one immediately
157+
// following the closing bracket.
158+
fn find_port_colon(authority string) ?int {
159+
if authority.starts_with('[') {
160+
bracket := authority.index(']') or { return none }
161+
if bracket + 1 < authority.len && authority[bracket + 1] == `:` {
162+
return bracket + 1
163+
}
164+
return none
165+
}
166+
colon := authority.last_index(':') or { return none }
167+
return colon
168+
}
169+
137170
// trim_ows removes leading/trailing OWS (RFC 7230 §3.2.3 - SP and
138171
// HTAB). RFC 9421 §2.1 step 3 mandates this trim before signing.
139172
fn trim_ows(s string) string {
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
// Tests for derived component canonicalization rules from RFC 9421 §2.2.
2+
module signature
3+
4+
fn test_authority_strips_default_https_port() {
5+
c := Components{
6+
authority: 'Example.com:443'
7+
scheme: 'https'
8+
}
9+
assert c.derived_value('@authority')! == 'example.com'
10+
}
11+
12+
fn test_authority_strips_default_http_port() {
13+
c := Components{
14+
authority: 'example.com:80'
15+
scheme: 'http'
16+
}
17+
assert c.derived_value('@authority')! == 'example.com'
18+
}
19+
20+
fn test_authority_keeps_non_default_port() {
21+
c := Components{
22+
authority: 'example.com:8443'
23+
scheme: 'https'
24+
}
25+
assert c.derived_value('@authority')! == 'example.com:8443'
26+
}
27+
28+
fn test_authority_does_not_strip_when_scheme_mismatched() {
29+
// :80 with https scheme is *not* the default, so it must stay.
30+
c := Components{
31+
authority: 'example.com:80'
32+
scheme: 'https'
33+
}
34+
assert c.derived_value('@authority')! == 'example.com:80'
35+
}
36+
37+
fn test_authority_no_port_unchanged() {
38+
c := Components{
39+
authority: 'Example.COM'
40+
scheme: 'https'
41+
}
42+
assert c.derived_value('@authority')! == 'example.com'
43+
}
44+
45+
fn test_authority_ipv6_strips_default_port() {
46+
c := Components{
47+
authority: '[2001:db8::1]:443'
48+
scheme: 'https'
49+
}
50+
assert c.derived_value('@authority')! == '[2001:db8::1]'
51+
}
52+
53+
fn test_authority_ipv6_keeps_non_default_port() {
54+
c := Components{
55+
authority: '[2001:db8::1]:8443'
56+
scheme: 'https'
57+
}
58+
assert c.derived_value('@authority')! == '[2001:db8::1]:8443'
59+
}
60+
61+
fn test_authority_ipv6_no_port_unchanged() {
62+
c := Components{
63+
authority: '[2001:db8::1]'
64+
scheme: 'https'
65+
}
66+
assert c.derived_value('@authority')! == '[2001:db8::1]'
67+
}
68+
69+
fn test_authority_no_scheme_keeps_port() {
70+
// Without a scheme we cannot know which port is the default, so
71+
// the port is preserved.
72+
c := Components{
73+
authority: 'example.com:443'
74+
}
75+
assert c.derived_value('@authority')! == 'example.com:443'
76+
}

0 commit comments

Comments
 (0)