Merge branch 'master' into ak/harden-api-body-handling

This commit is contained in:
AK-Khan02 2026-06-10 22:43:44 -04:00 • committed by GitHub
commit 2e0cc81bd4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
32 changed files with 1108 additions and 34 deletions

View file

@ -39,11 +39,15 @@ 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]) * 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]) * 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]) * 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]) * 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]) * 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 now returns a structured error response instead of panicking when `serve` API request bodies fail to read. ([#1276]) * Rojo now returns a structured error response instead of panicking when `serve` API request bodies fail to read. ([#1276])
* 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])
* Fixed syncback not removing stale `$properties` entries when Studio resets a property to its engine default. ([#1244])
[#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
@ -53,11 +57,15 @@ Making a new release? Simply add the new header with the version and date undern
[#1211]: https://github.com/rojo-rbx/rojo/pull/1211 [#1211]: https://github.com/rojo-rbx/rojo/pull/1211
[#1215]: https://github.com/rojo-rbx/rojo/pull/1215 [#1215]: https://github.com/rojo-rbx/rojo/pull/1215
[#1217]: https://github.com/rojo-rbx/rojo/pull/1217 [#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 [#1256]: https://github.com/rojo-rbx/rojo/pull/1256
[#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
[#1276]: https://github.com/rojo-rbx/rojo/pull/1276 [#1276]: https://github.com/rojo-rbx/rojo/pull/1276
[#1272]: https://github.com/rojo-rbx/rojo/pull/1272
[#1270]: https://github.com/rojo-rbx/rojo/pull/1270
[#1244]: https://github.com/rojo-rbx/rojo/pull/1244
## [7.7.0-rc.1] (November 27th, 2025) ## [7.7.0-rc.1] (November 27th, 2025)

View file

@ -0,0 +1,27 @@
---
source: tests/tests/build.rs
expression: contents
---
<roblox version="4">
<Item class="Folder" referent="0">
<Properties>
<string name="Name">plugin_init</string>
</Properties>
<Item class="Script" referent="1">
<Properties>
<string name="Name">lua</string>
<token name="RunContext">3</token>
<string name="Source"><![CDATA[return "From folder/lua/init.plugin.lua"
]]></string>
</Properties>
</Item>
<Item class="Script" referent="2">
<Properties>
<string name="Name">luau</string>
<token name="RunContext">3</token>
<string name="Source"><![CDATA[return "From folder/luau/init.plugin.luau"
]]></string>
</Properties>
</Item>
</Item>
</roblox>

View file

@ -0,0 +1,6 @@
{
"name": "plugin_init",
"tree": {
"$path": "folder"
}
}

View file

@ -0,0 +1 @@
return "From folder/lua/init.plugin.lua"

View file

@ -0,0 +1 @@
return "From folder/luau/init.plugin.luau"

View file

@ -0,0 +1,5 @@
---
source: tests/rojo_test/syncback_util.rs
expression: "String::from_utf8_lossy(&output.stdout)"
---
Writing default.project.json

View file

@ -0,0 +1,59 @@
---
source: tests/tests/syncback.rs
expression: default.project.json
---
{
"name": "SyncbackTest",
"tree": {
"$className": "DataModel",
"Workspace": {
"$className": "Workspace",
"TestPart": {
"$className": "Part",
"$properties": {
"Anchored": true,
"Color": {
"Color3uint8": [
0,
0,
255
]
}
}
},
"$properties": {
"EnableSLIMAvatars": {
"Enum": 0
},
"ImprovedAnimationConstraint": {
"Enum": 0
},
"ImprovedPhysicsReplication": {
"Enum": 0
},
"LayeredClothingCacheOptimizations": {
"Enum": 0
},
"MeshStreamingAndImprovedLods": {
"Enum": 0
},
"NextGenerationReplication": {
"Enum": 0
},
"PlayerScriptsUseInputActionSystem": {
"Enum": 0
},
"UseFixedSimulation": {
"Enum": 0
},
"UseNewLuauTypeSolver": "Disabled",
"ValidateEnabledProximityPrompt": {
"Enum": 0
}
},
"$attributes": {
"Rojo_Target_CurrentCamera": "302d573157260ee80a3baa32000003b5"
}
}
}
}

View file

@ -0,0 +1,21 @@
{
"name": "SyncbackTest",
"tree": {
"$className": "DataModel",
"Workspace": {
"$className": "Workspace",
"TestPart": {
"$className": "Part",
"$properties": {
"Transparency": 1.0,
"Anchored": true,
"Color": [
1.0,
0.0,
0.0
]
}
}
}
}
}

View file

@ -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 ")?;

View file

@ -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#"{

View file

@ -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()
} }

View file

@ -72,6 +72,8 @@ pub fn snapshot_dir_no_meta(
normalized_path.join("init.server.luau"), normalized_path.join("init.server.luau"),
normalized_path.join("init.client.lua"), normalized_path.join("init.client.lua"),
normalized_path.join("init.client.luau"), normalized_path.join("init.client.luau"),
normalized_path.join("init.plugin.lua"),
normalized_path.join("init.plugin.luau"),
normalized_path.join("init.csv"), normalized_path.join("init.csv"),
]; ];

View file

@ -182,6 +182,7 @@ pub fn syncback_lua_init<'sync>(
ScriptType::Server => "init.server.luau", ScriptType::Server => "init.server.luau",
ScriptType::Client => "init.client.luau", ScriptType::Client => "init.client.luau",
ScriptType::Module => "init.luau", ScriptType::Module => "init.luau",
ScriptType::Plugin => "init.plugin.luau",
_ => anyhow::bail!("syncback is not yet implemented for {script_type:?}"), _ => anyhow::bail!("syncback is not yet implemented for {script_type:?}"),
}); });

View file

@ -91,7 +91,9 @@ pub fn snapshot_from_vfs(
// TODO: Is this even necessary anymore? // TODO: Is this even necessary anymore?
match file_name { match file_name {
"init.server.luau" | "init.server.lua" | "init.client.luau" | "init.client.lua" "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::ServerScriptDir, "init.server.lua"),
(Middleware::ClientScriptDir, "init.client.luau"), (Middleware::ClientScriptDir, "init.client.luau"),
(Middleware::ClientScriptDir, "init.client.lua"), (Middleware::ClientScriptDir, "init.client.lua"),
(Middleware::PluginScriptDir, "init.plugin.lua"),
(Middleware::PluginScriptDir, "init.plugin.luau"),
(Middleware::CsvDir, "init.csv"), (Middleware::CsvDir, "init.csv"),
] ]
}); });
@ -205,6 +209,8 @@ pub enum Middleware {
#[serde(skip_deserializing)] #[serde(skip_deserializing)]
ClientScriptDir, ClientScriptDir,
#[serde(skip_deserializing)] #[serde(skip_deserializing)]
PluginScriptDir,
#[serde(skip_deserializing)]
ModuleScriptDir, ModuleScriptDir,
#[serde(skip_deserializing)] #[serde(skip_deserializing)]
CsvDir, CsvDir,
@ -255,6 +261,9 @@ impl Middleware {
Self::ClientScriptDir => { Self::ClientScriptDir => {
snapshot_lua_init(context, vfs, path, name, ScriptType::Client) snapshot_lua_init(context, vfs, path, name, ScriptType::Client)
} }
Self::PluginScriptDir => {
snapshot_lua_init(context, vfs, path, name, ScriptType::Plugin)
}
Self::ModuleScriptDir => { Self::ModuleScriptDir => {
snapshot_lua_init(context, vfs, path, name, ScriptType::Module) snapshot_lua_init(context, vfs, path, name, ScriptType::Module)
} }
@ -297,6 +306,7 @@ impl Middleware {
Middleware::Dir => syncback_dir(snapshot), Middleware::Dir => syncback_dir(snapshot),
Middleware::ServerScriptDir => syncback_lua_init(ScriptType::Server, snapshot), Middleware::ServerScriptDir => syncback_lua_init(ScriptType::Server, snapshot),
Middleware::ClientScriptDir => syncback_lua_init(ScriptType::Client, 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::ModuleScriptDir => syncback_lua_init(ScriptType::Module, snapshot),
Middleware::CsvDir => syncback_csv_init(snapshot), Middleware::CsvDir => syncback_csv_init(snapshot),
@ -318,6 +328,7 @@ impl Middleware {
Middleware::Dir Middleware::Dir
| Middleware::ServerScriptDir | Middleware::ServerScriptDir
| Middleware::ClientScriptDir | Middleware::ClientScriptDir
| Middleware::PluginScriptDir
| Middleware::ModuleScriptDir | Middleware::ModuleScriptDir
| Middleware::CsvDir | Middleware::CsvDir
) )

View file

@ -344,6 +344,11 @@ pub fn syncback_project<'sync>(
let mut new_child_map = HashMap::new(); let mut new_child_map = HashMap::new();
let mut node_changed_map = Vec::new(); let mut node_changed_map = Vec::new();
// Tracks whether any stale default-valued properties were removed from
// project nodes. If so, we must reserialize even if
// project_node_should_reserialize wouldn't otherwise detect a change
// (it only compares node properties forward, not in reverse).
let mut removed_stale_properties = false;
let mut node_queue = VecDeque::with_capacity(1); let mut node_queue = VecDeque::with_capacity(1);
node_queue.push_back((&mut project.tree, old_inst, snapshot.new_inst())); node_queue.push_back((&mut project.tree, old_inst, snapshot.new_inst()));
@ -402,10 +407,12 @@ pub fn syncback_project<'sync>(
// We only want to set properties if it needs it. // We only want to set properties if it needs it.
if !middleware.handles_own_properties() { if !middleware.handles_own_properties() {
project_node_property_syncback_path(snapshot, new_inst, node); removed_stale_properties |=
project_node_property_syncback_path(snapshot, new_inst, node);
} }
} else { } else {
project_node_property_syncback_no_path(snapshot, new_inst, node); removed_stale_properties |=
project_node_property_syncback_no_path(snapshot, new_inst, node);
} }
for child_ref in new_inst.children() { for child_ref in new_inst.children() {
@ -507,12 +514,18 @@ pub fn syncback_project<'sync>(
} }
let mut fs_snapshot = FsSnapshot::new(); let mut fs_snapshot = FsSnapshot::new();
for (node_properties, node_attributes, old_inst) in node_changed_map { let mut needs_reserialize = removed_stale_properties;
if project_node_should_reserialize(node_properties, node_attributes, old_inst)? { if !needs_reserialize {
fs_snapshot.add_file(project_path, serde_json::to_vec_pretty(&project)?); for (node_properties, node_attributes, old_inst) in node_changed_map {
break; if project_node_should_reserialize(node_properties, node_attributes, old_inst)? {
needs_reserialize = true;
break;
}
} }
} }
if needs_reserialize {
fs_snapshot.add_file(project_path, serde_json::to_vec_pretty(&project)?);
}
Ok(SyncbackReturn { Ok(SyncbackReturn {
fs_snapshot, fs_snapshot,
@ -521,15 +534,18 @@ pub fn syncback_project<'sync>(
}) })
} }
/// Syncs properties from the new instance into the project node.
/// Returns `true` if any stale properties were removed (i.e. properties
/// that existed in the project node but are now at their engine default).
fn project_node_property_syncback( fn project_node_property_syncback(
_snapshot: &SyncbackSnapshot, _snapshot: &SyncbackSnapshot,
filtered_properties: UstrMap<&Variant>, filtered_properties: UstrMap<&Variant>,
new_inst: &Instance, new_inst: &Instance,
node: &mut ProjectNode, node: &mut ProjectNode,
) { ) -> bool {
let properties = &mut node.properties; let properties = &mut node.properties;
let mut attributes = BTreeMap::new(); let mut attributes = BTreeMap::new();
for (name, value) in filtered_properties { for (&name, &value) in &filtered_properties {
match value { match value {
Variant::Attributes(attrs) => { Variant::Attributes(attrs) => {
for (attr_name, attr_value) in attrs.iter() { for (attr_name, attr_value) in attrs.iter() {
@ -552,14 +568,48 @@ fn project_node_property_syncback(
} }
} }
} }
// Remove stale properties: entries that exist in the project node's
// $properties but are no longer in the filtered (non-default) properties
// from the instance. This handles the case where Studio resets a property
// to its engine default — filter_properties won't include it, so we need
// to clean up the now-stale project entry.
let class_data = rbx_reflection_database::get()
.ok()
.and_then(|db| db.classes.get(new_inst.class.as_str()));
let len_before = properties.len();
properties.retain(|prop_name, _| {
if filtered_properties.contains_key(prop_name) {
return true;
}
// Only remove if the property has a known default value in the
// reflection database. If there's no default, the property might be
// absent from the instance for other reasons (e.g. unknown property),
// so we conservatively keep it.
if let Some(data) = &class_data {
if data.default_properties.contains_key(prop_name.as_str()) {
log::debug!(
"Removing stale property '{}' from project node for class '{}': \
value has been reset to engine default",
prop_name,
new_inst.class
);
return false;
}
}
true
});
let removed_stale = properties.len() < len_before;
node.attributes = attributes; node.attributes = attributes;
removed_stale
} }
fn project_node_property_syncback_path( fn project_node_property_syncback_path(
snapshot: &SyncbackSnapshot, snapshot: &SyncbackSnapshot,
new_inst: &Instance, new_inst: &Instance,
node: &mut ProjectNode, node: &mut ProjectNode,
) { ) -> bool {
let filtered_properties = snapshot let filtered_properties = snapshot
.get_path_filtered_properties(new_inst.referent()) .get_path_filtered_properties(new_inst.referent())
.unwrap(); .unwrap();
@ -570,7 +620,7 @@ fn project_node_property_syncback_no_path(
snapshot: &SyncbackSnapshot, snapshot: &SyncbackSnapshot,
new_inst: &Instance, new_inst: &Instance,
node: &mut ProjectNode, node: &mut ProjectNode,
) { ) -> bool {
let filtered_properties = filter_properties(snapshot.project(), new_inst); let filtered_properties = filter_properties(snapshot.project(), new_inst);
project_node_property_syncback(snapshot, filtered_properties, new_inst, node) project_node_property_syncback(snapshot, filtered_properties, new_inst, node)
} }

View file

@ -15,6 +15,8 @@ metadata:
- /root/init.server.luau - /root/init.server.luau
- /root/init.client.lua - /root/init.client.lua
- /root/init.client.luau - /root/init.client.luau
- /root/init.plugin.lua
- /root/init.plugin.luau
- /root/init.csv - /root/init.csv
- /root/init.meta.json - /root/init.meta.json
- /root/init.meta.jsonc - /root/init.meta.jsonc

View file

@ -15,6 +15,8 @@ metadata:
- /root/init.server.luau - /root/init.server.luau
- /root/init.client.lua - /root/init.client.lua
- /root/init.client.luau - /root/init.client.luau
- /root/init.plugin.lua
- /root/init.plugin.luau
- /root/init.csv - /root/init.csv
- /root/init.meta.json - /root/init.meta.json
- /root/init.meta.jsonc - /root/init.meta.jsonc

View file

@ -15,6 +15,8 @@ metadata:
- /foo/init.server.luau - /foo/init.server.luau
- /foo/init.client.lua - /foo/init.client.lua
- /foo/init.client.luau - /foo/init.client.luau
- /foo/init.plugin.lua
- /foo/init.plugin.luau
- /foo/init.csv - /foo/init.csv
- /foo/init.meta.json - /foo/init.meta.json
- /foo/init.meta.jsonc - /foo/init.meta.jsonc

View file

@ -15,6 +15,8 @@ metadata:
- /foo/init.server.luau - /foo/init.server.luau
- /foo/init.client.lua - /foo/init.client.lua
- /foo/init.client.luau - /foo/init.client.luau
- /foo/init.plugin.lua
- /foo/init.plugin.luau
- /foo/init.csv - /foo/init.csv
- /foo/init.meta.json - /foo/init.meta.json
- /foo/init.meta.jsonc - /foo/init.meta.jsonc
@ -40,6 +42,8 @@ children:
- /foo/Child/init.server.luau - /foo/Child/init.server.luau
- /foo/Child/init.client.lua - /foo/Child/init.client.lua
- /foo/Child/init.client.luau - /foo/Child/init.client.luau
- /foo/Child/init.plugin.lua
- /foo/Child/init.plugin.luau
- /foo/Child/init.csv - /foo/Child/init.csv
- /foo/Child/init.meta.json - /foo/Child/init.meta.json
- /foo/Child/init.meta.jsonc - /foo/Child/init.meta.jsonc

View file

@ -15,6 +15,8 @@ metadata:
- /root/init.server.luau - /root/init.server.luau
- /root/init.client.lua - /root/init.client.lua
- /root/init.client.luau - /root/init.client.luau
- /root/init.plugin.lua
- /root/init.plugin.luau
- /root/init.csv - /root/init.csv
- /root/init.meta.json - /root/init.meta.json
- /root/init.meta.jsonc - /root/init.meta.jsonc

View file

@ -15,6 +15,8 @@ metadata:
- /root/init.server.luau - /root/init.server.luau
- /root/init.client.lua - /root/init.client.lua
- /root/init.client.luau - /root/init.client.luau
- /root/init.plugin.lua
- /root/init.plugin.luau
- /root/init.csv - /root/init.csv
- /root/init.meta.json - /root/init.meta.json
- /root/init.meta.jsonc - /root/init.meta.jsonc

View file

@ -35,6 +35,7 @@ pub fn name_for_inst<'old>(
| Middleware::CsvDir | Middleware::CsvDir
| Middleware::ServerScriptDir | Middleware::ServerScriptDir
| Middleware::ClientScriptDir | Middleware::ClientScriptDir
| Middleware::PluginScriptDir
| Middleware::ModuleScriptDir => Cow::Owned(new_inst.name.clone()), | Middleware::ModuleScriptDir => Cow::Owned(new_inst.name.clone()),
_ => { _ => {
let extension = extension_for_middleware(middleware); let extension = extension_for_middleware(middleware);
@ -78,6 +79,7 @@ pub fn extension_for_middleware(middleware: Middleware) -> &'static str {
| Middleware::CsvDir | Middleware::CsvDir
| Middleware::ServerScriptDir | Middleware::ServerScriptDir
| Middleware::ClientScriptDir | Middleware::ClientScriptDir
| Middleware::PluginScriptDir
| Middleware::ModuleScriptDir => { | Middleware::ModuleScriptDir => {
unimplemented!("directory middleware requires special treatment") unimplemented!("directory middleware requires special treatment")
} }

View file

@ -359,6 +359,7 @@ pub fn get_best_middleware(snapshot: &SyncbackSnapshot) -> Middleware {
middleware = match middleware { middleware = match middleware {
Middleware::ServerScript => Middleware::ServerScriptDir, Middleware::ServerScript => Middleware::ServerScriptDir,
Middleware::ClientScript => Middleware::ClientScriptDir, Middleware::ClientScript => Middleware::ClientScriptDir,
Middleware::PluginScript => Middleware::PluginScriptDir,
Middleware::ModuleScript => Middleware::ModuleScriptDir, Middleware::ModuleScript => Middleware::ModuleScriptDir,
Middleware::Csv => Middleware::CsvDir, Middleware::Csv => Middleware::CsvDir,
Middleware::JsonModel | Middleware::Text => Middleware::Dir, Middleware::JsonModel | Middleware::Text => Middleware::Dir,

View file

@ -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::{ use hyper::{
@ -24,6 +24,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::{
@ -31,8 +32,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,
@ -68,11 +73,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
@ -276,7 +285,7 @@ impl ApiService {
response_dom.transfer_within(child_ref, object_value); response_dom.transfer_within(child_ref, object_value);
} else { } else {
msgpack( return msgpack(
ErrorResponse::bad_request(format!("provided id {id} is not in the tree")), ErrorResponse::bad_request(format!("provided id {id} is not in the tree")),
StatusCode::BAD_REQUEST, StatusCode::BAD_REQUEST,
); );
@ -360,6 +369,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,

View file

@ -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,
} }

View file

@ -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); 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<Body>| { 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();
async move { 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
View 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"
);
}
}
}

View file

@ -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 {
@ -224,11 +228,11 @@ impl TestServeSession {
} }
} }
pub fn get_api_serialize( pub fn post_api_serialize(
&self, &self,
ids: &[Ref], ids: &[Ref],
session_id: SessionId, session_id: SessionId,
) -> Result<SerializeResponse, reqwest::Error> { ) -> Result<reqwest::blocking::Response, reqwest::Error> {
let client = reqwest::blocking::Client::new(); let client = reqwest::blocking::Client::new();
let url = format!("http://localhost:{}/api/serialize", self.port); let url = format!("http://localhost:{}/api/serialize", self.port);
let body = serialize_msgpack(&SerializeRequest { let body = serialize_msgpack(&SerializeRequest {
@ -237,9 +241,40 @@ impl TestServeSession {
}) })
.unwrap(); .unwrap();
let body = client.post(url).body(body).send()?.bytes()?; client.post(url).body(body).send()
}
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()
} }
} }
@ -254,7 +289,7 @@ fn serialize_msgpack<T: Serialize>(value: T) -> Result<Vec<u8>, rmp_serde::encod
Ok(serialized) Ok(serialized)
} }
fn deserialize_msgpack<'a, T: Deserialize<'a>>( pub fn deserialize_msgpack<'a, T: Deserialize<'a>>(
input: &'a [u8], input: &'a [u8],
) -> Result<T, rmp_serde::decode::Error> { ) -> Result<T, rmp_serde::decode::Error> {
let mut deserializer = rmp_serde::Deserializer::new(input).with_human_readable(); let mut deserializer = rmp_serde::Deserializer::new(input).with_human_readable();

View file

@ -65,6 +65,7 @@ gen_build_tests! {
no_name_default_project, no_name_default_project,
no_name_project, no_name_project,
no_name_top_level_project, no_name_top_level_project,
plugin_init,
} }
fn run_build_test(test_name: &str) { fn run_build_test(test_name: &str) {

View file

@ -1,14 +1,84 @@
use std::fs; use std::fs;
use insta::{assert_snapshot, assert_yaml_snapshot, with_settings}; use insta::{assert_snapshot, assert_yaml_snapshot, with_settings};
use rbx_dom_weak::types::Ref;
use reqwest::StatusCode;
use tempfile::tempdir; use tempfile::tempdir;
use crate::rojo_test::{ use crate::rojo_test::{
internable::InternAndRedact, internable::InternAndRedact,
serve_util::{run_serve_test, serialize_to_xml_model}, serve_util::{deserialize_msgpack, run_serve_test, serialize_to_xml_model},
}; };
use librojo::web_api::SocketPacketType; 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] #[test]
fn empty() { fn empty() {
@ -645,9 +715,13 @@ fn meshpart_with_id() {
.find(|(_, inst)| inst.class_name == "ObjectValue") .find(|(_, inst)| inst.class_name == "ObjectValue")
.unwrap(); .unwrap();
let serialize_response = session let body = session
.get_api_serialize(&[*meshpart, *objectvalue], info.session_id) .post_api_serialize(&[*meshpart, *objectvalue], info.session_id)
.unwrap()
.bytes()
.unwrap(); .unwrap();
let serialize_response: SerializeResponse =
deserialize_msgpack(&body).expect("Server returned malformed response");
// We don't assert a snapshot on the SerializeResponse because the model includes the // We don't assert a snapshot on the SerializeResponse because the model includes the
// Refs from the DOM as names, which means it will obviously be different every time // Refs from the DOM as names, which means it will obviously be different every time
@ -659,6 +733,20 @@ fn meshpart_with_id() {
}); });
} }
#[test]
fn serialize_missing_id() {
run_serve_test("empty", |session, _| {
let info = session.get_api_rojo().unwrap();
let missing_id = Ref::new();
let response = session
.post_api_serialize(&[missing_id], info.session_id)
.unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
});
}
#[test] #[test]
fn forced_parent() { fn forced_parent() {
run_serve_test("forced_parent", |session, mut redactions| { run_serve_test("forced_parent", |session, mut redactions| {
@ -673,9 +761,13 @@ fn forced_parent() {
read_response.intern_and_redact(&mut redactions, root_id) read_response.intern_and_redact(&mut redactions, root_id)
); );
let serialize_response = session let body = session
.get_api_serialize(&[root_id], info.session_id) .post_api_serialize(&[root_id], info.session_id)
.unwrap()
.bytes()
.unwrap(); .unwrap();
let serialize_response: SerializeResponse =
deserialize_msgpack(&body).expect("Server returned malformed response");
assert_eq!(serialize_response.session_id, info.session_id); assert_eq!(serialize_response.session_id, info.session_id);

View file

@ -86,4 +86,7 @@ syncback_tests! {
sync_rules => ["src/module.modulescript", "src/text.text"], sync_rules => ["src/module.modulescript", "src/text.text"],
// Ensures that the `syncUnscriptable` setting works // Ensures that the `syncUnscriptable` setting works
unscriptable_properties => ["default.project.json"], unscriptable_properties => ["default.project.json"],
// Ensures that syncback correctly removes default values from projects rather
// than leaving an incorrect value.
project_default_properties_remove => ["default.project.json"],
} }