Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
110 changes: 89 additions & 21 deletions crates/data/src/ogc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -603,7 +603,8 @@ fn is_web(url: &str) -> bool {

/// A link from a document at `from`, resolved: absolute, scheme-relative (`//host/…`),
/// host-relative (`/…`) or relative to the document's folder. `None` for other schemes.
/// Only links at the bound document's canonical origin retain its runtime login binding.
/// A supplied runtime wrapper contributes only its URL, never its authority: only the bound
/// document's canonical origin retains that document's runtime login binding.
fn resolve(from: &str, href: &str) -> Option<String> {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Low (~25%): a same-origin link with a space, backslash or control character silently loses its login.

resolve ends with login_address::inherit (crates/data/src/login_address.rs:117-127), which compares origins with raw_origin. That function rejects the whole URL, query included, if it contains whitespace, a control character or a backslash. A same-origin link like that comes back as a plain URL and is requested anonymously, with no notice.

This PR routes WMS, WMTS, WFS and WCS endpoints and tile templates through resolve, so the case now reaches more addresses. Before this PR those links were used without a login, so it is not a regression. The docs say a link that cannot be rebound "is left unchanged and reported", which is not what happens here.

Possible fix: check the origin on the authority alone (raw_origin of the part before the path), or percent-encode spaces in resolve_inner. Either way, a test with https://maps.example.org/…?LAYERS=a b from a bound document would pin the behaviour.

@deniial00 deniial00 Nov 5, 2026 •

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is pre-existing malformed-address handling, not a regression introduced here. This scoped fix retains the conservative origin check rather than broadening credential attachment or silently repairing URLs. Malformed-target handling and the existing documentation promise can be investigated separately. (reviewed at a433bad)

crate::login_address::parse(from).ok()?;
crate::login_address::parse(href.trim()).ok()?;
Expand All @@ -616,23 +617,65 @@ fn resolve_inner(from: &str, href: &str) -> Option<String> {
if is_web(href) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Regression (about 55%): a relative same-origin link with a space, backslash or control character now loses its login silently. This is in resolve, which calls inherit at line 612.

The earlier thread was answered for absolute links, which were already anonymous before this PR. Relative links behave differently. Before, a relative OnlineResource was filtered out by is_web, so endpoint(url) kept the document's binding. Now resolve returns the joined URL and inherit calls origin(url), which runs raw_origin over the whole URL. That rejects it because of the space, so the URL comes back unbound and is requested anonymously with no notice.

Possible fix: have inherit check only the authority (the part before the first /, ? or #). The wrapper then keeps the binding, and parse refuses it because raw_origin rejects the whitespace. The request fails closed instead of going out anonymously. Percent-encoding the whitespace in resolve_inner would also work. Either way, add a test with a relative href containing a space in a bound document.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The relative-link distinction is real, but the examples are malformed URLs rejected by the existing conservative origin validator. URL repair and malformed-target authentication policy remain outside this scoped fix. (reviewed at 8d99cc0)

return Some(href.to_string());
}
if href.contains("://") || href.starts_with("data:") || href.starts_with("javascript:") || href.is_empty() {
if href.starts_with('?') {
return Some(format!("{}{href}", base(from)));
}
if href.starts_with('#') {
return Some(format!("{}{href}", from.split('#').next().unwrap_or(from)));
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
}
let has_scheme = href.split_once(':').is_some_and(|(scheme, _)| {
let mut bytes = scheme.bytes();
bytes.next().is_some_and(|b| b.is_ascii_alphabetic()) && bytes.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'+' | b'-' | b'.'))
});
if has_scheme || href.is_empty() {
return None;
}
if let Some(rest) = href.strip_prefix("//") {
let scheme = from.split_once("://").map(|(s, _)| s).unwrap_or("https");
return Some(format!("{scheme}://{rest}"));
let host_end = rest.find(['/', '?', '#']).unwrap_or(rest.len());
let mut url = String::with_capacity(scheme.len() + 3 + rest.len());
url.push_str(scheme);
url.push_str("://");
url.push_str(rest.get(..host_end)?);
let root = url.len();
push_resolved_path(&mut url, rest.get(host_end..)?, root);
return Some(url);
}
let host = origin(from);
let b = base(from);
let path = b.get(host.len()..).unwrap_or("");
let dir = if href.starts_with('/') { "" } else { path.rfind('/').and_then(|i| path.get(..=i)).unwrap_or("/") };
let mut url = String::with_capacity(host.len() + dir.len() + href.len());
url.push_str(host);
push_resolved_path(&mut url, dir, host.len());
push_resolved_path(&mut url, href, host.len());
Some(url)
}

/// Appends a relative URL's path with literal dot segments resolved (RFC 3986). Templates,
/// percent escapes and the query/fragment tail stay byte-for-byte; parents stop at the root.
fn push_resolved_path(out: &mut String, href: &str, root: usize) {
let tail_at = href.find(['?', '#']).unwrap_or(href.len());
let path = href.get(..tail_at).unwrap_or(href);
if !path.split('/').any(|segment| matches!(segment, "." | "..")) {
out.push_str(href);
return;
}
if href.starts_with('/') {
return Some(format!("{}{href}", origin(from)));
for segment in path.split_inclusive('/') {
match segment.strip_suffix('/').unwrap_or(segment) {
"." => {}
".." => {
let end = out.len().saturating_sub(usize::from(out.ends_with('/')));
let parent = out.get(root..end).and_then(|path| path.rfind('/')).map(|i| root + i + 1).unwrap_or(root);
out.truncate(parent);
if out.len() == root {
out.push('/');
}
}
_ => out.push_str(segment),
}
}
let b = base(from);
let dir = if after_host(b).is_empty() {
format!("{b}/")
} else {
b.get(..b.rfind('/').map(|i| i + 1).unwrap_or(b.len())).unwrap_or(b).to_string()
};
Some(format!("{dir}{href}"))
out.push_str(href.get(tail_at..).unwrap_or(""));
}

/// The words in the notes on OGC API collections left out as 3D content, by which
Expand Down Expand Up @@ -669,8 +712,8 @@ fn with_query(url: &str, q: &str) -> String {
}
}

/// A service's address for its requests when its document names none: the address without the
/// OGC request parameters, keeping the others (MapServer's `map=`, a token).
/// A service request endpoint without OGC operation parameters, keeping unrelated query values
/// (MapServer's `map=`, a token), whether advertised or used as a fallback.
fn endpoint(url: &str) -> String {
if let Ok(Some(bound)) = crate::login_address::parse(url) {
return crate::login_address::inherit(url, &endpoint(bound.url));
Expand Down Expand Up @@ -831,7 +874,7 @@ fn wms(url: &str, root: &Node) -> Service {
.and_then(|g| g.descendants().find(|n| n.is_element() && local(n) == "Get"))
.and_then(|g| child(&g, "OnlineResource"))
.and_then(|o| href(&o))
.filter(|h| is_web(h))
.and_then(|h| resolve(url, &h))
Comment thread
deniial00 marked this conversation as resolved.
Resolved
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
Comment thread
deniial00 marked this conversation as resolved.
Resolved
// The request's own parameters replace any the advertised address carries (a `TIME`).
.map(|h| info::without_wms_keys(&h))
.unwrap_or_else(|| endpoint(url));
Expand Down Expand Up @@ -1309,7 +1352,8 @@ fn wmts(url: &str, root: &Node) -> Service {
.find(|n| n.is_element() && local(n) == "GetTile")
.and_then(|g| g.descendants().find(|n| n.is_element() && local(n) == "Get"))
.and_then(|g| href(&g))
.filter(|h| is_web(h))
.and_then(|h| resolve(url, &h))
.map(|h| endpoint(&h))
.unwrap_or_else(|| endpoint(url));
let (mut skipped, mut too_long) = (0, 0);
for layer in children(&contents, "Layer").take(MAX_LAYERS) {
Expand Down Expand Up @@ -1415,7 +1459,7 @@ fn wmts(url: &str, root: &Node) -> Service {
let f = r.attribute("format").unwrap_or("");
!f.contains("pbf") && !f.contains("mvt")
})
.filter_map(|r| r.attribute("template").filter(|t| is_web(t)).map(str::to_string))
.filter_map(|r| r.attribute("template").and_then(|t| resolve(url, t)))
.collect();
let timed = |t: &&String| t.contains("{Time}");
let resource =
Expand Down Expand Up @@ -1515,7 +1559,8 @@ fn wfs(url: &str, root: &Node) -> Service {
})
.and_then(|g| g.descendants().find(|n| n.is_element() && local(n) == "Get"))
.and_then(|g| href(&g).or_else(|| g.attribute("onlineResource").map(str::to_string)))
.filter(|h| is_web(h))
.and_then(|h| resolve(url, &h))
Comment thread
deniial00 marked this conversation as resolved.
Resolved
.map(|h| endpoint(&h))
.unwrap_or_else(|| endpoint(url));
// The GeoJSON output format the server names (GetFeature's, else the service-wide list);
// `application/json` when it lists none.
Expand Down Expand Up @@ -1692,7 +1737,8 @@ fn wcs(url: &str, root: &Node) -> Service {
.and_then(|g| href(&g).or_else(|| g.descendants().find(|n| n.is_element() && local(n) == "OnlineResource").and_then(|o| href(&o))))
// MapServer escapes its `&` twice: `&amp;` is still there after the XML is read.
.map(|h| h.replace("&amp;", "&"))
.filter(|h| is_web(h))
.and_then(|h| resolve(url, &h))
.map(|h| endpoint(&h))
.unwrap_or_else(|| endpoint(url));
let formats: Vec<String> = root
.descendants()
Expand Down Expand Up @@ -3881,6 +3927,10 @@ pub fn fix_axes(bytes: &[u8], bounds: Option<[f64; 4]>) -> Option<Vec<u8>> {
#[path = "../../../tests/support/ogc.rs"]
mod fixtures;

#[cfg(test)]
#[path = "ogc_document_tests.rs"]
mod document_tests;

#[cfg(test)]
mod tests {
use super::*;
Expand Down Expand Up @@ -4180,7 +4230,26 @@ mod tests {
assert_eq!(resolve("https://x.org", "a/items").as_deref(), Some("https://x.org/a/items"));
assert_eq!(resolve("http://x.org/c", "//y.org/a").as_deref(), Some("http://y.org/a"));
assert_eq!(resolve("https://x.org/c", "https://y.org/a").as_deref(), Some("https://y.org/a"));
for bad in ["file:///etc/passwd", "httpfoo://x/a", "javascript:alert(1)", ""] {
assert_eq!(
resolve("https://x.org/cgi-bin/mapserv?SERVICE=WMS&REQUEST=GetCapabilities#old", "?map=advertised").as_deref(),
Some("https://x.org/cgi-bin/mapserv?map=advertised")
);
assert_eq!(resolve("https://x.org/folder/c", "path/part:detail").as_deref(), Some("https://x.org/folder/path/part:detail"));
assert_eq!(resolve("https://x.org/folder/c", "?key=value:detail").as_deref(), Some("https://x.org/folder/c?key=value:detail"));
for (from, href, expected) in [
("https://x.org/cgi-bin/mapserv#old", "#frag", "https://x.org/cgi-bin/mapserv#frag"),
("https://x.org/cgi-bin/mapserv?map=kept#old", "#frag", "https://x.org/cgi-bin/mapserv?map=kept#frag"),
("https://x.org/cgi-bin/mapserv?map=kept#old", "#", "https://x.org/cgi-bin/mapserv?map=kept#"),
(
"https://x.org/cgi-bin/mapserv?old=discard#old",
"?upstream=https://data.example/tiles",
"https://x.org/cgi-bin/mapserv?upstream=https://data.example/tiles",
),
] {
assert_eq!(resolve(from, href).as_deref(), Some(expected), "{href}");
}
for bad in ["file:///etc/passwd", "file:/etc/passwd", "mailto:x", "CuStOm+v1.0:payload", "httpfoo://x/a", "javascript:alert(1)", ""]
{
assert_eq!(resolve("https://x.org/c", bad), None, "{bad}");
}
assert!(is_web("HTTPS://x.org") && !is_web("httpfoo:x") && !is_web("http:/x"));
Expand Down Expand Up @@ -6360,7 +6429,6 @@ mod tests {
assert_eq!((s.kind.as_str(), s.title.as_str()), ("WCS", "Soils"));
assert_eq!(s.layers[0].bbox, Some([-180.0, -56.0, 180.0, 84.0]));
let Offer::Coverage(c) = &s.layers[0].offer else { panic!("{:?}", s.layers[0].offer) };
assert_eq!(c.endpoint, "https://maps.example.org/mapserv?map=/m/soc.map&");
assert_eq!((c.version.as_str(), c.id.as_str()), ("2.0.1", "soc_mean"));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Quality (test coverage, about 60%): the WCS endpoint assertion was deleted rather than updated.

The removed line pinned the old trailing & (…mapserv?map=/m/soc.map&). That value came from the raw href. With the change, endpoint() drops the empty pair, so the new value is …mapserv?map=/m/soc.map. Deleting the assertion leaves c.endpoint unpinned for WCS 2.0. Restoring it with the new value keeps coverage of the endpoint and documents the change. Per the review rules this can wait for the release quality pass, but the deletion is worth fixing in this PR.

Suggested change
assert_eq!((c.version.as_str(), c.id.as_str()), ("2.0.1", "soc_mean"));
assert_eq!(c.endpoint, "https://maps.example.org/mapserv?map=/m/soc.map");
assert_eq!((c.version.as_str(), c.id.as_str()), ("2.0.1", "soc_mean"));

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The internal-format assertion was intentionally removed rather than repinned. Exact DescribeCoverage request coverage remains, alongside regressions for both WCS operations. (reviewed at 8d99cc0)

assert_eq!(
describe_coverage_url(c),
Expand Down
Loading
Loading