mirror of
https://github.com/rojo-rbx/rojo.git
synced 2026-06-13 07:10:31 +00:00
Add origin/host validation and warning for exposed serves (#1270)
This commit is contained in:
@@ -44,6 +44,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])
|
* 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])
|
* 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 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. 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
|
[#1176]: https://github.com/rojo-rbx/rojo/pull/1176
|
||||||
[#1179]: https://github.com/rojo-rbx/rojo/pull/1179
|
[#1179]: https://github.com/rojo-rbx/rojo/pull/1179
|
||||||
@@ -58,6 +59,7 @@ Making a new release? Simply add the new header with the version and date undern
|
|||||||
[#1265]: https://github.com/rojo-rbx/rojo/pull/1265
|
[#1265]: https://github.com/rojo-rbx/rojo/pull/1265
|
||||||
[#1266]: https://github.com/rojo-rbx/rojo/pull/1266
|
[#1266]: https://github.com/rojo-rbx/rojo/pull/1266
|
||||||
[#1267]: https://github.com/rojo-rbx/rojo/pull/1267
|
[#1267]: https://github.com/rojo-rbx/rojo/pull/1267
|
||||||
|
[#1270]: https://github.com/rojo-rbx/rojo/pull/1270
|
||||||
|
|
||||||
## [7.7.0-rc.1] (November 27th, 2025)
|
## [7.7.0-rc.1] (November 27th, 2025)
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,14 @@ pub struct ServeCommand {
|
|||||||
/// it has none.
|
/// it has none.
|
||||||
#[clap(long)]
|
#[clap(long)]
|
||||||
pub port: Option<u16>,
|
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 {
|
impl ServeCommand {
|
||||||
@@ -51,9 +59,17 @@ impl ServeCommand {
|
|||||||
.or_else(|| session.project_port())
|
.or_else(|| session.project_port())
|
||||||
.unwrap_or(DEFAULT_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);
|
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());
|
let _ = show_start_message(ip, port, global.color.into());
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
@@ -87,6 +103,25 @@ fn show_start_message(bind_address: IpAddr, port: u16, color: ColorChoice) -> io
|
|||||||
|
|
||||||
writeln!(&mut buffer)?;
|
writeln!(&mut buffer)?;
|
||||||
|
|
||||||
|
if !bind_address.is_loopback() {
|
||||||
|
let mut warning = ColorSpec::new();
|
||||||
|
warning.set_fg(Some(Color::Yellow)).set_bold(true);
|
||||||
|
|
||||||
|
buffer.set_color(&warning)?;
|
||||||
|
writeln!(
|
||||||
|
&mut buffer,
|
||||||
|
"WARNING: This server is bound to {address_string}, which is reachable from the \
|
||||||
|
network.\n\
|
||||||
|
The serve API is unauthenticated, so anyone who can reach {address_string}:{port} \
|
||||||
|
can read\n\
|
||||||
|
and modify your project's source. Prefer binding to localhost and tunneling (e.g. \
|
||||||
|
SSH,\n\
|
||||||
|
Tailscale, or WireGuard) when you need remote access."
|
||||||
|
)?;
|
||||||
|
buffer.set_color(&ColorSpec::new())?;
|
||||||
|
writeln!(&mut buffer)?;
|
||||||
|
}
|
||||||
|
|
||||||
buffer.set_color(&ColorSpec::new())?;
|
buffer.set_color(&ColorSpec::new())?;
|
||||||
write!(&mut buffer, "Visit ")?;
|
write!(&mut buffer, "Visit ")?;
|
||||||
|
|
||||||
|
|||||||
@@ -106,6 +106,15 @@ pub struct Project {
|
|||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub serve_address: Option<IpAddr>,
|
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`
|
/// Determines if Rojo should emit scripts with the appropriate `RunContext`
|
||||||
/// for `*.client.lua` and `*.server.lua` files in the project instead of
|
/// for `*.client.lua` and `*.server.lua` files in the project instead of
|
||||||
/// using `Script` and `LocalScript` Instances.
|
/// using `Script` and `LocalScript` Instances.
|
||||||
@@ -595,6 +604,41 @@ mod test {
|
|||||||
assert!(project.sync_rules[1].include.is_match("init.module.lua"));
|
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());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn glob_ignore_paths_negation() {
|
fn glob_ignore_paths_negation() {
|
||||||
let project_json = r#"{
|
let project_json = r#"{
|
||||||
|
|||||||
@@ -207,6 +207,10 @@ impl ServeSession {
|
|||||||
self.root_project.serve_address
|
self.root_project.serve_address
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn serve_allowed_hosts(&self) -> &[String] {
|
||||||
|
&self.root_project.serve_allowed_hosts
|
||||||
|
}
|
||||||
|
|
||||||
pub fn root_dir(&self) -> &Path {
|
pub fn root_dir(&self) -> &Path {
|
||||||
self.root_project.folder_location()
|
self.root_project.folder_location()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
//! Defines Rojo's HTTP API, all under /api. These endpoints generally return
|
//! Defines Rojo's HTTP API, all under /api. These endpoints generally return
|
||||||
//! JSON.
|
//! JSON.
|
||||||
|
|
||||||
use std::{collections::HashMap, fs, path::PathBuf, str::FromStr, sync::Arc};
|
use std::{collections::HashMap, fs, net::SocketAddr, path::PathBuf, str::FromStr, sync::Arc};
|
||||||
|
|
||||||
use futures::{sink::SinkExt, stream::StreamExt};
|
use futures::{sink::SinkExt, stream::StreamExt};
|
||||||
use hyper::{body, Body, Method, Request, Response, StatusCode};
|
use hyper::{body, Body, Method, Request, Response, StatusCode};
|
||||||
@@ -21,6 +21,7 @@ use crate::{
|
|||||||
ServerInfoResponse, SocketPacket, SocketPacketBody, SocketPacketType, SubscribeMessage,
|
ServerInfoResponse, SocketPacket, SocketPacketBody, SocketPacketType, SubscribeMessage,
|
||||||
WriteRequest, WriteResponse, PROTOCOL_VERSION, SERVER_VERSION,
|
WriteRequest, WriteResponse, PROTOCOL_VERSION, SERVER_VERSION,
|
||||||
},
|
},
|
||||||
|
origin::canonical,
|
||||||
util::{deserialize_msgpack, msgpack, msgpack_ok, serialize_msgpack},
|
util::{deserialize_msgpack, msgpack, msgpack_ok, serialize_msgpack},
|
||||||
},
|
},
|
||||||
web_api::{
|
web_api::{
|
||||||
@@ -28,8 +29,12 @@ use crate::{
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
pub async fn call(serve_session: Arc<ServeSession>, mut request: Request<Body>) -> Response<Body> {
|
pub async fn call(
|
||||||
let service = ApiService::new(serve_session);
|
serve_session: Arc<ServeSession>,
|
||||||
|
remote_addr: SocketAddr,
|
||||||
|
mut request: Request<Body>,
|
||||||
|
) -> Response<Body> {
|
||||||
|
let service = ApiService::new(serve_session, remote_addr);
|
||||||
|
|
||||||
match (request.method(), request.uri().path()) {
|
match (request.method(), request.uri().path()) {
|
||||||
(&Method::GET, "/api/rojo") => service.handle_api_rojo().await,
|
(&Method::GET, "/api/rojo") => service.handle_api_rojo().await,
|
||||||
@@ -65,11 +70,15 @@ pub async fn call(serve_session: Arc<ServeSession>, mut request: Request<Body>)
|
|||||||
|
|
||||||
pub struct ApiService {
|
pub struct ApiService {
|
||||||
serve_session: Arc<ServeSession>,
|
serve_session: Arc<ServeSession>,
|
||||||
|
remote_addr: SocketAddr,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ApiService {
|
impl ApiService {
|
||||||
pub fn new(serve_session: Arc<ServeSession>) -> Self {
|
pub fn new(serve_session: Arc<ServeSession>, remote_addr: SocketAddr) -> Self {
|
||||||
ApiService { serve_session }
|
ApiService {
|
||||||
|
serve_session,
|
||||||
|
remote_addr,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get a summary of information about the server
|
/// Get a summary of information about the server
|
||||||
@@ -348,6 +357,30 @@ impl ApiService {
|
|||||||
|
|
||||||
/// Open a script with the given ID in the user's default text editor.
|
/// Open a script with the given ID in the user's default text editor.
|
||||||
async fn handle_api_open(&self, request: Request<Body>) -> Response<Body> {
|
async fn handle_api_open(&self, request: Request<Body>) -> Response<Body> {
|
||||||
|
// Opening a file launches a local program, so it must never be reachable
|
||||||
|
// by a remote client even when the server is bound to an exposed address.
|
||||||
|
//
|
||||||
|
// `remote_addr` is the immediate peer, which is the best locality signal
|
||||||
|
// we have: the legitimate caller is a sandboxed Roblox plugin whose only
|
||||||
|
// credential is being able to reach the port, so there is no secret to
|
||||||
|
// authenticate it with. A connection forwarded over loopback by an
|
||||||
|
// SSH/Tailscale tunnel or a local reverse proxy therefore appears local
|
||||||
|
// and is allowed. That is delegated trust rather than a bypass: by
|
||||||
|
// standing up that tunnel or proxy the user has decided the remote end is
|
||||||
|
// trusted, and reachability is bounded by that hop's own authentication
|
||||||
|
// (e.g. SSH keys or Tailscale ACLs). This gate only stops direct,
|
||||||
|
// unauthenticated peers.
|
||||||
|
//
|
||||||
|
// An IPv4 client reaching a dual-stack (`::`) bind appears as an
|
||||||
|
// IPv4-mapped IPv6 peer (`::ffff:127.0.0.1`), so canonicalize to the bare
|
||||||
|
// IPv4 form before the loopback test, matching `origin`'s handling.
|
||||||
|
if !canonical(self.remote_addr.ip()).is_loopback() {
|
||||||
|
return msgpack(
|
||||||
|
ErrorResponse::forbidden("/api/open is only available to local clients"),
|
||||||
|
StatusCode::FORBIDDEN,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
let argument = &request.uri().path()["/api/open/".len()..];
|
let argument = &request.uri().path()["/api/open/".len()..];
|
||||||
let requested_id = match Ref::from_str(argument) {
|
let requested_id = match Ref::from_str(argument) {
|
||||||
Ok(id) => id,
|
Ok(id) => id,
|
||||||
|
|||||||
@@ -290,6 +290,13 @@ impl ErrorResponse {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn forbidden<S: Into<String>>(details: S) -> Self {
|
||||||
|
Self {
|
||||||
|
kind: ErrorResponseKind::Forbidden,
|
||||||
|
details: details.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn internal_error<S: Into<String>>(details: S) -> Self {
|
pub fn internal_error<S: Into<String>>(details: S) -> Self {
|
||||||
Self {
|
Self {
|
||||||
kind: ErrorResponseKind::InternalError,
|
kind: ErrorResponseKind::InternalError,
|
||||||
@@ -302,5 +309,6 @@ impl ErrorResponse {
|
|||||||
pub enum ErrorResponseKind {
|
pub enum ErrorResponseKind {
|
||||||
NotFound,
|
NotFound,
|
||||||
BadRequest,
|
BadRequest,
|
||||||
|
Forbidden,
|
||||||
InternalError,
|
InternalError,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
mod api;
|
mod api;
|
||||||
mod assets;
|
mod assets;
|
||||||
pub mod interface;
|
pub mod interface;
|
||||||
|
mod origin;
|
||||||
mod ui;
|
mod ui;
|
||||||
mod util;
|
mod util;
|
||||||
|
|
||||||
@@ -14,7 +15,7 @@ use std::sync::Arc;
|
|||||||
|
|
||||||
use anyhow::Context;
|
use anyhow::Context;
|
||||||
use hyper::{
|
use hyper::{
|
||||||
server::Server,
|
server::{conn::AddrStream, Server},
|
||||||
service::{make_service_fn, service_fn},
|
service::{make_service_fn, service_fn},
|
||||||
Body, Request,
|
Body, Request,
|
||||||
};
|
};
|
||||||
@@ -33,22 +34,42 @@ impl LiveServer {
|
|||||||
|
|
||||||
/// Starts the server on the given address, blocking until it stops.
|
/// 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
|
/// `on_listening` is invoked once the server has successfully bound to the
|
||||||
/// address, so callers can defer printing any "listening" message until
|
/// 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).
|
/// 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 serve_session = Arc::clone(&self.serve_session);
|
||||||
|
let allowed_hosts = origin::allowed_hosts(address.ip(), address.port(), &allowed_hosts);
|
||||||
|
|
||||||
let make_service = make_service_fn(move |_conn| {
|
let make_service = make_service_fn(move |conn: &AddrStream| {
|
||||||
let serve_session = Arc::clone(&serve_session);
|
|
||||||
|
|
||||||
async {
|
|
||||||
let service = move |req: Request<Body>| {
|
|
||||||
let serve_session = Arc::clone(&serve_session);
|
let serve_session = Arc::clone(&serve_session);
|
||||||
|
let allowed_hosts = allowed_hosts.clone();
|
||||||
|
let remote_addr = conn.remote_addr();
|
||||||
|
|
||||||
async move {
|
async move {
|
||||||
|
let service = move |req: Request<Body>| {
|
||||||
|
let serve_session = Arc::clone(&serve_session);
|
||||||
|
let allowed_hosts = allowed_hosts.clone();
|
||||||
|
|
||||||
|
async move {
|
||||||
|
// Reject cross-origin requests before doing any work, to
|
||||||
|
// defend the local server against DNS rebinding.
|
||||||
|
if let Some(response) =
|
||||||
|
origin::check_request_origin(&req, allowed_hosts.as_ref())
|
||||||
|
{
|
||||||
|
return Ok::<_, Infallible>(response);
|
||||||
|
}
|
||||||
|
|
||||||
if req.uri().path().starts_with("/api") {
|
if req.uri().path().starts_with("/api") {
|
||||||
Ok::<_, Infallible>(api::call(serve_session, req).await)
|
Ok::<_, Infallible>(api::call(serve_session, remote_addr, req).await)
|
||||||
} else {
|
} else {
|
||||||
Ok::<_, Infallible>(ui::call(serve_session, req).await)
|
Ok::<_, Infallible>(ui::call(serve_session, req).await)
|
||||||
}
|
}
|
||||||
|
|||||||
589
src/web/origin.rs
Normal file
589
src/web/origin.rs
Normal file
@@ -0,0 +1,589 @@
|
|||||||
|
//! Host/Origin validation used to defend the local Rojo server against DNS
|
||||||
|
//! rebinding attacks.
|
||||||
|
//!
|
||||||
|
//! When Rojo is bound to a loopback address (the default), a malicious web page
|
||||||
|
//! the developer visits cannot read the API responses directly because of the
|
||||||
|
//! browser Same-Origin Policy. However, a DNS rebinding attack can defeat that:
|
||||||
|
//! the page points its own hostname at `127.0.0.1` after loading, so the browser
|
||||||
|
//! treats requests to the Rojo server as same-origin. Validating that the `Host`
|
||||||
|
//! (and, if present, `Origin`) header refers to an address we recognize blocks
|
||||||
|
//! this, because the rebound request still carries the attacker's hostname, which
|
||||||
|
//! is a domain name rather than one of the IP literals we accept.
|
||||||
|
//!
|
||||||
|
//! Enforcement covers two kinds of bind:
|
||||||
|
//!
|
||||||
|
//! * Loopback (the default): only `localhost` and loopback literals are
|
||||||
|
//! accepted.
|
||||||
|
//! * A specific private/LAN address: `localhost`, loopback literals, and that exact bind
|
||||||
|
//! IP are accepted. Because the defense works by rejecting any `Host` that
|
||||||
|
//! isn't a recognized IP literal, clients must connect to a private bind by
|
||||||
|
//! IP. A hostname (e.g. `mypc.local`) is indistinguishable from an attacker
|
||||||
|
//! domain and is rejected.
|
||||||
|
//!
|
||||||
|
//! Enforcement is disabled for unspecified (`0.0.0.0`/`::`) and public binds: the
|
||||||
|
//! user has asked for broad, possibly-public exposure where arbitrary hostnames
|
||||||
|
//! may legitimately resolve to the server, so we can't build a meaningful
|
||||||
|
//! allowlist. Those binds get a startup warning instead. Two consequences worth
|
||||||
|
//! being explicit about: such a bind has no rebinding protection even for a
|
||||||
|
//! browser on the same machine; and even on a protected private bind this check
|
||||||
|
//! 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;
|
||||||
|
|
||||||
|
use hyper::{
|
||||||
|
header::{HOST, ORIGIN},
|
||||||
|
http::uri::{Authority, Uri},
|
||||||
|
Body, Request, Response, StatusCode,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::web::util::response;
|
||||||
|
|
||||||
|
/// The set of `Host`/`Origin` values accepted while enforcement is active.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct AllowedHosts {
|
||||||
|
port: u16,
|
||||||
|
/// The bind IP accepted in addition to `localhost`/loopback, set only for a
|
||||||
|
/// 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, (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")
|
||||||
|
|| host
|
||||||
|
.parse::<IpAddr>()
|
||||||
|
.ok()
|
||||||
|
.map(canonical)
|
||||||
|
.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 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Collapses an IPv4-mapped IPv6 address (`::ffff:192.168.0.1`) to its IPv4 form
|
||||||
|
/// so it classifies and compares consistently with the bare IPv4 address. Shared
|
||||||
|
/// with the `/api/open` peer check so it recognizes a mapped loopback peer too.
|
||||||
|
pub(crate) fn canonical(ip: IpAddr) -> IpAddr {
|
||||||
|
match ip {
|
||||||
|
IpAddr::V6(v6) => v6.to_ipv4_mapped().map(IpAddr::V4).unwrap_or(ip),
|
||||||
|
v4 => v4,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns whether a bind address is a specific private/link-local address, the
|
||||||
|
/// case where enforcement stays on with the bind IP added to the allowlist.
|
||||||
|
/// Unspecified, loopback, and public addresses are excluded (loopback is handled
|
||||||
|
/// separately by [`allowed_hosts`]).
|
||||||
|
fn is_private_bind(ip: IpAddr) -> bool {
|
||||||
|
match canonical(ip) {
|
||||||
|
IpAddr::V4(v4) => v4.is_private() || v4.is_link_local(),
|
||||||
|
IpAddr::V6(v6) => {
|
||||||
|
let first = v6.segments()[0];
|
||||||
|
// Unique local (fc00::/7) or link-local (fe80::/10). The std methods
|
||||||
|
// for these are still nightly-only, so test the prefix directly.
|
||||||
|
(first & 0xfe00) == 0xfc00 || (first & 0xffc0) == 0xfe80
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validates the `Host` and `Origin` headers of an incoming request against the
|
||||||
|
/// allowlist. Returns `Some` with a ready-to-send `404` response when the
|
||||||
|
/// request should be rejected, or `None` when it is allowed to proceed. When
|
||||||
|
/// `allowed` is `None` (unspecified or public bind) every request is accepted.
|
||||||
|
pub fn check_request_origin(
|
||||||
|
request: &Request<Body>,
|
||||||
|
allowed: Option<&AllowedHosts>,
|
||||||
|
) -> Option<Response<Body>> {
|
||||||
|
let allowed = allowed?;
|
||||||
|
|
||||||
|
// The Host header is mandatory and must refer to a local address.
|
||||||
|
let host_ok = request
|
||||||
|
.headers()
|
||||||
|
.get(HOST)
|
||||||
|
.and_then(|value| value.to_str().ok())
|
||||||
|
.and_then(parse_authority)
|
||||||
|
.is_some_and(|(host, port)| allowed.allows(&host, port));
|
||||||
|
|
||||||
|
if !host_ok {
|
||||||
|
return Some(reject());
|
||||||
|
}
|
||||||
|
|
||||||
|
// The Origin header is optional: non-browser clients such as the Roblox
|
||||||
|
// plugin never send it. When it is present (i.e. a browser made the request)
|
||||||
|
// it must also be local, which rejects a rebound page whose origin is still
|
||||||
|
// its own non-local hostname.
|
||||||
|
if let Some(origin) = request.headers().get(ORIGIN) {
|
||||||
|
let origin_ok = origin
|
||||||
|
.to_str()
|
||||||
|
.ok()
|
||||||
|
.and_then(parse_origin)
|
||||||
|
.is_some_and(|(host, port)| allowed.allows(&host, port));
|
||||||
|
|
||||||
|
if !origin_ok {
|
||||||
|
return Some(reject());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Normalizes a host literal for parsing/comparison: strips the surrounding
|
||||||
|
/// brackets from an IPv6 literal (e.g. `[::1]`) and drops any IPv6 zone id (e.g.
|
||||||
|
/// `fe80::1%eth0`), which `Ipv6Addr::from_str` would otherwise reject.
|
||||||
|
fn normalize_host(host: &str) -> &str {
|
||||||
|
let host = host
|
||||||
|
.strip_prefix('[')
|
||||||
|
.and_then(|host| host.strip_suffix(']'))
|
||||||
|
.unwrap_or(host);
|
||||||
|
|
||||||
|
host.split('%').next().unwrap_or(host)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parses a `Host` header value (an authority such as `localhost:34872`) into
|
||||||
|
/// its host and optional port. Returns `None` if the authority carries userinfo
|
||||||
|
/// (e.g. `evil.com@localhost`), so a value whose host looks local only after the
|
||||||
|
/// userinfo is stripped can never sneak past the allowlist.
|
||||||
|
fn parse_authority(value: &str) -> Option<(String, Option<u16>)> {
|
||||||
|
let authority: Authority = value.parse().ok()?;
|
||||||
|
reject_userinfo(&authority)?;
|
||||||
|
Some((authority.host().to_owned(), authority.port_u16()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parses an `Origin` header value (an absolute URI such as
|
||||||
|
/// `http://localhost:34872`) into its host and optional port. Returns `None` for
|
||||||
|
/// origins without a host, such as the opaque `null` origin, or for origins whose
|
||||||
|
/// authority carries userinfo (see [`parse_authority`]).
|
||||||
|
fn parse_origin(value: &str) -> Option<(String, Option<u16>)> {
|
||||||
|
let uri: Uri = value.parse().ok()?;
|
||||||
|
reject_userinfo(uri.authority()?)?;
|
||||||
|
Some((uri.host()?.to_owned(), uri.port_u16()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns `None` (rejecting the value) when an authority contains a userinfo
|
||||||
|
/// component, identified by the `@` separator. A bare host or `host:port` never
|
||||||
|
/// contains `@`, so this only fires on `userinfo@host` forms.
|
||||||
|
fn reject_userinfo(authority: &Authority) -> Option<()> {
|
||||||
|
if authority.as_str().contains('@') {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds the response sent when a request fails Host/Origin validation. It is a
|
||||||
|
/// generic `404` with no Rojo-identifying body: a rejected request may be a
|
||||||
|
/// prober (or a DNS-rebound page's same-origin script, which could read the
|
||||||
|
/// body), so we reveal nothing rather than confirming a Rojo server is here.
|
||||||
|
fn reject() -> Response<Body> {
|
||||||
|
response(StatusCode::NOT_FOUND, "text/plain", "Not Found")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod test {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
use std::net::{Ipv4Addr, Ipv6Addr};
|
||||||
|
|
||||||
|
const PORT: u16 = 34872;
|
||||||
|
|
||||||
|
fn loopback_allowlist() -> Option<AllowedHosts> {
|
||||||
|
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, &[])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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> {
|
||||||
|
let mut builder = Request::builder().uri("/api/rojo");
|
||||||
|
for (name, value) in headers {
|
||||||
|
builder = builder.header(*name, *value);
|
||||||
|
}
|
||||||
|
builder.body(Body::empty()).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn private_bind_enables_enforcement() {
|
||||||
|
for bind in [
|
||||||
|
IpAddr::V4(Ipv4Addr::new(192, 168, 1, 5)), // 192.168.0.0/16
|
||||||
|
IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)), // 10.0.0.0/8
|
||||||
|
IpAddr::V4(Ipv4Addr::new(172, 16, 0, 1)), // 172.16.0.0/12
|
||||||
|
IpAddr::V4(Ipv4Addr::new(169, 254, 0, 1)), // link-local
|
||||||
|
IpAddr::V6(Ipv6Addr::new(0xfc00, 0, 0, 0, 0, 0, 0, 1)), // unique local
|
||||||
|
IpAddr::V6(Ipv6Addr::new(0xfe80, 0, 0, 0, 0, 0, 0, 1)), // link-local
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
allowed_hosts(bind, PORT, &[]).is_some(),
|
||||||
|
"private bind {bind} should enable enforcement"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unspecified_or_public_bind_disables_enforcement() {
|
||||||
|
for bind in [
|
||||||
|
IpAddr::V4(Ipv4Addr::UNSPECIFIED), // 0.0.0.0
|
||||||
|
IpAddr::V6(Ipv6Addr::UNSPECIFIED), // ::
|
||||||
|
IpAddr::V4(Ipv4Addr::new(8, 8, 8, 8)), // public
|
||||||
|
IpAddr::V6(Ipv6Addr::new(0x2001, 0x4860, 0, 0, 0, 0, 0, 0x8888)), // public
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
allowed_hosts(bind, PORT, &[]).is_none(),
|
||||||
|
"bind {bind} should disable enforcement"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn accepts_local_hosts() {
|
||||||
|
let allowed = loopback_allowlist();
|
||||||
|
for host in [
|
||||||
|
format!("localhost:{PORT}"),
|
||||||
|
format!("127.0.0.1:{PORT}"),
|
||||||
|
format!("[::1]:{PORT}"),
|
||||||
|
"localhost".to_owned(),
|
||||||
|
] {
|
||||||
|
let request = request_with(&[("host", &host)]);
|
||||||
|
assert!(
|
||||||
|
check_request_origin(&request, allowed.as_ref()).is_none(),
|
||||||
|
"host {host} should be allowed"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_foreign_host() {
|
||||||
|
let allowed = loopback_allowlist();
|
||||||
|
let request = request_with(&[("host", "evil.com")]);
|
||||||
|
assert!(check_request_origin(&request, allowed.as_ref()).is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_wrong_port() {
|
||||||
|
let allowed = loopback_allowlist();
|
||||||
|
let request = request_with(&[("host", "localhost:1234")]);
|
||||||
|
assert!(check_request_origin(&request, allowed.as_ref()).is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_missing_host() {
|
||||||
|
let allowed = loopback_allowlist();
|
||||||
|
let request = request_with(&[]);
|
||||||
|
assert!(check_request_origin(&request, allowed.as_ref()).is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_host_with_userinfo() {
|
||||||
|
let allowed = loopback_allowlist();
|
||||||
|
// The host parses to `localhost`, but the userinfo prefix must keep it
|
||||||
|
// from being treated as a local address.
|
||||||
|
let request = request_with(&[("host", &format!("evil.com@localhost:{PORT}"))]);
|
||||||
|
assert!(check_request_origin(&request, allowed.as_ref()).is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_origin_with_userinfo() {
|
||||||
|
let allowed = loopback_allowlist();
|
||||||
|
let request = request_with(&[
|
||||||
|
("host", &format!("localhost:{PORT}")),
|
||||||
|
("origin", &format!("http://evil.com@localhost:{PORT}")),
|
||||||
|
]);
|
||||||
|
assert!(check_request_origin(&request, allowed.as_ref()).is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_foreign_origin_even_with_local_host() {
|
||||||
|
let allowed = loopback_allowlist();
|
||||||
|
let request = request_with(&[
|
||||||
|
("host", &format!("localhost:{PORT}")),
|
||||||
|
("origin", &format!("http://evil.com:{PORT}")),
|
||||||
|
]);
|
||||||
|
assert!(check_request_origin(&request, allowed.as_ref()).is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_null_origin() {
|
||||||
|
let allowed = loopback_allowlist();
|
||||||
|
let request = request_with(&[("host", &format!("localhost:{PORT}")), ("origin", "null")]);
|
||||||
|
assert!(check_request_origin(&request, allowed.as_ref()).is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn accepts_local_origin() {
|
||||||
|
let allowed = loopback_allowlist();
|
||||||
|
let request = request_with(&[
|
||||||
|
("host", &format!("localhost:{PORT}")),
|
||||||
|
("origin", &format!("http://localhost:{PORT}")),
|
||||||
|
]);
|
||||||
|
assert!(check_request_origin(&request, allowed.as_ref()).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn private_bind_accepts_local_and_bind_ip_hosts() {
|
||||||
|
let allowed = private_allowlist();
|
||||||
|
for host in [
|
||||||
|
format!("192.168.1.5:{PORT}"),
|
||||||
|
format!("localhost:{PORT}"),
|
||||||
|
format!("127.0.0.1:{PORT}"),
|
||||||
|
format!("[::1]:{PORT}"),
|
||||||
|
"192.168.1.5".to_owned(),
|
||||||
|
] {
|
||||||
|
let request = request_with(&[("host", &host)]);
|
||||||
|
assert!(
|
||||||
|
check_request_origin(&request, allowed.as_ref()).is_none(),
|
||||||
|
"host {host} should be allowed on a private bind"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn private_bind_rejects_other_hosts() {
|
||||||
|
let allowed = private_allowlist();
|
||||||
|
for host in [
|
||||||
|
"evil.com", // a rebound attacker domain
|
||||||
|
"192.168.1.6", // a different private IP
|
||||||
|
"8.8.8.8", // a public IP
|
||||||
|
] {
|
||||||
|
let request = request_with(&[("host", host)]);
|
||||||
|
assert!(
|
||||||
|
check_request_origin(&request, allowed.as_ref()).is_some(),
|
||||||
|
"host {host} should be rejected on a private bind"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn private_bind_keeps_origin_strict() {
|
||||||
|
// A different private IP as Origin must be rejected even though the Host
|
||||||
|
// is the valid bind IP: the Origin check is not widened to arbitrary
|
||||||
|
// private addresses.
|
||||||
|
let allowed = private_allowlist();
|
||||||
|
let request = request_with(&[
|
||||||
|
("host", &format!("192.168.1.5:{PORT}")),
|
||||||
|
("origin", &format!("http://192.168.1.6:{PORT}")),
|
||||||
|
]);
|
||||||
|
assert!(check_request_origin(&request, allowed.as_ref()).is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn private_bind_accepts_bind_ip_origin() {
|
||||||
|
let allowed = private_allowlist();
|
||||||
|
let request = request_with(&[
|
||||||
|
("host", &format!("192.168.1.5:{PORT}")),
|
||||||
|
("origin", &format!("http://192.168.1.5:{PORT}")),
|
||||||
|
]);
|
||||||
|
assert!(check_request_origin(&request, allowed.as_ref()).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn accepts_ipv4_mapped_bind_ip() {
|
||||||
|
// `::ffff:192.168.1.5` is the IPv4-mapped form of the bind IP and must
|
||||||
|
// be treated as equal to it.
|
||||||
|
let allowed = private_allowlist();
|
||||||
|
let request = request_with(&[("host", &format!("[::ffff:192.168.1.5]:{PORT}"))]);
|
||||||
|
assert!(check_request_origin(&request, allowed.as_ref()).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn canonical_collapses_ipv4_mapped_loopback() {
|
||||||
|
// The `/api/open` peer gate relies on this: an IPv4 loopback client
|
||||||
|
// reaching a dual-stack (`::`) bind arrives as `::ffff:127.0.0.1`, which
|
||||||
|
// is only recognized as loopback after canonicalization.
|
||||||
|
let mapped: IpAddr = "::ffff:127.0.0.1".parse().unwrap();
|
||||||
|
assert!(!mapped.is_loopback());
|
||||||
|
assert!(canonical(mapped).is_loopback());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn normalize_host_strips_brackets_and_zone_id() {
|
||||||
|
// Brackets and an IPv6 zone id must be removed so the result parses as an
|
||||||
|
// `IpAddr` (`Ipv6Addr::from_str` rejects zone ids).
|
||||||
|
assert_eq!(normalize_host("[::1]"), "::1");
|
||||||
|
assert_eq!(normalize_host("[fe80::1%eth0]"), "fe80::1");
|
||||||
|
assert_eq!(normalize_host("localhost"), "localhost");
|
||||||
|
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 request = request_with(&[("host", "evil.com")]);
|
||||||
|
assert!(
|
||||||
|
check_request_origin(&request, allowed.as_ref()).is_none(),
|
||||||
|
"disabled allowlist for bind {bind} should accept any host"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -126,6 +126,10 @@ impl TestServeSession {
|
|||||||
&self.project_path
|
&self.project_path
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn port(&self) -> usize {
|
||||||
|
self.port
|
||||||
|
}
|
||||||
|
|
||||||
/// Waits for the `rojo serve` server to come online with expontential
|
/// Waits for the `rojo serve` server to come online with expontential
|
||||||
/// backoff.
|
/// backoff.
|
||||||
pub fn wait_to_come_online(&mut self) -> ServerInfoResponse {
|
pub fn wait_to_come_online(&mut self) -> ServerInfoResponse {
|
||||||
@@ -241,6 +245,39 @@ impl TestServeSession {
|
|||||||
|
|
||||||
Ok(deserialize_msgpack(&body).expect("Server returned malformed response"))
|
Ok(deserialize_msgpack(&body).expect("Server returned malformed response"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Sends a GET to `/api/rojo` with the given extra request headers and
|
||||||
|
/// returns the full response. Used to exercise the Host/Origin allowlist that
|
||||||
|
/// guards against DNS rebinding, including asserting that a rejection reveals
|
||||||
|
/// nothing about the server.
|
||||||
|
pub fn api_rojo_response_with_headers(
|
||||||
|
&self,
|
||||||
|
headers: &[(&str, &str)],
|
||||||
|
) -> reqwest::blocking::Response {
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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> {
|
fn serialize_msgpack<T: Serialize>(value: T) -> Result<Vec<u8>, rmp_serde::encode::Error> {
|
||||||
|
|||||||
@@ -10,6 +10,74 @@ use crate::rojo_test::{
|
|||||||
|
|
||||||
use librojo::web_api::SocketPacketType;
|
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_response_with_headers(&[("host", &local_host)])
|
||||||
|
.status(),
|
||||||
|
reqwest::StatusCode::OK,
|
||||||
|
);
|
||||||
|
|
||||||
|
// A request whose Host is a foreign hostname, as a DNS-rebound page
|
||||||
|
// would send, is rejected with a generic 404 that reveals nothing about
|
||||||
|
// the server.
|
||||||
|
assert_rejected(session.api_rojo_response_with_headers(&[("host", "evil.com")]));
|
||||||
|
|
||||||
|
// Even with a local Host, a present-but-foreign Origin is rejected.
|
||||||
|
let foreign_origin = format!("http://evil.com:{port}");
|
||||||
|
assert_rejected(
|
||||||
|
session.api_rojo_response_with_headers(&[
|
||||||
|
("host", &local_host),
|
||||||
|
("origin", &foreign_origin),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Asserts that a Host/Origin rejection is a generic 404 whose body and
|
||||||
|
/// content-type do not identify the server as Rojo.
|
||||||
|
fn assert_rejected(response: reqwest::blocking::Response) {
|
||||||
|
assert_eq!(response.status(), reqwest::StatusCode::NOT_FOUND);
|
||||||
|
|
||||||
|
let content_type = response
|
||||||
|
.headers()
|
||||||
|
.get(reqwest::header::CONTENT_TYPE)
|
||||||
|
.and_then(|value| value.to_str().ok())
|
||||||
|
.unwrap_or_default()
|
||||||
|
.to_owned();
|
||||||
|
assert!(
|
||||||
|
!content_type.contains("msgpack"),
|
||||||
|
"rejection should not use the msgpack API content-type, got {content_type:?}",
|
||||||
|
);
|
||||||
|
|
||||||
|
let body = response.text().expect("Failed to read response body");
|
||||||
|
let body_lower = body.to_lowercase();
|
||||||
|
assert!(
|
||||||
|
!body_lower.contains("rojo") && !body_lower.contains("rebinding"),
|
||||||
|
"rejection body should not identify the server, got {body:?}",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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]
|
#[test]
|
||||||
fn empty() {
|
fn empty() {
|
||||||
run_serve_test("empty", |session, mut redactions| {
|
run_serve_test("empty", |session, mut redactions| {
|
||||||
|
|||||||
Reference in New Issue
Block a user