diff --git a/crates/data/src/ogc.rs b/crates/data/src/ogc.rs index 472ebdbde..d2916e356 100644 --- a/crates/data/src/ogc.rs +++ b/crates/data/src/ogc.rs @@ -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 { crate::login_address::parse(from).ok()?; crate::login_address::parse(href.trim()).ok()?; @@ -616,23 +617,65 @@ fn resolve_inner(from: &str, href: &str) -> Option { if is_web(href) { 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))); + } + 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 @@ -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)); @@ -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)) // 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)); @@ -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) { @@ -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 = @@ -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)) + .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. @@ -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: `&` is still there after the XML is read. .map(|h| h.replace("&", "&")) - .filter(|h| is_web(h)) + .and_then(|h| resolve(url, &h)) + .map(|h| endpoint(&h)) .unwrap_or_else(|| endpoint(url)); let formats: Vec = root .descendants() @@ -3881,6 +3927,10 @@ pub fn fix_axes(bytes: &[u8], bounds: Option<[f64; 4]>) -> Option> { #[path = "../../../tests/support/ogc.rs"] mod fixtures; +#[cfg(test)] +#[path = "ogc_document_tests.rs"] +mod document_tests; + #[cfg(test)] mod tests { use super::*; @@ -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")); @@ -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")); assert_eq!( describe_coverage_url(c), diff --git a/crates/data/src/ogc_document_tests.rs b/crates/data/src/ogc_document_tests.rs new file mode 100644 index 000000000..5054fdc48 --- /dev/null +++ b/crates/data/src/ogc_document_tests.rs @@ -0,0 +1,345 @@ +use super::*; + +#[path = "../../../tests/support/ogc_document_logins.rs"] +mod documents; + +const BASE: &str = "https://maps.example.org/folder/caps.xml"; +const ORIGIN: &str = "https://maps.example.org"; +const BASE_BINDING: &str = "0123456789abcdef"; +const FOREIGN_BINDING: &str = "fedcba9876543210"; + +fn requests(service: &Service) -> Vec { + assert_eq!(service.layers.len(), 1); + let layer = &service.layers[0]; + let mut requests = match &layer.offer { + Offer::Tiles { template } => vec![template.clone()], + Offer::Features { url } => vec![url.clone()], + Offer::Coverage(c) => vec![ + describe_coverage_url(c), + get_coverage_url(c, &CoverageInfo { format: "image/tiff".into(), axes: None, bbox: None }, [1.0, 2.0, 3.0, 4.0]), + ], + other => panic!("unexpected offer: {other:?}"), + }; + if let Some(info) = &layer.feature_info { + assert!(info.queryable(), "trusted inherited wrappers remain usable internally"); + requests.push(info.request(0.0, 0.0, 1.0).unwrap()); + } + requests.extend(layer.legends.iter().map(|legend| legend.url.clone())); + requests +} + +fn assert_authority(request: &str, binding: Option<&str>, target: &str, context: &str) { + let bound = crate::login_address::parse(request).unwrap(); + assert_eq!(bound.map(|b| b.binding), binding, "{context}: {request}"); + if let Some(bound) = bound { + assert_eq!(bound.origin, ORIGIN, "{context}"); + } + let inner = crate::login_address::inner(request); + assert!(!inner.contains(crate::login_address::PREFIX), "{context}: nested runtime authority"); + assert!(inner.starts_with(target), "{context}: {inner} should target {target}"); +} + +#[test] +fn document_endpoints_inherit_only_the_trusted_base_login() { + let bound_base = crate::login_address::bind(BASE_BINDING, ORIGIN, BASE).unwrap(); + let foreign = + crate::login_address::bind(FOREIGN_BINDING, "https://foreign.example.org", "https://foreign.example.org/advertised").unwrap(); + let same_origin_foreign = crate::login_address::bind(FOREIGN_BINDING, ORIGIN, "https://maps.example.org/advertised").unwrap(); + for (href, target, same_origin) in [ + ("https://maps.example.org/advertised", "https://maps.example.org/advertised", true), + ("https://MAPS.example.org:443/advertised", "https://MAPS.example.org:443/advertised", true), + ("http://maps.example.org/advertised", "http://maps.example.org/advertised", false), + ("https://maps.example.org:444/advertised", "https://maps.example.org:444/advertised", false), + ("https://foreign.example.org/advertised", "https://foreign.example.org/advertised", false), + ("//foreign.example.org/advertised", "https://foreign.example.org/advertised", false), + ("/advertised", "https://maps.example.org/advertised", true), + ("advertised", "https://maps.example.org/folder/advertised", true), + ("?map=advertised", "https://maps.example.org/folder/caps.xml?map=advertised", true), + ( + "?upstream=https://foreign.example.org/tiles", + "https://maps.example.org/folder/caps.xml?upstream=https://foreign.example.org/tiles", + true, + ), + ("proxy?src=https://data.example/t&sig=a%2Bb", "https://maps.example.org/folder/proxy?src=https://data.example/t&sig=a%2Bb", true), + ("/proxy?src=https://data.example/t&sig=a%2Bb", "https://maps.example.org/proxy?src=https://data.example/t&sig=a%2Bb", true), + ("//maps.example.org/proxy?src=https://data.example/t", "https://maps.example.org/proxy?src=https://data.example/t", true), + ("//foreign.example.org/proxy?src=https://data.example/t", "https://foreign.example.org/proxy?src=https://data.example/t", false), + ("proxy/https://data.example/t", "https://maps.example.org/folder/proxy/https://data.example/t", true), + (foreign.as_str(), "https://foreign.example.org/advertised", false), + (same_origin_foreign.as_str(), "https://maps.example.org/advertised", true), + ] { + for (base, has_login) in [(BASE, false), (bound_base.as_str(), true)] { + for (boundary, document) in documents::documents(href) { + let service = parse(base, document.as_bytes()).unwrap(); + let expected = (has_login && same_origin).then_some(BASE_BINDING); + let requests = requests(&service); + for request in &requests { + assert_authority(request, expected, target, boundary); + } + let inner = crate::login_address::inner(&requests[0]); + match &service.layers[0].offer { + Offer::Tiles { .. } if boundary == "WMTS ResourceURL" => { + assert_eq!(inner, format!("{target}/{{z}}/{{y}}/{{x}}.png")); + } + Offer::Tiles { .. } => { + assert!(inner.contains(if service.kind == "WMS" { "REQUEST=GetMap" } else { "REQUEST=GetTile" })); + } + Offer::Features { .. } => assert!(inner.contains("REQUEST=GetFeature")), + Offer::Coverage(_) => assert!(inner.contains("REQUEST=DescribeCoverage")), + other => panic!("unexpected offer: {other:?}"), + } + } + } + } +} + +#[test] +fn fragment_only_document_links_retain_the_endpoint_and_query() { + let source = format!("{BASE}?map=kept#old"); + let bound_source = crate::login_address::bind(BASE_BINDING, ORIGIN, &source).unwrap(); + for href in ["#frag", "#"] { + for (source, has_login) in [(source.as_str(), false), (bound_source.as_str(), true)] { + for (boundary, document) in documents::documents(href) { + let service = parse(source, document.as_bytes()).unwrap(); + for request in requests(&service) { + assert_authority(&request, has_login.then_some(BASE_BINDING), BASE, boundary); + let target = url::Url::parse(crate::login_address::inner(&request)).unwrap(); + assert!(target.query_pairs().any(|(key, value)| key == "map" && value == "kept"), "{boundary}: {request}"); + } + } + } + } +} + +#[test] +fn malformed_document_wrappers_cannot_supply_authority() { + let base = crate::login_address::bind(BASE_BINDING, ORIGIN, BASE).unwrap(); + for bad in [ + "rustgis-login:secret", + "rustgis-login:fedcba9876543210:1:é:https://foreign.example.org/advertised", + "rustgis-login:fedcba9876543210:0::https://foreign.example.org/advertised", + "httpfoo://foreign.example.org/advertised", + "file:///etc/passwd", + "file:/etc/passwd", + "mailto:x", + "CuStOm+v1.0:payload", + ] { + for (boundary, document) in documents::documents(bad) { + let service = parse(&base, document.as_bytes()).unwrap(); + for request in requests(&service) { + assert_authority(&request, Some(BASE_BINDING), BASE, boundary); + } + } + } +} + +#[test] +fn oversized_same_origin_document_targets_fail_closed() { + let base = crate::login_address::bind(BASE_BINDING, ORIGIN, BASE).unwrap(); + let target = format!("{ORIGIN}/{}", "a".repeat(17000)); + let prefix = format!("rustgis-login:{BASE_BINDING}:{}:{ORIGIN}:", ORIGIN.len()); + for (boundary, document) in documents::documents(&target) { + let service = parse(&base, document.as_bytes()).unwrap(); + assert_eq!(service.layers.len(), 1, "{boundary}"); + let requests = match &service.layers[0].offer { + Offer::Tiles { template } => vec![template.clone()], + Offer::Features { url } => vec![url.clone()], + Offer::Coverage(c) => vec![ + describe_coverage_url(c), + get_coverage_url(c, &CoverageInfo { format: "image/tiff".into(), axes: None, bbox: None }, [1.0, 2.0, 3.0, 4.0]), + ], + other => panic!("unexpected offer: {other:?}"), + }; + for request in requests { + assert!(request.starts_with(&prefix), "{boundary}: must not downgrade to anonymous"); + assert!(request.contains(&target), "{boundary}: must not replace the target with the base endpoint"); + assert!(crate::login_address::parse(&request).is_err(), "{boundary}"); + assert!(crate::login_address::check(&request).is_err(), "{boundary}"); + } + } +} + +fn assert_operation(request: &str, service: &str, operation: &str, version: &str) { + let inner = crate::login_address::inner(request); + let url = url::Url::parse(inner).unwrap(); + for (key, expected) in [("SERVICE", service), ("REQUEST", operation), ("VERSION", version)] { + let values: Vec<_> = url.query_pairs().filter(|(k, _)| k.eq_ignore_ascii_case(key)).map(|(_, v)| v.into_owned()).collect(); + assert_eq!(values, [expected], "{key}: {request}"); + } + let query = inner.split_once('?').unwrap().1.split('#').next().unwrap(); + for pair in ["map=kept", "token=a%2Bb"] { + assert_eq!(query.split('&').filter(|p| *p == pair).count(), 1, "{pair}: {request}"); + } +} + +#[test] +fn fragment_endpoints_replace_capabilities_operation_parameters() { + for (boundary, document) in documents::documents("#frag") { + let (kind, version, operation) = match boundary { + "WMS 1.3 GetMap" => ("WMS", "1.3.0", "GetMap"), + "WMS 1.1 GetMap" => ("WMS", "1.1.1", "GetMap"), + "WMTS KVP GetTile" => ("WMTS", "1.0.0", "GetTile"), + "WFS 1.0 GetFeature" => ("WFS", "1.0.0", "GetFeature"), + "WFS 2.0 GetFeature" => ("WFS", "2.0.0", "GetFeature"), + "WCS 1.0 GetCoverage" => ("WCS", "1.0.0", "DescribeCoverage"), + "WCS 2.0 GetCoverage" => ("WCS", "2.0.1", "DescribeCoverage"), + "WMTS ResourceURL" => continue, + other => panic!("unexpected fixture: {other}"), + }; + let source = capabilities_url("https://maps.example.org/mapserv?map=kept&token=a%2Bb&VERSION=obsolete", Some(kind)); + let bound_source = crate::login_address::bind(BASE_BINDING, ORIGIN, &source).unwrap(); + for (source, has_login) in [(source.as_str(), false), (bound_source.as_str(), true)] { + let service = parse(source, document.as_bytes()).unwrap(); + // Legends and REST templates have their own query semantics, not operation keys. + for (i, request) in requests(&service).iter().take(if kind == "WCS" { 2 } else { 1 }).enumerate() { + assert_authority(request, has_login.then_some(BASE_BINDING), "https://maps.example.org/mapserv", boundary); + assert_operation(request, kind, if i == 1 { "GetCoverage" } else { operation }, version); + } + } + } +} + +#[test] +fn parsed_fragment_wfs_offer_reads_all_offset_pages() { + let document = documents::documents("#frag").into_iter().find(|(name, _)| *name == "WFS 2.0 GetFeature").unwrap().1; + let source = capabilities_url("https://maps.example.org/mapserv?map=kept&token=a%2Bb&VERSION=obsolete", Some("WFS")); + let bound_source = crate::login_address::bind(BASE_BINDING, ORIGIN, &source).unwrap(); + for (source, has_login) in [(source.as_str(), false), (bound_source.as_str(), true)] { + let service = parse(source, document.as_bytes()).unwrap(); + let Offer::Features { url } = &service.layers[0].offer else { panic!("expected WFS features") }; + let mut calls = 0; + let (joined, note) = super::join_pages(url, 100, |next| { + calls += 1; + assert_authority(next, has_login.then_some(BASE_BINDING), "https://maps.example.org/mapserv", "WFS paging"); + assert_operation(next, "WFS", "GetFeature", "2.0.0"); + let parsed = url::Url::parse(crate::login_address::inner(next)).unwrap(); + let values = |key: &str| { + parsed.query_pairs().filter(|(k, _)| k.eq_ignore_ascii_case(key)).map(|(_, v)| v.into_owned()).collect::>() + }; + match calls { + 1 => { + assert_eq!(next, url); + assert_eq!(values("COUNT"), [FEATURE_LIMIT.to_string()]); + assert!(values("STARTINDEX").is_empty()); + assert!(values("RESULTTYPE").is_empty()); + Ok(br#"{"type":"FeatureCollection","features":[{"id":"a.1"},{"id":"a.2"}]}"#.to_vec()) + } + 2 => { + assert_eq!(values("RESULTTYPE"), ["hits"]); + assert!(values("OUTPUTFORMAT").is_empty()); + Ok(br#""# + .to_vec()) + } + 3 => { + assert_eq!(values("STARTINDEX"), ["2"]); + assert_eq!(values("COUNT"), ["2"]); + assert!(values("RESULTTYPE").is_empty()); + Ok(br#"{"type":"FeatureCollection","features":[{"id":"a.3"}]}"#.to_vec()) + } + _ => panic!("unexpected WFS request: {next}"), + } + }) + .unwrap(); + assert_eq!(calls, 3); + let joined: serde_json::Value = serde_json::from_slice(&joined).unwrap(); + let ids: Vec<_> = joined["features"].as_array().unwrap().iter().map(|f| f["id"].as_str().unwrap()).collect(); + assert_eq!(ids, ["a.1", "a.2", "a.3"]); + assert_eq!(note, None); + } +} + +fn resource_document(template: &str) -> String { + let document = documents::documents("RESOURCE_TEMPLATE").into_iter().find(|(name, _)| *name == "WMTS ResourceURL").unwrap().1; + let escaped = template.replace('&', "&").replace('"', """).replace('<', "<"); + document.replace("RESOURCE_TEMPLATE/{TileMatrix}/{TileRow}/{TileCol}.png", &escaped) +} + +#[test] +fn relative_resource_templates_resolve_literal_dot_segments_without_rewriting_bytes() { + let bound_base = crate::login_address::bind(BASE_BINDING, ORIGIN, BASE).unwrap(); + for (href, expected, same_origin) in [ + ( + "../tiles/{TileMatrix}/{TileRow}/{TileCol}.png?sig=a%2Bb&x=../kept", + "https://maps.example.org/tiles/{z}/{y}/{x}.png?sig=a%2Bb&x=../kept", + true, + ), + ("../../../../tiles/{TileMatrix}/{TileRow}/{TileCol}.png", "https://maps.example.org/tiles/{z}/{y}/{x}.png", true), + ("/../tiles/./{TileMatrix}/{TileRow}/{TileCol}.png", "https://maps.example.org/tiles/{z}/{y}/{x}.png", true), + ("../tiles/{TileMatrix}/{TileRow}/{TileCol}/.", "https://maps.example.org/tiles/{z}/{y}/{x}/", true), + ("../tiles/{TileMatrix}/{TileRow}/{TileCol}/..", "https://maps.example.org/tiles/{z}/{y}/", true), + ("..", "https://maps.example.org/", true), + (".", "https://maps.example.org/folder/", true), + ("../tiles//./{TileMatrix}//{TileRow}/{TileCol}.png/", "https://maps.example.org/tiles//{z}//{y}/{x}.png/", true), + ("../tiles//../{TileMatrix}/{TileRow}/{TileCol}.png", "https://maps.example.org/tiles/{z}/{y}/{x}.png", true), + ( + "../%2e/%2E%2e/{TileMatrix}/{TileRow}/{TileCol}.png?b=2&a=%2f#../fragment", + "https://maps.example.org/%2e/%2E%2e/{z}/{y}/{x}.png?b=2&a=%2f#../fragment", + true, + ), + ( + "tiles/{TileMatrix}/{TileRow}/{TileCol}.png?x=../kept&sig=a%2Bb", + "https://maps.example.org/folder/tiles/{z}/{y}/{x}.png?x=../kept&sig=a%2Bb", + true, + ), + ( + "tiles/{TileMatrix}/{TileRow}/{TileCol}.png?src=https://data.example/t&sig=a%2Bb", + "https://maps.example.org/folder/tiles/{z}/{y}/{x}.png?src=https://data.example/t&sig=a%2Bb", + true, + ), + ( + "/tiles/{TileMatrix}/{TileRow}/{TileCol}.png?src=https://data.example/t", + "https://maps.example.org/tiles/{z}/{y}/{x}.png?src=https://data.example/t", + true, + ), + ( + "//maps.example.org/tiles/{TileMatrix}/{TileRow}/{TileCol}.png?src=https://data.example/t", + "https://maps.example.org/tiles/{z}/{y}/{x}.png?src=https://data.example/t", + true, + ), + ( + "//foreign.example.org/tiles/{TileMatrix}/{TileRow}/{TileCol}.png?src=https://data.example/t", + "https://foreign.example.org/tiles/{z}/{y}/{x}.png?src=https://data.example/t", + false, + ), + ( + "proxy/https://data.example/{TileMatrix}/{TileRow}/{TileCol}.png", + "https://maps.example.org/folder/proxy/https://data.example/{z}/{y}/{x}.png", + true, + ), + ("//maps.example.org/folder/../tiles/{TileMatrix}/{TileRow}/{TileCol}.png", "https://maps.example.org/tiles/{z}/{y}/{x}.png", true), + ( + "//foreign.example.org/folder/../tiles/{TileMatrix}/{TileRow}/{TileCol}.png", + "https://foreign.example.org/tiles/{z}/{y}/{x}.png", + false, + ), + ( + "https://maps.example.org/folder/../tiles/{TileMatrix}/{TileRow}/{TileCol}.png", + "https://maps.example.org/folder/../tiles/{z}/{y}/{x}.png", + true, + ), + ] { + let document = resource_document(href); + for (source, has_login) in [(BASE, false), (bound_base.as_str(), true)] { + let service = parse(source, document.as_bytes()).unwrap(); + let Offer::Tiles { template } = &service.layers[0].offer else { panic!("expected WMTS tiles") }; + assert_authority(template, (has_login && same_origin).then_some(BASE_BINDING), expected, href); + assert_eq!(crate::login_address::inner(template), expected, "{href}"); + } + } +} + +#[test] +fn advertised_absolute_dotpaths_keep_bytes_but_never_supply_login_authority() { + let source = crate::login_address::bind(BASE_BINDING, ORIGIN, BASE).unwrap(); + for (target_origin, target, expected_binding) in [ + (ORIGIN, "https://maps.example.org/folder/../tiles/{TileMatrix}/{TileRow}/{TileCol}.png", Some(BASE_BINDING)), + ("https://foreign.example.org", "https://foreign.example.org/folder/../tiles/{TileMatrix}/{TileRow}/{TileCol}.png", None), + ] { + let href = crate::login_address::bind(FOREIGN_BINDING, target_origin, target).unwrap(); + let service = parse(&source, resource_document(&href).as_bytes()).unwrap(); + let Offer::Tiles { template } = &service.layers[0].offer else { panic!("expected WMTS tiles") }; + let expected = target.replace("{TileMatrix}", "{z}").replace("{TileRow}", "{y}").replace("{TileCol}", "{x}"); + assert_authority(template, expected_binding, &expected, target); + assert_eq!(crate::login_address::inner(template), expected); + } +} diff --git a/docs/user-guide/data.md b/docs/user-guide/data.md index 3e33ef619..6d3720b7a 100644 --- a/docs/user-guide/data.md +++ b/docs/user-guide/data.md @@ -810,6 +810,13 @@ origin keeps the login, and a redirect to another origin is followed without it, another connection's login either. In the web app a request carrying a login isn't redirected at all, since the browser can't take the login off on the way: a service that redirects (to `https://` or to another path) fails there, so save the connection with the address it ends at. +Service documents cannot supply a login binding. While resolving their links, RustGIS inherits +only the login used to read the document, and only at that same origin. A runtime wrapper in a +document link contributes its ordinary URL, never its login. +Relative links, including parent paths, resolve against the document's address without rewriting +template placeholders or query values, including proxy parameters containing `https://` URLs. +KVP requests replace capabilities operation parameters, keeping unrelated parameters such as +MapServer's `map=`. If a service supplies a same-origin link too long to carry its login, the request is refused instead of being fetched anonymously. Addresses that cannot be rebound to a login are left unchanged and reported. diff --git a/tests/support/ogc_document_logins.rs b/tests/support/ogc_document_logins.rs new file mode 100644 index 000000000..66c397bcb --- /dev/null +++ b/tests/support/ogc_document_logins.rs @@ -0,0 +1,45 @@ +// Capabilities fixtures for document-controlled request boundaries. +pub fn documents(href: &str) -> Vec<(&'static str, String)> { + let href = href.replace('&', "&").replace('"', """).replace('<', "<"); + let mut docs = Vec::new(); + for version in ["1.3.0", "1.1.1"] { + docs.push(( + if version == "1.3.0" { "WMS 1.3 GetMap" } else { "WMS 1.1 GetMap" }, + format!( + r#" +image/png +application/json +image/png +aEPSG:3857EPSG:3857"# + ), + )); + } + for kind in ["WMTS KVP GetTile", "WMTS ResourceURL"] { + let operation = match kind { + "WMTS KVP GetTile" => format!(r#""#), + _ => String::new(), + }; + let resource = if kind == "WMTS ResourceURL" { + format!(r#""#) + } else { + String::new() + }; + docs.push((kind, format!( + r#"{operation}aimage/pnggm{resource} +gmEPSG:38570559082264.0287178-20037508.34278925 20037508.34278925256256"# + ))); + } + docs.push(("WFS 1.0 GetFeature", format!( + r#"aEPSG:4326"# + ))); + docs.push(("WFS 2.0 GetFeature", format!( + r#"a"# + ))); + docs.push(("WCS 1.0 GetCoverage", format!( + r#"a"# + ))); + docs.push(("WCS 2.0 GetCoverage", format!( + r#"a"# + ))); + docs +}