Fix OAuth: Loopback redirect URIs without a path (for example http://127.0.0.1:45678) are not matched against registered loopback URIs on any port and are rejected by dynamic client registration

This commit is contained in:
Maurus Decimus committed 2026-10-15 17:07:06 +02:00
1 parent e27d49154f
commit 21e1831dee
3 files changed
+73 -9

No files matched your search

+1
View File
@@ -12,6 +12,7 @@ If you are upgrading from v0.16.x, replace the binary (or run `docker pull`). If
## Fixed
- IMAP: `SEARCH RETURN` with `MIN` or `MAX` alongside `ALL` returns only the minimum and maximum in `ALL`, and `SAVE` combined with `MIN` or `MAX` and `ALL` or `COUNT` saves only the minimum and maximum instead of every match.
- OAuth: Loopback redirect URIs without a path (for example `http://127.0.0.1:45678`) are not matched against registered loopback URIs on any port and are rejected by dynamic client registration.
## [0.16.26] - 2026-10-11
+69 -9
View File
@@ -212,15 +212,23 @@ pub fn loopback_redirect_parts(uri: &str) -> Option<(&str, &str)> {
for host in ["127.0.0.1", "[::1]"] {
if let Some(rest) = uri.strip_prefix(host) {
if let Some(path) = rest.strip_prefix('/') {
return Some((host, path));
} else if let Some(after_colon) = rest.strip_prefix(':')
&& let Some((port, path)) = after_colon.split_once('/')
&& !port.is_empty()
&& port.bytes().all(|b| b.is_ascii_digit())
{
return Some((host, path));
}
let rest = match rest.strip_prefix(':') {
Some(after_colon) => {
let rest = after_colon.trim_start_matches(|c: char| c.is_ascii_digit());
if rest.len() == after_colon.len() {
return None;
}
rest
}
None => rest,
};
return if let Some(path) = rest.strip_prefix('/') {
Some((host, path))
} else if rest.is_empty() || rest.starts_with('?') {
Some((host, rest))
} else {
None
};
}
}
None
@@ -293,3 +301,55 @@ pub fn validate_grant_metadata(
Ok(())
}
#[cfg(test)]
mod tests {
use super::{loopback_redirect_parts, redirect_uri_matches};
#[test]
fn loopback_matches_any_port() {
// Loopback redirects match regardless of port and of an empty versus "/" path (RFC 8252 §7.3)
for (registered, presented) in [
("http://127.0.0.1", "http://127.0.0.1:45678"),
("http://127.0.0.1/", "http://127.0.0.1:45678"),
("http://127.0.0.1", "http://127.0.0.1:45678/"),
("http://127.0.0.1/cb", "http://127.0.0.1:45678/cb"),
("http://127.0.0.1/?a=1", "http://127.0.0.1:45678?a=1"),
("http://[::1]", "http://[::1]:8080"),
("http://[::1]/", "http://[::1]:8080/"),
] {
assert!(
redirect_uri_matches(registered, presented),
"{registered} should match {presented}"
);
}
// Different paths, hosts or schemes never match
for (registered, presented) in [
("http://127.0.0.1/cb", "http://127.0.0.1:45678"),
("http://127.0.0.1", "http://127.0.0.1:45678/cb"),
("http://127.0.0.1", "http://[::1]:45678"),
("http://127.0.0.1", "https://127.0.0.1:45678"),
("http://127.0.0.1", "http://127.0.0.1.evil.com"),
("http://127.0.0.1", "http://127.0.0.1:80@evil.com"),
("http://127.0.0.1", "http://127.0.0.1:45678#frag"),
] {
assert!(
!redirect_uri_matches(registered, presented),
"{registered} should not match {presented}"
);
}
// Malformed loopback URIs are not recognised
for uri in [
"http://127.0.0.1:",
"http://127.0.0.1:/cb",
"http://127.0.0.1:abc/",
"http://127.0.0.1.evil.com/",
"http://127.0.0.1@evil.com/",
"http://localhost:8080/",
] {
assert_eq!(loopback_redirect_parts(uri), None, "{uri}");
}
}
}
+3
View File
@@ -246,6 +246,9 @@ pub async fn test(test: &mut TestServer) {
"http://127.0.0.1/cb",
"http://127.0.0.1:54321/cb",
"http://[::1]:8080/cb",
"http://127.0.0.1",
"http://127.0.0.1:54321",
"http://[::1]:8080",
] {
let (status, body) = post_json_raw(
&metadata.registration_endpoint,