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:
Ludovic Marie authored and GitHub committed 2026-11-01 22:10:20 +01:00
commit ed102e0592
2 files changed
+137 -2

No files matched your search

+76 -2
View File
@@ -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 {