use std::fmt;
use std::ops::Deref;
use url::Url;
use crate::error::AllowedUrlError;
#[derive(Clone)]
pub struct UrlResolver {
pub base_url: Option<Url>,
}
impl UrlResolver {
pub fn new(base_url: Option<Url>) -> Self {
UrlResolver { base_url }
}
pub fn resolve_href(&self, href: &str) -> Result<AllowedUrl, AllowedUrlError> {
let url = Url::options()
.base_url(self.base_url.as_ref())
.parse(href)
.map_err(AllowedUrlError::UrlParseError)?;
if url.scheme() == "data" {
return Ok(AllowedUrl(url));
}
if url.query().is_some() {
return Err(AllowedUrlError::NoQueriesAllowed);
}
if url.fragment().is_some() {
return Err(AllowedUrlError::NoFragmentIdentifierAllowed);
}
if self.base_url.is_none() {
return Err(AllowedUrlError::BaseRequired);
}
let base_url = self.base_url.as_ref().unwrap();
if url.scheme() != base_url.scheme() {
return Err(AllowedUrlError::DifferentUriSchemes);
}
if url.scheme() == "resource" {
return Ok(AllowedUrl(url));
}
if url.scheme() != "file" {
return Err(AllowedUrlError::DisallowedScheme);
}
assert!(url.scheme() == "file");
if let Some(segments) = url.path_segments() {
if segments
.last()
.expect("URL path segments always contain at last 1 element")
.is_empty()
{
return Err(AllowedUrlError::NotSiblingOrChildOfBaseFile);
}
} else {
unreachable!("the file: URL cannot have an empty path");
}
let url_path = url
.to_file_path()
.map_err(|_| AllowedUrlError::InvalidPath)?;
let base_path = base_url
.to_file_path()
.map_err(|_| AllowedUrlError::InvalidPath)?;
let base_parent = base_path.parent();
if base_parent.is_none() {
return Err(AllowedUrlError::BaseIsRoot);
}
let base_parent = base_parent.unwrap();
let path_canon = url_path
.canonicalize()
.map_err(|_| AllowedUrlError::CanonicalizationError)?;
let parent_canon = base_parent
.canonicalize()
.map_err(|_| AllowedUrlError::CanonicalizationError)?;
if path_canon.starts_with(parent_canon) {
let path_to_url = Url::from_file_path(path_canon).unwrap();
Ok(AllowedUrl(path_to_url))
} else {
Err(AllowedUrlError::NotSiblingOrChildOfBaseFile)
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct AllowedUrl(Url);
impl Deref for AllowedUrl {
type Target = Url;
fn deref(&self) -> &Url {
&self.0
}
}
impl fmt::Display for AllowedUrl {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
self.0.fmt(f)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
#[test]
fn disallows_relative_file_with_no_base_file() {
let url_resolver = UrlResolver::new(None);
assert!(matches!(
url_resolver.resolve_href("foo.svg"),
Err(AllowedUrlError::UrlParseError(
url::ParseError::RelativeUrlWithoutBase
))
));
}
#[test]
fn disallows_different_schemes() {
let url_resolver = UrlResolver::new(Some(
Url::parse("http://example.com/malicious.svg").unwrap(),
));
assert!(matches!(
url_resolver.resolve_href("file:///etc/passwd"),
Err(AllowedUrlError::DifferentUriSchemes)
));
}
fn make_file_uri(p: &str) -> String {
if cfg!(windows) {
format!("file:///c:{}", p)
} else {
format!("file://{}", p)
}
}
#[test]
fn disallows_base_is_root() {
let url_resolver = UrlResolver::new(Some(Url::parse(&make_file_uri("/")).unwrap()));
assert!(matches!(
url_resolver.resolve_href("foo.svg"),
Err(AllowedUrlError::BaseIsRoot)
));
}
#[test]
fn disallows_non_file_scheme() {
let url_resolver = UrlResolver::new(Some(Url::parse("http://foo.bar/baz.svg").unwrap()));
assert!(matches!(
url_resolver.resolve_href("foo.svg"),
Err(AllowedUrlError::DisallowedScheme)
));
}
#[test]
fn allows_data_url_with_no_base_file() {
let url_resolver = UrlResolver::new(None);
assert_eq!(
url_resolver
.resolve_href("data:image/jpeg;base64,xxyyzz")
.unwrap()
.as_ref(),
"data:image/jpeg;base64,xxyyzz",
);
}
fn url_from_test_fixtures(filename_relative_to_librsvg_srcdir: &str) -> Url {
let path = PathBuf::from(filename_relative_to_librsvg_srcdir);
let absolute = path
.canonicalize()
.expect("files from test fixtures are supposed to canonicalize");
Url::from_file_path(absolute).unwrap()
}
#[test]
fn allows_relative() {
let base_url = url_from_test_fixtures("tests/fixtures/loading/bar.svg");
let url_resolver = UrlResolver::new(Some(base_url));
let resolved = url_resolver.resolve_href("foo.svg").unwrap();
let resolved_str = resolved.as_str();
assert!(resolved_str.ends_with("/loading/foo.svg"));
}
#[test]
fn allows_sibling() {
let url_resolver = UrlResolver::new(Some(url_from_test_fixtures(
"tests/fixtures/loading/bar.svg",
)));
let resolved = url_resolver
.resolve_href(url_from_test_fixtures("tests/fixtures/loading/foo.svg").as_str())
.unwrap();
let resolved_str = resolved.as_str();
assert!(resolved_str.ends_with("/loading/foo.svg"));
}
#[test]
fn allows_child_of_sibling() {
let url_resolver = UrlResolver::new(Some(url_from_test_fixtures(
"tests/fixtures/loading/bar.svg",
)));
let resolved = url_resolver
.resolve_href(url_from_test_fixtures("tests/fixtures/loading/subdir/baz.svg").as_str())
.unwrap();
let resolved_str = resolved.as_str();
assert!(resolved_str.ends_with("/loading/subdir/baz.svg"));
}
#[cfg(unix)]
#[test]
fn disallows_non_sibling() {
let url_resolver = UrlResolver::new(Some(url_from_test_fixtures(
"tests/fixtures/loading/bar.svg",
)));
assert!(matches!(
url_resolver.resolve_href(&make_file_uri("/etc/passwd")),
Err(AllowedUrlError::NotSiblingOrChildOfBaseFile)
));
}
#[test]
fn disallows_queries() {
let url_resolver = UrlResolver::new(Some(
Url::parse(&make_file_uri("/example/bar.svg")).unwrap(),
));
assert!(matches!(
url_resolver.resolve_href(".?../../../../../../../../../../etc/passwd"),
Err(AllowedUrlError::NoQueriesAllowed)
));
}
#[test]
fn disallows_weird_relative_uris() {
let url_resolver = UrlResolver::new(Some(
Url::parse(&make_file_uri("/example/bar.svg")).unwrap(),
));
assert!(url_resolver
.resolve_href(".@../../../../../../../../../../etc/passwd")
.is_err());
assert!(url_resolver
.resolve_href(".$../../../../../../../../../../etc/passwd")
.is_err());
assert!(url_resolver
.resolve_href(".%../../../../../../../../../../etc/passwd")
.is_err());
assert!(url_resolver
.resolve_href(".*../../../../../../../../../../etc/passwd")
.is_err());
assert!(url_resolver
.resolve_href("~/../../../../../../../../../../etc/passwd")
.is_err());
}
#[test]
fn disallows_dot_sibling() {
let url_resolver = UrlResolver::new(Some(
Url::parse(&make_file_uri("/example/bar.svg")).unwrap(),
));
assert!(matches!(
url_resolver.resolve_href("."),
Err(AllowedUrlError::NotSiblingOrChildOfBaseFile)
));
assert!(matches!(
url_resolver.resolve_href(".#../../../../../../../../../../etc/passwd"),
Err(AllowedUrlError::NoFragmentIdentifierAllowed)
));
}
#[test]
fn disallows_fragment() {
let url_resolver =
UrlResolver::new(Some(Url::parse("https://example.com/foo.svg").unwrap()));
assert!(matches!(
url_resolver.resolve_href("bar.svg#fragment"),
Err(AllowedUrlError::NoFragmentIdentifierAllowed)
));
}
#[cfg(windows)]
#[test]
fn invalid_url_from_test_suite() {
let resolver =
UrlResolver::new(Some(Url::parse("file:///c:/foo.svg").expect("initial url")));
match resolver.resolve_href("file://") {
Ok(_) => println!("yay!"),
Err(e) => println!("err: {}", e),
}
}
}