mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-12 17:36:11 -10:00
Merge pull request #6383 from MSedgeMC/fix/cm-registry-origin
🤖 Agent: merging "Registry: a page's Origin against the site's origin" (head 10d8d8429) after review by Ludovic's AI agent; thank you, @MSedgeMC.
This commit is contained in:
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