Add specified allowed hosts

This commit is contained in:
boatbomber 2026-06-03 13:06:39 -07:00
parent a90381cb13
commit 780c963645
6 changed files with 227 additions and 21 deletions

View file

@ -42,7 +42,7 @@ Making a new release? Simply add the new header with the version and date undern
* Fixed the sync fallback scrambling sibling order; replacements are now re-parented ancestors-first and in their original child order. ([#1265])
* Instances that share a name and class are now robustly matched on resync by comparing their properties, instead of relying on child order alone. ([#1266])
* Rojo now reports a clear error instead of panicking in several cases, including when the `serve` port is already in use, when a synced file is read-only or locked, when the filesystem watcher can't be created, and when the working directory is inaccessible. ([#1267])
* `rojo serve` now validates the `Host`/`Origin` headers to protect the local/private server against DNS rebinding, gates `/api/open` to local clients, and warns when bound to a network-reachable address. ([#1270])
* `rojo serve` now validates the `Host`/`Origin` headers to protect the local/private server against DNS rebinding, gates `/api/open` to local clients, and warns when bound to a network-reachable address. The accepted hosts can be extended with the `--allowed-hosts` option or a project's `serveAllowedHosts` field, for example to reach a network-exposed server by hostname. ([#1270])
[#1176]: https://github.com/rojo-rbx/rojo/pull/1176
[#1179]: https://github.com/rojo-rbx/rojo/pull/1179

View file

@ -31,6 +31,14 @@ pub struct ServeCommand {
/// it has none.
#[clap(long)]
pub port: Option<u16>,
/// Extra `Host`/`Origin` values the server will accept, beyond localhost and
/// the bind address (for example a hostname like `mypc.lan`). Repeat the
/// option or comma-separate to allow several. When given, this overrides the
/// project's `serveAllowedHosts`. Listing any host also turns on Host/Origin
/// validation for binds where it is otherwise off (such as `0.0.0.0`).
#[clap(long, value_delimiter = ',')]
pub allowed_hosts: Vec<String>,
}
impl ServeCommand {
@ -51,9 +59,17 @@ impl ServeCommand {
.or_else(|| session.project_port())
.unwrap_or(DEFAULT_PORT);
// The CLI flag, when given, replaces the project's list rather than
// merging with it, matching how --address and --port override theirs.
let allowed_hosts = if self.allowed_hosts.is_empty() {
session.serve_allowed_hosts().to_vec()
} else {
self.allowed_hosts
};
let server = LiveServer::new(session);
server.start((ip, port).into(), || {
server.start((ip, port).into(), allowed_hosts, || {
let _ = show_start_message(ip, port, global.color.into());
})?;

View file

@ -105,6 +105,15 @@ pub struct Project {
#[serde(skip_serializing_if = "Option::is_none")]
pub serve_address: Option<IpAddr>,
/// Additional `Host`/`Origin` header values that `rojo serve` will accept
/// beyond `localhost` and the bind address, such as a hostname like
/// `mypc.lan` used to reach a network-exposed server by name. Listing any
/// host also turns on `Host`/`Origin` validation for binds where it is
/// otherwise off (such as `0.0.0.0`). The `--allowed-hosts` CLI option
/// overrides this field when provided.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub serve_allowed_hosts: Vec<String>,
/// Determines if Rojo should emit scripts with the appropriate `RunContext`
/// for `*.client.lua` and `*.server.lua` files in the project instead of
/// using `Script` and `LocalScript` Instances.
@ -593,4 +602,39 @@ mod test {
assert!(project.sync_rules[0].include.is_match("data.data.json"));
assert!(project.sync_rules[1].include.is_match("init.module.lua"));
}
#[test]
fn project_with_serve_allowed_hosts() {
let project_json = r#"{
"name": "TestProject",
"tree": { "$path": "src" },
"serveAllowedHosts": ["mypc.lan", "192.168.1.5"]
}"#;
let project = Project::load_from_slice(
project_json.as_bytes(),
PathBuf::from("/test/default.project.json"),
None,
)
.expect("Failed to parse project with serveAllowedHosts");
assert_eq!(project.serve_allowed_hosts, vec!["mypc.lan", "192.168.1.5"]);
}
#[test]
fn project_without_serve_allowed_hosts_defaults_to_empty() {
let project_json = r#"{
"name": "TestProject",
"tree": { "$path": "src" }
}"#;
let project = Project::load_from_slice(
project_json.as_bytes(),
PathBuf::from("/test/default.project.json"),
None,
)
.expect("Failed to parse project");
assert!(project.serve_allowed_hosts.is_empty());
}
}

View file

@ -207,6 +207,10 @@ impl ServeSession {
self.root_project.serve_address
}
pub fn serve_allowed_hosts(&self) -> &[String] {
&self.root_project.serve_allowed_hosts
}
pub fn root_dir(&self) -> &Path {
self.root_project.folder_location()
}

View file

@ -34,12 +34,20 @@ impl LiveServer {
/// Starts the server on the given address, blocking until it stops.
///
/// `allowed_hosts` are extra `Host`/`Origin` values to accept in addition to
/// localhost and the bind address (see [`origin::allowed_hosts`]).
///
/// `on_listening` is invoked once the server has successfully bound to the
/// address, so callers can defer printing any "listening" message until
/// after binding can no longer fail (e.g. due to the port being in use).
pub fn start(self, address: SocketAddr, on_listening: impl FnOnce()) -> anyhow::Result<()> {
pub fn start(
self,
address: SocketAddr,
allowed_hosts: Vec<String>,
on_listening: impl FnOnce(),
) -> anyhow::Result<()> {
let serve_session = Arc::clone(&self.serve_session);
let allowed_hosts = origin::allowed_hosts(address.ip(), address.port());
let allowed_hosts = origin::allowed_hosts(address.ip(), address.port(), &allowed_hosts);
let make_service = make_service_fn(move |conn: &AddrStream| {
let serve_session = Arc::clone(&serve_session);

View file

@ -29,6 +29,13 @@
//! does nothing against a hostile peer already on the LAN, who can reach the
//! unauthenticated API directly. Both are the network-exposure risk the startup
//! warning addresses, not something the `Host` check is meant to cover.
//!
//! The allowlist can be widened with extra hosts (the `--allowed-hosts` CLI
//! option or a project's `serveAllowedHosts`), for example a hostname like
//! `mypc.lan` for reaching a network-exposed server by name. Listing any extra
//! host also turns enforcement back on for an unspecified or public bind that
//! would otherwise disable it, restricting that bind to localhost, the bind IP,
//! and the listed hosts.
use std::net::IpAddr;
@ -45,16 +52,23 @@ use crate::web::util::response;
pub struct AllowedHosts {
port: u16,
/// The bind IP accepted in addition to `localhost`/loopback, set only for a
/// private (LAN) bind. `None` for a loopback bind, which accepts localhost
/// and loopback literals only.
/// private (LAN) bind or a specific public bind that has extra hosts. `None`
/// for a loopback or unspecified bind, which has no single address to add.
bind_ip: Option<IpAddr>,
/// Extra `Host`/`Origin` values the user explicitly allowed, via the
/// `--allowed-hosts` option or a project's `serveAllowedHosts`. These are
/// hostnames such as `mypc.lan`, or IP literals, accepted in addition to
/// localhost and the bind IP. Each entry is already passed through
/// [`normalize_host`].
extra_hosts: Vec<String>,
}
impl AllowedHosts {
/// Returns whether the given host and optional port are allowed. The host is
/// accepted if it is `localhost`, a loopback IP literal, or (on a private
/// bind) the exact bind IP. A request with no explicit port is accepted (the
/// host already has to be one we recognize).
/// accepted if it is `localhost`, a loopback IP literal, (on a private or
/// specific public bind) the exact bind IP, or one of the explicitly allowed
/// extra hosts. A request with no explicit port is accepted (the host
/// already has to be one we recognize).
fn allows(&self, host: &str, port: Option<u16>) -> bool {
let host = normalize_host(host);
let host_ok = host.eq_ignore_ascii_case("localhost")
@ -62,25 +76,62 @@ impl AllowedHosts {
.parse::<IpAddr>()
.ok()
.map(canonical)
.is_some_and(|ip| ip.is_loopback() || self.bind_ip == Some(ip));
.is_some_and(|ip| ip.is_loopback() || self.bind_ip == Some(ip))
|| self.allows_extra(host);
host_ok && port.is_none_or(|port| port == self.port)
}
/// Returns whether `host` matches one of the explicitly allowed extra hosts.
/// Entries that are IP literals are compared as addresses, so equivalent
/// forms (such as an IPv4-mapped IPv6 literal) match; everything else is
/// compared as a case-insensitive hostname.
fn allows_extra(&self, host: &str) -> bool {
let host_ip = host.parse::<IpAddr>().ok().map(canonical);
self.extra_hosts.iter().any(|allowed| {
match (host_ip, allowed.parse::<IpAddr>().map(canonical)) {
(Some(host_ip), Ok(allowed_ip)) => host_ip == allowed_ip,
_ => host.eq_ignore_ascii_case(allowed),
}
})
}
}
/// Builds the allowlist for a given bind address. Returns `None` (validation
/// disabled) when bound to an unspecified (`0.0.0.0`/`::`) or public address,
/// where arbitrary hostnames may legitimately resolve to the server.
pub fn allowed_hosts(bind: IpAddr, port: u16) -> Option<AllowedHosts> {
/// Builds the allowlist for a given bind address and any extra allowed hosts.
/// Returns `None` (validation disabled) when bound to an unspecified
/// (`0.0.0.0`/`::`) or public address and no extra hosts were given, where
/// arbitrary hostnames may legitimately resolve to the server. Listing extra
/// hosts keeps validation on even for those binds.
pub fn allowed_hosts(bind: IpAddr, port: u16, extra: &[String]) -> Option<AllowedHosts> {
let extra_hosts: Vec<String> = extra
.iter()
.map(|host| normalize_host(host.trim()).to_owned())
.filter(|host| !host.is_empty())
.collect();
if bind.is_loopback() {
Some(AllowedHosts {
port,
bind_ip: None,
extra_hosts,
})
} else if is_private_bind(bind) {
Some(AllowedHosts {
port,
bind_ip: Some(canonical(bind)),
extra_hosts,
})
} else if !extra_hosts.is_empty() {
// The bind is unspecified or public, where validation is normally
// disabled. By listing explicit hosts the user has opted back into it,
// so we accept localhost, the bind IP (when it is a specific address),
// and those hosts. An unspecified bind (`0.0.0.0`/`::`) has no single
// address to add.
let bind_ip = (!bind.is_unspecified()).then(|| canonical(bind));
Some(AllowedHosts {
port,
bind_ip,
extra_hosts,
})
} else {
None
@ -213,12 +264,18 @@ mod test {
const PORT: u16 = 34872;
fn loopback_allowlist() -> Option<AllowedHosts> {
allowed_hosts(IpAddr::V4(Ipv4Addr::LOCALHOST), PORT)
allowed_hosts(IpAddr::V4(Ipv4Addr::LOCALHOST), PORT, &[])
}
/// An allowlist for a server bound to a specific private (LAN) address.
fn private_allowlist() -> Option<AllowedHosts> {
allowed_hosts(IpAddr::V4(Ipv4Addr::new(192, 168, 1, 5)), PORT)
allowed_hosts(IpAddr::V4(Ipv4Addr::new(192, 168, 1, 5)), PORT, &[])
}
/// An allowlist for `bind` with the given extra allowed hosts.
fn allowlist_with_hosts(bind: IpAddr, hosts: &[&str]) -> Option<AllowedHosts> {
let hosts: Vec<String> = hosts.iter().map(|host| host.to_string()).collect();
allowed_hosts(bind, PORT, &hosts)
}
fn request_with(headers: &[(&'static str, &str)]) -> Request<Body> {
@ -231,8 +288,8 @@ mod test {
#[test]
fn loopback_bind_enables_enforcement() {
assert!(allowed_hosts(IpAddr::V4(Ipv4Addr::LOCALHOST), PORT).is_some());
assert!(allowed_hosts(IpAddr::V6(Ipv6Addr::LOCALHOST), PORT).is_some());
assert!(allowed_hosts(IpAddr::V4(Ipv4Addr::LOCALHOST), PORT, &[]).is_some());
assert!(allowed_hosts(IpAddr::V6(Ipv6Addr::LOCALHOST), PORT, &[]).is_some());
}
#[test]
@ -246,7 +303,7 @@ mod test {
IpAddr::V6(Ipv6Addr::new(0xfe80, 0, 0, 0, 0, 0, 0, 1)), // link-local
] {
assert!(
allowed_hosts(bind, PORT).is_some(),
allowed_hosts(bind, PORT, &[]).is_some(),
"private bind {bind} should enable enforcement"
);
}
@ -261,7 +318,7 @@ mod test {
IpAddr::V6(Ipv6Addr::new(0x2001, 0x4860, 0, 0, 0, 0, 0, 0x8888)), // public
] {
assert!(
allowed_hosts(bind, PORT).is_none(),
allowed_hosts(bind, PORT, &[]).is_none(),
"bind {bind} should disable enforcement"
);
}
@ -427,13 +484,90 @@ mod test {
assert!(normalize_host("[fe80::1%eth0]").parse::<IpAddr>().is_ok());
}
#[test]
fn allowed_hosts_extend_the_allowlist() {
// A hostname listed as an allowed host is accepted on a loopback bind,
// with the usual port check still applied; an unlisted host is not.
let allowed = allowlist_with_hosts(IpAddr::V4(Ipv4Addr::LOCALHOST), &["mypc.lan"]);
let ok = request_with(&[("host", &format!("mypc.lan:{PORT}"))]);
assert!(check_request_origin(&ok, allowed.as_ref()).is_none());
let wrong_port = request_with(&[("host", "mypc.lan:1234")]);
assert!(check_request_origin(&wrong_port, allowed.as_ref()).is_some());
let other = request_with(&[("host", &format!("other.lan:{PORT}"))]);
assert!(check_request_origin(&other, allowed.as_ref()).is_some());
}
#[test]
fn allowed_hosts_apply_to_origin() {
let allowed = allowlist_with_hosts(IpAddr::V4(Ipv4Addr::LOCALHOST), &["mypc.lan"]);
let ok = request_with(&[
("host", &format!("mypc.lan:{PORT}")),
("origin", &format!("http://mypc.lan:{PORT}")),
]);
assert!(check_request_origin(&ok, allowed.as_ref()).is_none());
let foreign_origin = request_with(&[
("host", &format!("mypc.lan:{PORT}")),
("origin", &format!("http://evil.com:{PORT}")),
]);
assert!(check_request_origin(&foreign_origin, allowed.as_ref()).is_some());
}
#[test]
fn allowed_hosts_enable_enforcement_on_exposed_bind() {
// Binding to 0.0.0.0 normally disables validation, but listing a host
// turns it back on: localhost and the listed host are accepted while
// everything else is rejected.
let allowed = allowlist_with_hosts(IpAddr::V4(Ipv4Addr::UNSPECIFIED), &["mypc.lan"]);
assert!(allowed.is_some());
for host in [format!("mypc.lan:{PORT}"), format!("localhost:{PORT}")] {
let request = request_with(&[("host", &host)]);
assert!(
check_request_origin(&request, allowed.as_ref()).is_none(),
"host {host} should be allowed"
);
}
let evil = request_with(&[("host", "evil.com")]);
assert!(check_request_origin(&evil, allowed.as_ref()).is_some());
}
#[test]
fn allowed_hosts_on_public_bind_accept_the_bind_ip() {
// A specific public bind with an allowed host accepts localhost, the
// listed host, and the bind IP itself, but nothing else. (203.0.113.0/24
// is the TEST-NET-3 documentation range, treated here as a public IP.)
let bind = IpAddr::V4(Ipv4Addr::new(203, 0, 113, 5));
let allowed = allowlist_with_hosts(bind, &["mypc.lan"]);
for host in [
format!("203.0.113.5:{PORT}"),
format!("mypc.lan:{PORT}"),
format!("localhost:{PORT}"),
] {
let request = request_with(&[("host", &host)]);
assert!(
check_request_origin(&request, allowed.as_ref()).is_none(),
"host {host} should be allowed"
);
}
let evil = request_with(&[("host", "evil.com")]);
assert!(check_request_origin(&evil, allowed.as_ref()).is_some());
}
#[test]
fn disabled_allowlist_accepts_foreign_host() {
for bind in [
IpAddr::V4(Ipv4Addr::UNSPECIFIED),
IpAddr::V4(Ipv4Addr::new(8, 8, 8, 8)),
] {
let allowed = allowed_hosts(bind, PORT);
let allowed = allowed_hosts(bind, PORT, &[]);
let request = request_with(&[("host", "evil.com")]);
assert!(
check_request_origin(&request, allowed.as_ref()).is_none(),