1use std::path::{Component, Path, PathBuf};
2
3pub struct NodeAllowedSources {
10 mounts: AllowedPaths,
11 devices: AllowedPaths,
12}
13
14impl NodeAllowedSources {
15 pub fn new<S: AsRef<str>>(
16 mounts: impl IntoIterator<Item = S>,
17 devices: impl IntoIterator<Item = S>,
18 ) -> Self {
19 Self {
20 mounts: AllowedPaths::new(mounts),
21 devices: AllowedPaths::new(devices),
22 }
23 }
24
25 pub fn from_config(config: &wings_api::Config) -> Result<Self, serde_json::Error> {
26 #[derive(Default, serde::Deserialize)]
27 #[serde(default)]
28 struct AllowedSourcesConfig {
29 allowed_mounts: Vec<compact_str::CompactString>,
30 allowed_devices: Vec<compact_str::CompactString>,
31 }
32
33 let config: AllowedSourcesConfig = config.view()?;
34
35 Ok(Self::new(&config.allowed_mounts, &config.allowed_devices))
36 }
37
38 pub fn allows_mount(&self, source: &str, target: &str) -> Option<bool> {
40 self.mounts.allows(source, target)
41 }
42
43 pub fn allows_device(&self, source: &str, target: &str, permissions: &str) -> Option<bool> {
45 if permissions.is_empty() || !permissions.bytes().all(|p| matches!(p, b'r' | b'w' | b'm')) {
46 return Some(false);
47 }
48
49 self.devices.allows(source, target)
50 }
51}
52
53fn is_plain_absolute_path(path: &Path) -> bool {
54 path.is_absolute()
55 && !path
56 .components()
57 .any(|component| matches!(component, Component::CurDir | Component::ParentDir))
58}
59
60struct AllowedPaths {
61 absolute: Vec<PathBuf>,
62 has_relative: bool,
64}
65
66impl AllowedPaths {
67 fn new<S: AsRef<str>>(entries: impl IntoIterator<Item = S>) -> Self {
68 let mut absolute = Vec::new();
69 let mut has_relative = false;
70
71 for entry in entries {
72 let entry = Path::new(entry.as_ref());
73 if entry.is_absolute() {
74 absolute.push(crate::cap::CapFilesystem::resolve_path(entry));
75 } else {
76 has_relative = true;
77 }
78 }
79
80 Self {
81 absolute,
82 has_relative,
83 }
84 }
85
86 fn allows(&self, source: &str, target: &str) -> Option<bool> {
87 let source = Path::new(source);
88
89 if (self.absolute.is_empty() && !self.has_relative)
90 || !is_plain_absolute_path(Path::new(target))
91 || !is_plain_absolute_path(source)
92 {
93 return Some(false);
94 }
95
96 if self
97 .absolute
98 .iter()
99 .any(|allowed| source.starts_with(allowed))
100 {
101 Some(true)
102 } else if self.has_relative {
103 None
104 } else {
105 Some(false)
106 }
107 }
108}
109
110#[cfg(test)]
111mod tests {
112 use super::*;
113
114 fn mounts(entries: &[&str]) -> NodeAllowedSources {
115 NodeAllowedSources::new(entries.iter().copied(), std::iter::empty())
116 }
117
118 fn devices(entries: &[&str]) -> NodeAllowedSources {
119 NodeAllowedSources::new(std::iter::empty(), entries.iter().copied())
120 }
121
122 #[test]
124 fn an_empty_allowlist_allows_nothing() {
125 let sources = mounts(&[]);
126
127 assert_eq!(sources.allows_mount("/mnt/data", "/data"), Some(false));
128 }
129
130 #[test]
131 fn mounts_match_by_path_component_prefix() {
132 let sources = mounts(&["/mnt/data/"]);
133
134 assert_eq!(sources.allows_mount("/mnt/data", "/data"), Some(true));
135 assert_eq!(
136 sources.allows_mount("/mnt/data/sub/dir", "/data"),
137 Some(true)
138 );
139 assert_eq!(sources.allows_mount("/mnt/data2", "/data"), Some(false));
140 assert_eq!(sources.allows_mount("/mnt", "/data"), Some(false));
141 }
142
143 #[test]
144 fn mount_sources_and_targets_must_be_clean_absolute_paths() {
145 let sources = mounts(&["/mnt/data"]);
146
147 assert_eq!(sources.allows_mount("mnt/data", "/data"), Some(false));
148 assert_eq!(
149 sources.allows_mount("/mnt/data/../etc", "/data"),
150 Some(false)
151 );
152 assert_eq!(sources.allows_mount("/mnt/data/./x", "/data"), Some(true));
153 assert_eq!(sources.allows_mount("/mnt/data", "data"), Some(false));
154 assert_eq!(
155 sources.allows_mount("/mnt/data", "/data/../etc"),
156 Some(false)
157 );
158 }
159
160 #[test]
161 fn absolute_entries_are_lexically_normalized() {
162 let sources = mounts(&["/srv/x/../data"]);
163
164 assert_eq!(sources.allows_mount("/srv/data/file", "/data"), Some(true));
165 assert_eq!(sources.allows_mount("/srv/x/data", "/data"), Some(false));
166 }
167
168 #[test]
169 fn relative_entries_are_unknowable_unless_an_absolute_entry_allows() {
170 let sources = mounts(&["relative/data", "/mnt/data"]);
171
172 assert_eq!(sources.allows_mount("/mnt/data/x", "/data"), Some(true));
173 assert_eq!(sources.allows_mount("/srv/other", "/data"), None);
174 }
175
176 #[test]
178 fn device_permissions_must_be_nonempty_rwm() {
179 let sources = devices(&["/dev/fuse"]);
180
181 assert_eq!(
182 sources.allows_device("/dev/fuse", "/dev/fuse", "rwm"),
183 Some(true)
184 );
185 assert_eq!(
186 sources.allows_device("/dev/fuse", "/dev/fuse", "r"),
187 Some(true)
188 );
189 assert_eq!(
190 sources.allows_device("/dev/fuse", "/dev/fuse", ""),
191 Some(false)
192 );
193 assert_eq!(
194 sources.allows_device("/dev/fuse", "/dev/fuse", "rwx"),
195 Some(false)
196 );
197 assert_eq!(
198 sources.allows_device("/dev/null", "/dev/null", "rw"),
199 Some(false)
200 );
201 }
202
203 #[test]
204 fn mount_and_device_allowlists_are_independent() {
205 let mount_only = mounts(&["/dev"]);
206 let device_only = devices(&["/mnt"]);
207
208 assert_eq!(
209 mount_only.allows_device("/dev/fuse", "/dev/fuse", "rw"),
210 Some(false)
211 );
212 assert_eq!(device_only.allows_mount("/mnt/data", "/data"), Some(false));
213 }
214
215 #[test]
217 fn from_config_reads_the_allowlists_and_ignores_unknown_fields() {
218 let config = wings_api::Config(serde_json::json!({
219 "allowed_mounts": ["/srv"],
220 "allowed_devices": ["/dev/fuse"],
221 "docker": { "network": { "name": "pterodactyl_nw" } },
222 "some_future_section": { "enabled": true, "values": [1, 2, 3] },
223 }));
224
225 let sources = NodeAllowedSources::from_config(&config).unwrap();
226
227 assert_eq!(sources.allows_mount("/srv/x", "/data"), Some(true));
228 assert_eq!(sources.allows_mount("/etc", "/data"), Some(false));
229 assert_eq!(
230 sources.allows_device("/dev/fuse", "/dev/fuse", "rw"),
231 Some(true)
232 );
233 }
234
235 #[test]
236 fn from_config_without_allowlists_allows_nothing() {
237 let config = wings_api::Config(serde_json::json!({ "token": "secret" }));
238
239 let sources = NodeAllowedSources::from_config(&config).unwrap();
240
241 assert_eq!(sources.allows_mount("/srv/x", "/data"), Some(false));
242 assert_eq!(
243 sources.allows_device("/dev/fuse", "/dev/fuse", "rw"),
244 Some(false)
245 );
246 }
247}