mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-13 17:35:33 -10:00
Registry: a page's Origin against the site's origin, not its whole URL
A submission or a rating is taken only from the site's own pages: its Origin header, then the session and its CSRF token. The registry's README says the request must come from REGISTRY_PUBLIC_URL's origin, and that is what a browser sends: a scheme, a host and a port, never a path. The gate compared the Origin to REGISTRY_PUBLIC_URL as whole strings, so a public URL that carries a path (the form is a page below the site's root) or another case in its host name turned the site's own page away with "send it from the registry's pages" (403). The origin is now taken from the public URL, case folded as a host name is, and the environment value is trimmed of whitespace before it is used, as a pasted deploy variable may carry it. Fixes #6159
This commit is contained in:
1 parent
2c9b58e72a
commit
10d8d84295
2 files changed
+137
-2
No files matched your search
@@ -81,7 +81,10 @@ impl Config {
|
||||
return Err("REGISTRY_SESSION_KEY needs at least 32 characters".into());
|
||||
}
|
||||
Ok(Config {
|
||||
public_url: var("REGISTRY_PUBLIC_URL")?.trim_end_matches('/').to_owned(),
|
||||
public_url: var("REGISTRY_PUBLIC_URL")?
|
||||
.trim()
|
||||
.trim_end_matches('/')
|
||||
.to_owned(),
|
||||
session_key: key.into_bytes(),
|
||||
github: GitHub::new(
|
||||
github::Endpoints::default(),
|
||||
@@ -528,11 +531,29 @@ fn take(window: &mut VecDeque<Instant>, max: usize) -> bool {
|
||||
window.len() < max
|
||||
}
|
||||
|
||||
/// The origin a URL names: `scheme://host[:port]`, without a path. An
|
||||
/// `Origin` is a page's origin and never carries a path, while
|
||||
/// `REGISTRY_PUBLIC_URL` may: the form's pages are below the site's root.
|
||||
fn url_origin(url: &str) -> &str {
|
||||
let host = url.find("://").map_or(0, |scheme| scheme + 3);
|
||||
url[host..]
|
||||
.find('/')
|
||||
.map_or(url, |path| &url[..host + path])
|
||||
}
|
||||
|
||||
/// Whether a change comes from the site's own pages: its `Origin` is the
|
||||
/// origin of `REGISTRY_PUBLIC_URL`, which is what its pages are. A page on
|
||||
/// another site can't send its origin, so this is a whole origin's match,
|
||||
/// case folded the way a host name is.
|
||||
fn from_the_site(public_url: &str, origin: Option<&str>) -> bool {
|
||||
origin.is_some_and(|origin| origin.eq_ignore_ascii_case(url_origin(public_url)))
|
||||
}
|
||||
|
||||
/// A change sent by the site's own pages: from its origin, signed in, with
|
||||
/// the session's CSRF token. The account: (login, id).
|
||||
fn signed_in_here(app: &Inner, headers: &HeaderMap) -> Result<(String, u64), Refusal> {
|
||||
let origin = headers.get(header::ORIGIN).and_then(|v| v.to_str().ok());
|
||||
if origin != Some(app.config.public_url.as_str()) {
|
||||
if !from_the_site(&app.config.public_url, origin) {
|
||||
return Err(Refusal::new(
|
||||
StatusCode::FORBIDDEN,
|
||||
"send it from the registry's pages",
|
||||
@@ -1235,4 +1256,57 @@ mod tests {
|
||||
let cookie = format!("{}.{}", URL_SAFE_NO_PAD.encode(payload), tag);
|
||||
assert_eq!(unsign(&config.session_key, &cookie), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_page_is_known_by_its_origin() {
|
||||
// An `Origin` has no path, though REGISTRY_PUBLIC_URL may: the site's
|
||||
// pages are below its root (#6159).
|
||||
assert_eq!(
|
||||
url_origin("https://playhorizon.dev/mods"),
|
||||
"https://playhorizon.dev"
|
||||
);
|
||||
assert_eq!(
|
||||
url_origin("https://playhorizon.dev/mods/submit/"),
|
||||
"https://playhorizon.dev"
|
||||
);
|
||||
assert_eq!(
|
||||
url_origin("https://playhorizon.dev"),
|
||||
"https://playhorizon.dev"
|
||||
);
|
||||
assert_eq!(
|
||||
url_origin("http://localhost:8080/mods"),
|
||||
"http://localhost:8080"
|
||||
);
|
||||
// The configured site's origin, path or no path, and a host name's
|
||||
// case folded as a browser folds it.
|
||||
assert!(from_the_site(
|
||||
"https://playhorizon.dev/mods",
|
||||
Some("https://playhorizon.dev")
|
||||
));
|
||||
assert!(from_the_site(
|
||||
"https://playhorizon.dev/hub/",
|
||||
Some("https://PlayHorizon.dev")
|
||||
));
|
||||
// The scheme, the host and the port are all the origin's.
|
||||
assert!(!from_the_site(
|
||||
"https://playhorizon.dev",
|
||||
Some("http://playhorizon.dev")
|
||||
));
|
||||
assert!(!from_the_site(
|
||||
"https://playhorizon.dev",
|
||||
Some("https://playhorizon.dev:8443")
|
||||
));
|
||||
// Another site's page, a page with no origin to give, and a name
|
||||
// that only starts like the host.
|
||||
assert!(!from_the_site(
|
||||
"https://playhorizon.dev",
|
||||
Some("https://evil.test")
|
||||
));
|
||||
assert!(!from_the_site("https://playhorizon.dev", None));
|
||||
assert!(!from_the_site(
|
||||
"https://playhorizon.dev",
|
||||
Some("https://playhorizon.dev.evil.test")
|
||||
));
|
||||
assert!(!from_the_site("::", Some("null")));
|
||||
}
|
||||
}
|
||||
@@ -611,6 +611,67 @@ async fn what_the_form_refuses() {
|
||||
);
|
||||
}
|
||||
|
||||
/// The origin gate in front of a submission, before the sign-in and the
|
||||
/// form: how the answer to a request from `origin`.
|
||||
async fn origin_gate(app: &Router, origin: Option<&str>) -> StatusCode {
|
||||
let mut request = Request::post("/api/mods/submit");
|
||||
if let Some(origin) = origin {
|
||||
request = request.header(header::ORIGIN, origin);
|
||||
}
|
||||
let request = request.body(Body::empty()).unwrap();
|
||||
app.clone().oneshot(request).await.unwrap().status()
|
||||
}
|
||||
|
||||
/// #6159: a submission must come from the origin of `REGISTRY_PUBLIC_URL`,
|
||||
/// which is what the site's pages are. An `Origin` never carries a path, so
|
||||
/// a public URL that does (the form is a page below the site's root) still
|
||||
/// takes its own pages; another site's page is refused before the sign-in is
|
||||
/// looked at, and so is a request that names no origin.
|
||||
#[tokio::test]
|
||||
async fn a_submission_comes_from_the_sites_own_pages() {
|
||||
let site = |public_url: &str| {
|
||||
router(
|
||||
App::new(Config {
|
||||
public_url: public_url.into(),
|
||||
session_key: b"a test key of at least thirty-two bytes".to_vec(),
|
||||
github: github::GitHub::new(
|
||||
github::Endpoints::default(),
|
||||
"o/r".into(),
|
||||
"client".into(),
|
||||
"secret".into(),
|
||||
"bot".into(),
|
||||
),
|
||||
update_key: None,
|
||||
scratch: std::env::temp_dir().join("horizon-mod-registry-test"),
|
||||
database: ":memory:".into(),
|
||||
})
|
||||
.unwrap(),
|
||||
)
|
||||
};
|
||||
// A public URL with the form's page in it, as the site's pages are.
|
||||
let app = site("https://playhorizon.test/mods");
|
||||
assert_eq!(
|
||||
origin_gate(&app, Some("https://playhorizon.test")).await,
|
||||
StatusCode::UNAUTHORIZED
|
||||
);
|
||||
// Another site's page, and one that names no origin at all.
|
||||
assert_eq!(
|
||||
origin_gate(&app, Some("https://evil.test")).await,
|
||||
StatusCode::FORBIDDEN
|
||||
);
|
||||
assert_eq!(origin_gate(&app, None).await, StatusCode::FORBIDDEN);
|
||||
// A public URL that is the origin itself keeps working.
|
||||
let app = site("https://playhorizon.test");
|
||||
assert_eq!(
|
||||
origin_gate(&app, Some("https://playhorizon.test")).await,
|
||||
StatusCode::UNAUTHORIZED
|
||||
);
|
||||
assert_eq!(
|
||||
origin_gate(&app, Some("https://evil.test")).await,
|
||||
StatusCode::FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
const ENTRY: &str = "kind = \"mod\"\nname = \"N\"\nsummary = \"s\"\nlicense = \"MIT\"\nsource = \"https://x.test\"\n\n[[versions]]\nversion = \"1.0.0\"\npublished = \"2026-01-01\"\ngame = \"26.3\"\nfile = \"f.hmod\"\nsha256 = \"0000000000000000000000000000000000000000000000000000000000000000\"\n";
|
||||
|
||||
impl Client {
|
||||
|
||||
Reference in new issue
Block a user