Add origin/host validation and warning for exposed serves

This commit is contained in:
boatbomber 2026-06-02 08:57:07 -07:00
parent 1abf675949
commit 4e45cc7d0a
7 changed files with 432 additions and 9 deletions

View file

@ -126,6 +126,10 @@ impl TestServeSession {
&self.project_path
}
pub fn port(&self) -> usize {
self.port
}
/// Waits for the `rojo serve` server to come online with expontential
/// backoff.
pub fn wait_to_come_online(&mut self) -> ServerInfoResponse {
@ -241,6 +245,35 @@ impl TestServeSession {
Ok(deserialize_msgpack(&body).expect("Server returned malformed response"))
}
/// Sends a GET to `/api/rojo` with the given extra request headers and
/// returns the response status code. Used to exercise the Host/Origin
/// allowlist that guards against DNS rebinding.
pub fn api_rojo_status_with_headers(&self, headers: &[(&str, &str)]) -> reqwest::StatusCode {
let client = reqwest::blocking::Client::new();
let url = format!("http://localhost:{}/api/rojo", self.port);
let mut request = client.get(url);
for (name, value) in headers {
request = request.header(*name, *value);
}
request.send().expect("Failed to send request").status()
}
/// Sends a POST to `/api/open/<id>` and returns the response status code.
/// Used to verify that the local-only gate on `/api/open` admits loopback
/// peers (the test harness always connects over loopback).
pub fn api_open_status(&self, id: &str) -> reqwest::StatusCode {
let client = reqwest::blocking::Client::new();
let url = format!("http://localhost:{}/api/open/{}", self.port, id);
client
.post(url)
.send()
.expect("Failed to send request")
.status()
}
}
fn serialize_msgpack<T: Serialize>(value: T) -> Result<Vec<u8>, rmp_serde::encode::Error> {

View file

@ -10,6 +10,51 @@ use crate::rojo_test::{
use librojo::web_api::SocketPacketType;
#[test]
fn rejects_dns_rebinding_requests() {
run_serve_test("empty", |session, _redactions| {
let port = session.port();
let local_host = format!("localhost:{port}");
// A request carrying a local Host header is served normally.
assert_eq!(
session.api_rojo_status_with_headers(&[("host", &local_host)]),
reqwest::StatusCode::OK,
);
// A request whose Host is a foreign hostname, as a DNS-rebound page
// would send, is rejected.
assert_eq!(
session.api_rojo_status_with_headers(&[("host", "evil.com")]),
reqwest::StatusCode::FORBIDDEN,
);
// Even with a local Host, a present-but-foreign Origin is rejected.
let foreign_origin = format!("http://evil.com:{port}");
assert_eq!(
session.api_rojo_status_with_headers(&[
("host", &local_host),
("origin", &foreign_origin),
]),
reqwest::StatusCode::FORBIDDEN,
);
});
}
#[test]
fn allows_api_open_from_loopback_peer() {
run_serve_test("empty", |session, _redactions| {
// The harness always connects over loopback, so the local-only gate on
// /api/open must let the request through. A bogus instance id then fails
// id parsing with 400, which confirms we got past the gate rather than
// being rejected with 403.
assert_eq!(
session.api_open_status("not-a-real-ref"),
reqwest::StatusCode::BAD_REQUEST,
);
});
}
#[test]
fn empty() {
run_serve_test("empty", |session, mut redactions| {