Skip to content

Commit f3e774d

Browse files
committed
net.http.signature: reconstruct absolute @target-uri for origin-form requests
1 parent fbe1d52 commit f3e774d

2 files changed

Lines changed: 90 additions & 12 deletions

File tree

vlib/net/http/signature/http_message.v

Lines changed: 31 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,12 @@ pub:
2727
// the wire. Most signers leave it off (the verifier looks the alg
2828
// up by `keyid`); set to true for explicit signalling.
2929
include_alg bool
30+
// scheme is used to reconstruct `@target-uri` and `@scheme` when
31+
// `req.url` is origin-form (e.g. `/foo`, as produced by
32+
// `http.parse_request*`). The default matches the common signing
33+
// scenario (TLS-protected APIs). Ignored when `req.url` already
34+
// carries a scheme.
35+
scheme string = 'https'
3036
}
3137

3238
// sign_request signs an HTTP request in place by appending the
@@ -39,7 +45,7 @@ pub:
3945
// RFC 9421 §7.2.1 RECOMMENDS the parameter for replay protection.
4046
// Pass an explicit `created: 0` only if you know you don't want it.
4147
pub fn sign_request(mut req http.Request, key Key, opts SignRequestOptions) ! {
42-
c := request_components(req)!
48+
c := request_components(req, opts.scheme)!
4349
mut comps := opts.components.clone()
4450
if comps.len == 0 {
4551
comps = default_request_components(req)
@@ -69,14 +75,18 @@ pub struct VerifyRequestOptions {
6975
pub:
7076
label string
7177
now_unix i64
78+
// scheme — see SignRequestOptions.scheme. Both ends of the
79+
// signature must agree on the scheme used to reconstruct the
80+
// target URI, otherwise the signature bases differ.
81+
scheme string = 'https'
7282
}
7383

7484
// verify_request verifies a labelled signature on an HTTP request. If
7585
// `opts.label` is empty and exactly one signature is present, that
7686
// one is checked. If `opts.now_unix > 0`, the `expires` parameter is
7787
// also enforced.
7888
pub fn verify_request(req http.Request, key Key, opts VerifyRequestOptions) ! {
79-
c := request_components(req)!
89+
c := request_components(req, opts.scheme)!
8090
sig_input := merged_dict_field(req.header, 'Signature-Input') or {
8191
return MalformedMessage{
8292
reason: 'request has no Signature-Input header'
@@ -180,14 +190,13 @@ fn merged_dict_field(h http.Header, name string) ?string {
180190
}
181191

182192
// request_components extracts the derived-component values from an
183-
// http.Request. The url field is parsed once; if it is not a valid
184-
// URL we surface a typed error rather than silently dropping
185-
// components that depend on it (@scheme, @authority, @path, @query).
186-
fn request_components(req http.Request) !Components {
187-
mut c := Components{
188-
method: req.method.str()
189-
target_uri: req.url
190-
}
193+
// http.Request. `default_scheme` is used when `req.url` is in
194+
// origin-form (e.g. `/foo?bar=1`, as produced by `http.parse_request*`
195+
// for inbound HTTP/1.1 messages); in that case `@target-uri` is
196+
// reconstructed as `<scheme>://<authority><url>` per RFC 9110 §7.1.
197+
// If `req.url` is not a valid URL we surface a typed error rather
198+
// than silently dropping components that depend on it.
199+
fn request_components(req http.Request, default_scheme string) !Components {
191200
parsed := urllib.parse(req.url) or {
192201
return MalformedMessage{
193202
reason: 'request url "${req.url}" is not a valid URL: ${err.msg()}'
@@ -198,11 +207,21 @@ fn request_components(req http.Request) !Components {
198207
} else {
199208
req.host
200209
}
210+
scheme := if parsed.scheme != '' { parsed.scheme } else { default_scheme }
211+
mut c := Components{
212+
method: req.method.str()
213+
}
214+
is_origin_form := req.url.starts_with('/')
215+
c.target_uri = if is_origin_form && authority != '' {
216+
'${scheme}://${authority}${req.url}'
217+
} else {
218+
req.url
219+
}
201220
if authority != '' {
202221
c.authority = authority
203222
}
204-
if parsed.scheme != '' {
205-
c.scheme = parsed.scheme
223+
if scheme != '' {
224+
c.scheme = scheme
206225
}
207226
if parsed.path != '' {
208227
c.path = parsed.path

vlib/net/http/signature/http_message_test.v

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,65 @@ fn test_verify_request_rejects_expired_signature() {
140140
verify_request(req, key)!
141141
}
142142

143+
fn test_origin_form_request_target_uri_reconstructed() {
144+
// Inbound HTTP/1.1 requests parsed by `http.parse_request*` carry
145+
// the request target verbatim (`/foo?bar=1`), not an absolute URI.
146+
// `request_components` must rebuild `<scheme>://<authority><url>`
147+
// so the verifier sees the same `@target-uri` as a peer that signs
148+
// with an absolute URL.
149+
mut signing_req := http.Request{
150+
method: .post
151+
url: 'https://example.com/foo?bar=1'
152+
}
153+
signing_req.header.add_custom('Host', 'example.com')!
154+
key := Key.hmac_sha256(test_secret.bytes())
155+
sign_request(mut signing_req, key,
156+
components: ['@method', '@target-uri']
157+
created: 1
158+
)!
159+
160+
// Receiver side: same request as it would land after parsing.
161+
mut received := http.Request{
162+
method: .post
163+
url: '/foo?bar=1'
164+
host: 'example.com'
165+
}
166+
for k in signing_req.header.keys() {
167+
for v in signing_req.header.custom_values(k) {
168+
received.header.add_custom(k, v)!
169+
}
170+
}
171+
verify_request(received, key)!
172+
}
173+
174+
fn test_origin_form_uses_explicit_scheme() {
175+
// HTTP-only deployment: caller must pass `scheme: 'http'` so both
176+
// sides agree on the reconstructed target URI.
177+
mut signing_req := http.Request{
178+
method: .get
179+
url: 'http://api.example.com/v1/items'
180+
}
181+
signing_req.header.add_custom('Host', 'api.example.com')!
182+
key := Key.hmac_sha256(test_secret.bytes())
183+
sign_request(mut signing_req, key,
184+
components: ['@method', '@target-uri']
185+
created: 1
186+
scheme: 'http'
187+
)!
188+
189+
mut received := http.Request{
190+
method: .get
191+
url: '/v1/items'
192+
host: 'api.example.com'
193+
}
194+
for k in signing_req.header.keys() {
195+
for v in signing_req.header.custom_values(k) {
196+
received.header.add_custom(k, v)!
197+
}
198+
}
199+
verify_request(received, key, scheme: 'http')!
200+
}
201+
143202
fn test_sign_two_signatures_coexist() {
144203
mut req := build_request('https://example.com/foo')
145204
k1 := Key.hmac_sha256('one'.bytes())

0 commit comments

Comments
 (0)