diff --git a/CHANGELOG.md b/CHANGELOG.md
index 20165890..4459f61a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -39,10 +39,13 @@ Making a new release? Simply add the new header with the version and date undern
* Fixed a bug where the notification timeout thread would fail to cancel on unmount ([#1211])
* Added a "Forget" option to the sync reminder notification to avoid being reminded for that place in the future ([#1215])
* Improves relative path calculation for sourcemap generation to avoid issues with Windows UNC paths. ([#1217])
+* Fixed missing support for init.plugin.lua and init.plugin.luau. ([#1252])
+* Add support for gitignore-style negation in `globIgnorePaths` and syncback's `ignorePaths` ([#1256])
* 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])
* Fixed `/api/serialize` returning success when a requested instance ID is missing from the serve session tree. ([#1272])
+* `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
@@ -52,10 +55,13 @@ Making a new release? Simply add the new header with the version and date undern
[#1211]: https://github.com/rojo-rbx/rojo/pull/1211
[#1215]: https://github.com/rojo-rbx/rojo/pull/1215
[#1217]: https://github.com/rojo-rbx/rojo/pull/1217
+[#1252]: https://github.com/rojo-rbx/rojo/pull/1252
+[#1256]: https://github.com/rojo-rbx/rojo/pull/1256
[#1265]: https://github.com/rojo-rbx/rojo/pull/1265
[#1266]: https://github.com/rojo-rbx/rojo/pull/1266
[#1267]: https://github.com/rojo-rbx/rojo/pull/1267
[#1272]: https://github.com/rojo-rbx/rojo/pull/1272
+[#1270]: https://github.com/rojo-rbx/rojo/pull/1270
## [7.7.0-rc.1] (November 27th, 2025)
diff --git a/rojo-test/build-test-snapshots/end_to_end__tests__build__plugin_init.snap b/rojo-test/build-test-snapshots/end_to_end__tests__build__plugin_init.snap
new file mode 100644
index 00000000..40def8cd
--- /dev/null
+++ b/rojo-test/build-test-snapshots/end_to_end__tests__build__plugin_init.snap
@@ -0,0 +1,27 @@
+---
+source: tests/tests/build.rs
+expression: contents
+---
+
+ -
+
+ plugin_init
+
+ -
+
+ lua
+ 3
+
+
+
+ -
+
+ luau
+ 3
+
+
+
+
+
diff --git a/rojo-test/build-tests/plugin_init/default.project.json b/rojo-test/build-tests/plugin_init/default.project.json
new file mode 100644
index 00000000..c683b506
--- /dev/null
+++ b/rojo-test/build-tests/plugin_init/default.project.json
@@ -0,0 +1,6 @@
+{
+ "name": "plugin_init",
+ "tree": {
+ "$path": "folder"
+ }
+}
diff --git a/rojo-test/build-tests/plugin_init/folder/lua/init.plugin.lua b/rojo-test/build-tests/plugin_init/folder/lua/init.plugin.lua
new file mode 100644
index 00000000..9761a96d
--- /dev/null
+++ b/rojo-test/build-tests/plugin_init/folder/lua/init.plugin.lua
@@ -0,0 +1 @@
+return "From folder/lua/init.plugin.lua"
diff --git a/rojo-test/build-tests/plugin_init/folder/luau/init.plugin.luau b/rojo-test/build-tests/plugin_init/folder/luau/init.plugin.luau
new file mode 100644
index 00000000..f5de5239
--- /dev/null
+++ b/rojo-test/build-tests/plugin_init/folder/luau/init.plugin.luau
@@ -0,0 +1 @@
+return "From folder/luau/init.plugin.luau"
diff --git a/src/cli/serve.rs b/src/cli/serve.rs
index e33c3fc5..b22beb69 100644
--- a/src/cli/serve.rs
+++ b/src/cli/serve.rs
@@ -31,6 +31,14 @@ pub struct ServeCommand {
/// it has none.
#[clap(long)]
pub port: Option,
+
+ /// 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,
}
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());
})?;
@@ -87,6 +103,25 @@ fn show_start_message(bind_address: IpAddr, port: u16, color: ColorChoice) -> io
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())?;
write!(&mut buffer, "Visit ")?;
diff --git a/src/glob.rs b/src/glob.rs
index 96437c38..c0146863 100644
--- a/src/glob.rs
+++ b/src/glob.rs
@@ -48,3 +48,61 @@ impl<'de> Deserialize<'de> for Glob {
Glob::new(&glob).map_err(D::Error::custom)
}
}
+
+/// A glob with optional gitignore-style negation. A leading `!` marks the
+/// pattern as a negation (re-includes paths that an earlier rule excluded).
+/// To match a literal `!` at the start of a pattern, escape it with `\!`.
+#[derive(Debug, Clone)]
+pub struct IgnorableGlob {
+ glob: Glob,
+ negated: bool,
+ raw: String,
+}
+
+impl IgnorableGlob {
+ pub fn new(pattern: &str) -> Result {
+ let (negated, body) = if let Some(rest) = pattern.strip_prefix('!') {
+ (true, rest)
+ } else if pattern.starts_with(r"\!") {
+ (false, &pattern[1..])
+ } else {
+ (false, pattern)
+ };
+
+ Ok(IgnorableGlob {
+ glob: Glob::new(body)?,
+ negated,
+ raw: pattern.to_owned(),
+ })
+ }
+
+ pub fn is_match>(&self, path: P) -> bool {
+ self.glob.is_match(path)
+ }
+
+ pub fn is_negation(&self) -> bool {
+ self.negated
+ }
+}
+
+impl PartialEq for IgnorableGlob {
+ fn eq(&self, other: &Self) -> bool {
+ self.negated == other.negated && self.glob == other.glob
+ }
+}
+
+impl Eq for IgnorableGlob {}
+
+impl Serialize for IgnorableGlob {
+ fn serialize(&self, serializer: S) -> Result {
+ serializer.serialize_str(&self.raw)
+ }
+}
+
+impl<'de> Deserialize<'de> for IgnorableGlob {
+ fn deserialize>(deserializer: D) -> Result {
+ let pattern = String::deserialize(deserializer)?;
+
+ IgnorableGlob::new(&pattern).map_err(D::Error::custom)
+ }
+}
diff --git a/src/project.rs b/src/project.rs
index 5cc8278e..093529b1 100644
--- a/src/project.rs
+++ b/src/project.rs
@@ -12,7 +12,8 @@ use serde::{Deserialize, Serialize};
use thiserror::Error;
use crate::{
- glob::Glob, json, resolution::UnresolvedValue, snapshot::SyncRule, syncback::SyncbackRules,
+ glob::IgnorableGlob, json, resolution::UnresolvedValue, snapshot::SyncRule,
+ syncback::SyncbackRules,
};
/// Represents 'default' project names that act as `init` files
@@ -105,6 +106,15 @@ pub struct Project {
#[serde(skip_serializing_if = "Option::is_none")]
pub serve_address: Option,
+ /// 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,
+
/// 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.
@@ -114,7 +124,7 @@ pub struct Project {
/// A list of globs, relative to the folder the project file is in, that
/// match files that should be excluded if Rojo encounters them.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
- pub glob_ignore_paths: Vec,
+ pub glob_ignore_paths: Vec,
/// A list of rules for syncback with this project file.
#[serde(skip_serializing_if = "Option::is_none")]
@@ -593,4 +603,90 @@ 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());
+ }
+
+ #[test]
+ fn glob_ignore_paths_negation() {
+ let project_json = r#"{
+ "name": "TestProject",
+ "tree": { "$path": "src" },
+ "globIgnorePaths": [
+ "**/*.spec.lua",
+ "!keep.spec.lua",
+ "\\!literal.lua"
+ ]
+ }"#;
+
+ let project = Project::load_from_slice(
+ project_json.as_bytes(),
+ PathBuf::from("/test/default.project.json"),
+ None,
+ )
+ .expect("project should parse");
+
+ let paths = &project.glob_ignore_paths;
+ assert_eq!(paths.len(), 3);
+
+ assert!(!paths[0].is_negation());
+ assert!(paths[0].is_match("foo.spec.lua"));
+
+ assert!(paths[1].is_negation());
+ assert!(paths[1].is_match("keep.spec.lua"));
+
+ // `\!literal.lua` should match a file literally named `!literal.lua`,
+ // not be parsed as a negation.
+ assert!(!paths[2].is_negation());
+ assert!(paths[2].is_match("!literal.lua"));
+
+ let rules: Vec<_> = paths
+ .iter()
+ .map(|g| crate::snapshot::PathIgnoreRule {
+ base_path: PathBuf::from("/test"),
+ glob: g.clone(),
+ })
+ .collect();
+ assert!(crate::snapshot::is_path_ignored(
+ &rules,
+ "/test/foo.spec.lua"
+ ));
+ assert!(!crate::snapshot::is_path_ignored(
+ &rules,
+ "/test/keep.spec.lua"
+ ));
+ assert!(!crate::snapshot::is_path_ignored(&rules, "/test/plain.lua"));
+ }
}
diff --git a/src/serve_session.rs b/src/serve_session.rs
index f50d3926..e46cdfe9 100644
--- a/src/serve_session.rs
+++ b/src/serve_session.rs
@@ -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()
}
diff --git a/src/snapshot/metadata.rs b/src/snapshot/metadata.rs
index 79f7a2d0..a1bf0afe 100644
--- a/src/snapshot/metadata.rs
+++ b/src/snapshot/metadata.rs
@@ -8,7 +8,7 @@ use anyhow::Context;
use serde::{Deserialize, Serialize};
use crate::{
- glob::Glob,
+ glob::{Glob, IgnorableGlob},
path_serializer,
project::ProjectNode,
snapshot_middleware::{emit_legacy_scripts_default, Middleware},
@@ -222,18 +222,37 @@ pub struct PathIgnoreRule {
pub base_path: PathBuf,
/// The actual glob that can be matched against the input path.
- pub glob: Glob,
+ pub glob: IgnorableGlob,
}
impl PathIgnoreRule {
- pub fn passes>(&self, path: P) -> bool {
+ pub fn matches>(&self, path: P) -> bool {
let path = path.as_ref();
match path.strip_prefix(&self.base_path) {
- Ok(suffix) => !self.glob.is_match(suffix),
- Err(_) => true,
+ Ok(suffix) => self.glob.is_match(suffix),
+ Err(_) => false,
}
}
+
+ pub fn is_negation(&self) -> bool {
+ self.glob.is_negation()
+ }
+}
+
+/// Evaluates an ordered list of [`PathIgnoreRule`]s against a path using
+/// gitignore-style "last match wins" semantics: a path is ignored if the last
+/// rule whose pattern matches it is non-negated. Paths matched by no rule are
+/// not ignored.
+pub fn is_path_ignored>(rules: &[PathIgnoreRule], path: P) -> bool {
+ let path = path.as_ref();
+ let mut ignored = false;
+ for rule in rules {
+ if rule.matches(path) {
+ ignored = !rule.is_negation();
+ }
+ }
+ ignored
}
/// Represents where a particular Instance or InstanceSnapshot came from.
diff --git a/src/snapshot_middleware/dir.rs b/src/snapshot_middleware/dir.rs
index fb444c37..b9f62743 100644
--- a/src/snapshot_middleware/dir.rs
+++ b/src/snapshot_middleware/dir.rs
@@ -7,7 +7,9 @@ use anyhow::Context;
use memofs::{DirEntry, Vfs};
use crate::{
- snapshot::{InstanceContext, InstanceMetadata, InstanceSnapshot, InstigatingSource},
+ snapshot::{
+ is_path_ignored, InstanceContext, InstanceMetadata, InstanceSnapshot, InstigatingSource,
+ },
syncback::{hash_instance, FsSnapshot, SyncbackReturn, SyncbackSnapshot},
};
@@ -41,12 +43,8 @@ pub fn snapshot_dir_no_meta(
path: &Path,
name: &str,
) -> anyhow::Result> {
- let passes_filter_rules = |child: &DirEntry| {
- context
- .path_ignore_rules
- .iter()
- .all(|rule| rule.passes(child.path()))
- };
+ let passes_filter_rules =
+ |child: &DirEntry| !is_path_ignored(&context.path_ignore_rules, child.path());
let mut snapshot_children = Vec::new();
@@ -74,6 +72,8 @@ pub fn snapshot_dir_no_meta(
normalized_path.join("init.server.luau"),
normalized_path.join("init.client.lua"),
normalized_path.join("init.client.luau"),
+ normalized_path.join("init.plugin.lua"),
+ normalized_path.join("init.plugin.luau"),
normalized_path.join("init.csv"),
];
diff --git a/src/snapshot_middleware/lua.rs b/src/snapshot_middleware/lua.rs
index a0058555..98f43e40 100644
--- a/src/snapshot_middleware/lua.rs
+++ b/src/snapshot_middleware/lua.rs
@@ -182,6 +182,7 @@ pub fn syncback_lua_init<'sync>(
ScriptType::Server => "init.server.luau",
ScriptType::Client => "init.client.luau",
ScriptType::Module => "init.luau",
+ ScriptType::Plugin => "init.plugin.luau",
_ => anyhow::bail!("syncback is not yet implemented for {script_type:?}"),
});
diff --git a/src/snapshot_middleware/mod.rs b/src/snapshot_middleware/mod.rs
index 9eec5f83..fadce5f6 100644
--- a/src/snapshot_middleware/mod.rs
+++ b/src/snapshot_middleware/mod.rs
@@ -91,7 +91,9 @@ pub fn snapshot_from_vfs(
// TODO: Is this even necessary anymore?
match file_name {
"init.server.luau" | "init.server.lua" | "init.client.luau" | "init.client.lua"
- | "init.luau" | "init.lua" | "init.csv" => return Ok(None),
+ | "init.plugin.luau" | "init.plugin.lua" | "init.luau" | "init.lua" | "init.csv" => {
+ return Ok(None)
+ }
_ => {}
}
@@ -124,6 +126,8 @@ fn get_dir_middleware<'path>(
(Middleware::ServerScriptDir, "init.server.lua"),
(Middleware::ClientScriptDir, "init.client.luau"),
(Middleware::ClientScriptDir, "init.client.lua"),
+ (Middleware::PluginScriptDir, "init.plugin.lua"),
+ (Middleware::PluginScriptDir, "init.plugin.luau"),
(Middleware::CsvDir, "init.csv"),
]
});
@@ -205,6 +209,8 @@ pub enum Middleware {
#[serde(skip_deserializing)]
ClientScriptDir,
#[serde(skip_deserializing)]
+ PluginScriptDir,
+ #[serde(skip_deserializing)]
ModuleScriptDir,
#[serde(skip_deserializing)]
CsvDir,
@@ -255,6 +261,9 @@ impl Middleware {
Self::ClientScriptDir => {
snapshot_lua_init(context, vfs, path, name, ScriptType::Client)
}
+ Self::PluginScriptDir => {
+ snapshot_lua_init(context, vfs, path, name, ScriptType::Plugin)
+ }
Self::ModuleScriptDir => {
snapshot_lua_init(context, vfs, path, name, ScriptType::Module)
}
@@ -297,6 +306,7 @@ impl Middleware {
Middleware::Dir => syncback_dir(snapshot),
Middleware::ServerScriptDir => syncback_lua_init(ScriptType::Server, snapshot),
Middleware::ClientScriptDir => syncback_lua_init(ScriptType::Client, snapshot),
+ Middleware::PluginScriptDir => syncback_lua_init(ScriptType::Plugin, snapshot),
Middleware::ModuleScriptDir => syncback_lua_init(ScriptType::Module, snapshot),
Middleware::CsvDir => syncback_csv_init(snapshot),
@@ -318,6 +328,7 @@ impl Middleware {
Middleware::Dir
| Middleware::ServerScriptDir
| Middleware::ClientScriptDir
+ | Middleware::PluginScriptDir
| Middleware::ModuleScriptDir
| Middleware::CsvDir
)
diff --git a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__csv__test__csv_init.snap b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__csv__test__csv_init.snap
index a275a6cf..813a2933 100644
--- a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__csv__test__csv_init.snap
+++ b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__csv__test__csv_init.snap
@@ -15,6 +15,8 @@ metadata:
- /root/init.server.luau
- /root/init.client.lua
- /root/init.client.luau
+ - /root/init.plugin.lua
+ - /root/init.plugin.luau
- /root/init.csv
- /root/init.meta.json
- /root/init.meta.jsonc
diff --git a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__csv__test__csv_init_with_meta.snap b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__csv__test__csv_init_with_meta.snap
index 603bd2df..01ce0a0f 100644
--- a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__csv__test__csv_init_with_meta.snap
+++ b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__csv__test__csv_init_with_meta.snap
@@ -15,6 +15,8 @@ metadata:
- /root/init.server.luau
- /root/init.client.lua
- /root/init.client.luau
+ - /root/init.plugin.lua
+ - /root/init.plugin.luau
- /root/init.csv
- /root/init.meta.json
- /root/init.meta.jsonc
diff --git a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__dir__test__empty_folder.snap b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__dir__test__empty_folder.snap
index 813a6767..865c3c27 100644
--- a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__dir__test__empty_folder.snap
+++ b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__dir__test__empty_folder.snap
@@ -15,6 +15,8 @@ metadata:
- /foo/init.server.luau
- /foo/init.client.lua
- /foo/init.client.luau
+ - /foo/init.plugin.lua
+ - /foo/init.plugin.luau
- /foo/init.csv
- /foo/init.meta.json
- /foo/init.meta.jsonc
diff --git a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__dir__test__folder_in_folder.snap b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__dir__test__folder_in_folder.snap
index 2f44988d..00a4bdf0 100644
--- a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__dir__test__folder_in_folder.snap
+++ b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__dir__test__folder_in_folder.snap
@@ -15,6 +15,8 @@ metadata:
- /foo/init.server.luau
- /foo/init.client.lua
- /foo/init.client.luau
+ - /foo/init.plugin.lua
+ - /foo/init.plugin.luau
- /foo/init.csv
- /foo/init.meta.json
- /foo/init.meta.jsonc
@@ -40,6 +42,8 @@ children:
- /foo/Child/init.server.luau
- /foo/Child/init.client.lua
- /foo/Child/init.client.luau
+ - /foo/Child/init.plugin.lua
+ - /foo/Child/init.plugin.luau
- /foo/Child/init.csv
- /foo/Child/init.meta.json
- /foo/Child/init.meta.jsonc
diff --git a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__lua__test__init_module_from_vfs.snap b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__lua__test__init_module_from_vfs.snap
index 80178738..f5de8064 100644
--- a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__lua__test__init_module_from_vfs.snap
+++ b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__lua__test__init_module_from_vfs.snap
@@ -15,6 +15,8 @@ metadata:
- /root/init.server.luau
- /root/init.client.lua
- /root/init.client.luau
+ - /root/init.plugin.lua
+ - /root/init.plugin.luau
- /root/init.csv
- /root/init.meta.json
- /root/init.meta.jsonc
diff --git a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__lua__test__init_module_from_vfs_with_meta.snap b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__lua__test__init_module_from_vfs_with_meta.snap
index d5f8aa9f..60b43c20 100644
--- a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__lua__test__init_module_from_vfs_with_meta.snap
+++ b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__lua__test__init_module_from_vfs_with_meta.snap
@@ -15,6 +15,8 @@ metadata:
- /root/init.server.luau
- /root/init.client.lua
- /root/init.client.luau
+ - /root/init.plugin.lua
+ - /root/init.plugin.luau
- /root/init.csv
- /root/init.meta.json
- /root/init.meta.jsonc
diff --git a/src/syncback/file_names.rs b/src/syncback/file_names.rs
index 4653aabd..7654fed8 100644
--- a/src/syncback/file_names.rs
+++ b/src/syncback/file_names.rs
@@ -35,6 +35,7 @@ pub fn name_for_inst<'old>(
| Middleware::CsvDir
| Middleware::ServerScriptDir
| Middleware::ClientScriptDir
+ | Middleware::PluginScriptDir
| Middleware::ModuleScriptDir => Cow::Owned(new_inst.name.clone()),
_ => {
let extension = extension_for_middleware(middleware);
@@ -78,6 +79,7 @@ pub fn extension_for_middleware(middleware: Middleware) -> &'static str {
| Middleware::CsvDir
| Middleware::ServerScriptDir
| Middleware::ClientScriptDir
+ | Middleware::PluginScriptDir
| Middleware::ModuleScriptDir => {
unimplemented!("directory middleware requires special treatment")
}
diff --git a/src/syncback/mod.rs b/src/syncback/mod.rs
index 5dc3028f..7fe3992d 100644
--- a/src/syncback/mod.rs
+++ b/src/syncback/mod.rs
@@ -21,7 +21,7 @@ use std::{
};
use crate::{
- glob::Glob,
+ glob::{Glob, IgnorableGlob},
snapshot::{InstanceWithMeta, RojoTree},
snapshot_middleware::Middleware,
syncback::ref_properties::{collect_referents, link_referents},
@@ -359,6 +359,7 @@ pub fn get_best_middleware(snapshot: &SyncbackSnapshot) -> Middleware {
middleware = match middleware {
Middleware::ServerScript => Middleware::ServerScriptDir,
Middleware::ClientScript => Middleware::ClientScriptDir,
+ Middleware::PluginScript => Middleware::PluginScriptDir,
Middleware::ModuleScript => Middleware::ModuleScriptDir,
Middleware::Csv => Middleware::CsvDir,
Middleware::JsonModel | Middleware::Text => Middleware::Dir,
@@ -414,18 +415,18 @@ pub struct SyncbackRules {
}
impl SyncbackRules {
- pub fn compile_globs(&self) -> anyhow::Result> {
+ pub fn compile_globs(&self) -> anyhow::Result> {
let mut globs = Vec::with_capacity(self.ignore_paths.len());
let dir_ignore_paths = self.create_ignore_dir_paths.unwrap_or(true);
for pattern in &self.ignore_paths {
- let glob = Glob::new(pattern)
+ let glob = IgnorableGlob::new(pattern)
.with_context(|| format!("the pattern '{pattern}' is not a valid glob"))?;
globs.push(glob);
if dir_ignore_paths {
if let Some(dir_pattern) = pattern.strip_suffix("/**") {
- if let Ok(glob) = Glob::new(dir_pattern) {
+ if let Ok(glob) = IgnorableGlob::new(dir_pattern) {
globs.push(glob)
}
}
@@ -436,7 +437,7 @@ impl SyncbackRules {
}
}
-fn is_valid_path(globs: &Option>, base_path: &Path, path: &Path) -> bool {
+fn is_valid_path(globs: &Option>, base_path: &Path, path: &Path) -> bool {
let git_glob = GIT_IGNORE_GLOB.get_or_init(|| Glob::new(".git/**").unwrap());
let test_path = match path.strip_prefix(base_path) {
Ok(suffix) => suffix,
@@ -446,11 +447,16 @@ fn is_valid_path(globs: &Option>, base_path: &Path, path: &Path) -> bo
return false;
}
if let Some(ref ignore_paths) = globs {
+ // Gitignore-style "last match wins"
+ let mut ignored = false;
for glob in ignore_paths {
if glob.is_match(test_path) {
- return false;
+ ignored = !glob.is_negation();
}
}
+ if ignored {
+ return false;
+ }
}
true
}
@@ -538,3 +544,71 @@ fn strip_unknown_root_children(new: &mut WeakDom, old: &RojoTree) {
new.destroy(child_ref);
}
}
+
+#[cfg(test)]
+mod test {
+ use super::*;
+
+ fn rules(ignore_paths: &[&str], create_ignore_dir_paths: Option) -> SyncbackRules {
+ SyncbackRules {
+ ignore_trees: Vec::new(),
+ ignore_paths: ignore_paths.iter().map(|s| s.to_string()).collect(),
+ ignore_properties: IndexMap::new(),
+ sync_current_camera: None,
+ sync_unscriptable: None,
+ ignore_referents: None,
+ create_ignore_dir_paths,
+ }
+ }
+
+ #[test]
+ fn ignore_paths_negation() {
+ let globs = Some(
+ rules(&["**/*.lua", "!keep.lua"], Some(false))
+ .compile_globs()
+ .unwrap(),
+ );
+ let base = Path::new("/test");
+
+ // A later negation re-includes a path matched by an earlier pattern.
+ assert!(!is_valid_path(&globs, base, Path::new("/test/foo.lua")));
+ assert!(is_valid_path(&globs, base, Path::new("/test/keep.lua")));
+ // Paths matched by no rule are valid.
+ assert!(is_valid_path(&globs, base, Path::new("/test/plain.txt")));
+ }
+
+ #[test]
+ fn ignore_paths_negation_with_dir_expansion() {
+ // With `create_ignore_dir_paths`, a negated `foo/**` pattern should also
+ // re-include the `foo` directory itself, mirroring the file rule.
+ let globs = Some(
+ rules(&["**/*", "!keep/**"], Some(true))
+ .compile_globs()
+ .unwrap(),
+ );
+ let base = Path::new("/test");
+
+ assert!(!is_valid_path(&globs, base, Path::new("/test/drop/a.lua")));
+ assert!(is_valid_path(&globs, base, Path::new("/test/keep")));
+ assert!(is_valid_path(&globs, base, Path::new("/test/keep/a.lua")));
+ }
+
+ #[test]
+ fn ignore_paths_escaped_bang_is_literal() {
+ // `\!literal.lua` should ignore a file literally named `!literal.lua`
+ // rather than being parsed as a negation.
+ let globs = Some(
+ rules(&[r"\!literal.lua"], Some(false))
+ .compile_globs()
+ .unwrap(),
+ );
+ let base = Path::new("/test");
+
+ assert!(!globs.as_ref().unwrap()[0].is_negation());
+ assert!(!is_valid_path(
+ &globs,
+ base,
+ Path::new("/test/!literal.lua")
+ ));
+ }
+}
diff --git a/src/web/api.rs b/src/web/api.rs
index b1edc9d0..4fd21c0a 100644
--- a/src/web/api.rs
+++ b/src/web/api.rs
@@ -1,7 +1,7 @@
//! Defines Rojo's HTTP API, all under /api. These endpoints generally return
//! 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 hyper::{body, Body, Method, Request, Response, StatusCode};
@@ -21,6 +21,7 @@ use crate::{
ServerInfoResponse, SocketPacket, SocketPacketBody, SocketPacketType, SubscribeMessage,
WriteRequest, WriteResponse, PROTOCOL_VERSION, SERVER_VERSION,
},
+ origin::canonical,
util::{deserialize_msgpack, msgpack, msgpack_ok, serialize_msgpack},
},
web_api::{
@@ -28,8 +29,12 @@ use crate::{
},
};
-pub async fn call(serve_session: Arc, mut request: Request) -> Response {
- let service = ApiService::new(serve_session);
+pub async fn call(
+ serve_session: Arc,
+ remote_addr: SocketAddr,
+ mut request: Request,
+) -> Response {
+ let service = ApiService::new(serve_session, remote_addr);
match (request.method(), request.uri().path()) {
(&Method::GET, "/api/rojo") => service.handle_api_rojo().await,
@@ -65,11 +70,15 @@ pub async fn call(serve_session: Arc, mut request: Request)
pub struct ApiService {
serve_session: Arc,
+ remote_addr: SocketAddr,
}
impl ApiService {
- pub fn new(serve_session: Arc) -> Self {
- ApiService { serve_session }
+ pub fn new(serve_session: Arc, remote_addr: SocketAddr) -> Self {
+ ApiService {
+ serve_session,
+ remote_addr,
+ }
}
/// 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.
async fn handle_api_open(&self, request: Request) -> Response {
+ // 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 requested_id = match Ref::from_str(argument) {
Ok(id) => id,
diff --git a/src/web/interface.rs b/src/web/interface.rs
index 51307b67..8ec332ed 100644
--- a/src/web/interface.rs
+++ b/src/web/interface.rs
@@ -290,6 +290,13 @@ impl ErrorResponse {
}
}
+ pub fn forbidden>(details: S) -> Self {
+ Self {
+ kind: ErrorResponseKind::Forbidden,
+ details: details.into(),
+ }
+ }
+
pub fn internal_error>(details: S) -> Self {
Self {
kind: ErrorResponseKind::InternalError,
@@ -302,5 +309,6 @@ impl ErrorResponse {
pub enum ErrorResponseKind {
NotFound,
BadRequest,
+ Forbidden,
InternalError,
}
diff --git a/src/web/mod.rs b/src/web/mod.rs
index 8e33d9c5..8a003285 100644
--- a/src/web/mod.rs
+++ b/src/web/mod.rs
@@ -5,6 +5,7 @@
mod api;
mod assets;
pub mod interface;
+mod origin;
mod ui;
mod util;
@@ -14,7 +15,7 @@ use std::sync::Arc;
use anyhow::Context;
use hyper::{
- server::Server,
+ server::{conn::AddrStream, Server},
service::{make_service_fn, service_fn},
Body, Request,
};
@@ -33,22 +34,42 @@ 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,
+ on_listening: impl FnOnce(),
+ ) -> anyhow::Result<()> {
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);
+ let allowed_hosts = allowed_hosts.clone();
+ let remote_addr = conn.remote_addr();
- async {
+ async move {
let service = move |req: Request| {
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") {
- Ok::<_, Infallible>(api::call(serve_session, req).await)
+ Ok::<_, Infallible>(api::call(serve_session, remote_addr, req).await)
} else {
Ok::<_, Infallible>(ui::call(serve_session, req).await)
}
diff --git a/src/web/origin.rs b/src/web/origin.rs
new file mode 100644
index 00000000..bb36a7e7
--- /dev/null
+++ b/src/web/origin.rs
@@ -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,
+ /// 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,
+}
+
+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) -> bool {
+ let host = normalize_host(host);
+ let host_ok = host.eq_ignore_ascii_case("localhost")
+ || host
+ .parse::()
+ .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::().ok().map(canonical);
+ self.extra_hosts.iter().any(|allowed| {
+ match (host_ip, allowed.parse::().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 {
+ let extra_hosts: Vec = 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,
+ allowed: Option<&AllowedHosts>,
+) -> Option> {
+ 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)> {
+ 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)> {
+ 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 {
+ 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 {
+ allowed_hosts(IpAddr::V4(Ipv4Addr::LOCALHOST), PORT, &[])
+ }
+
+ /// An allowlist for a server bound to a specific private (LAN) address.
+ fn private_allowlist() -> Option {
+ 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 {
+ let hosts: Vec = hosts.iter().map(|host| host.to_string()).collect();
+ allowed_hosts(bind, PORT, &hosts)
+ }
+
+ fn request_with(headers: &[(&'static str, &str)]) -> Request {
+ 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::().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"
+ );
+ }
+ }
+}
diff --git a/tests/rojo_test/serve_util.rs b/tests/rojo_test/serve_util.rs
index 01781e9c..458743e0 100644
--- a/tests/rojo_test/serve_util.rs
+++ b/tests/rojo_test/serve_util.rs
@@ -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 {
@@ -239,6 +243,39 @@ impl TestServeSession {
client.post(url).body(body).send()
}
+
+ /// 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/` 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(value: T) -> Result, rmp_serde::encode::Error> {
diff --git a/tests/tests/build.rs b/tests/tests/build.rs
index 8c533115..c7873050 100644
--- a/tests/tests/build.rs
+++ b/tests/tests/build.rs
@@ -65,6 +65,7 @@ gen_build_tests! {
no_name_default_project,
no_name_project,
no_name_top_level_project,
+ plugin_init,
}
fn run_build_test(test_name: &str) {
diff --git a/tests/tests/serve.rs b/tests/tests/serve.rs
index 7ea41ccd..0d6f75cc 100644
--- a/tests/tests/serve.rs
+++ b/tests/tests/serve.rs
@@ -12,6 +12,74 @@ use crate::rojo_test::{
use librojo::web_api::{SerializeResponse, 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]
fn empty() {
run_serve_test("empty", |session, mut redactions| {