Skip to main content

shared/models/node/
allowed_sources.rs

1use std::path::{Component, Path, PathBuf};
2
3/// The `allowed_mounts` / `allowed_devices` lists of a node's wings config, used to predict
4/// whether wings will bind a mount or device or silently skip it.
5///
6/// Mirrors wings' `resolve_allowed_path` lexically: wings canonicalizes the configured entries
7/// and the source on the node, so symlinks (and, for devices, the char/block file type check)
8/// cannot be accounted for here.
9pub 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    /// `None` when the answer depends on a relative entry the panel cannot resolve.
39    pub fn allows_mount(&self, source: &str, target: &str) -> Option<bool> {
40        self.mounts.allows(source, target)
41    }
42
43    /// `None` when the answer depends on a relative entry the panel cannot resolve.
44    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    /// Relative entries resolve against the wings working directory, which the panel cannot know.
63    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    // NodeAllowedSources::allows_mount
123    #[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    // NodeAllowedSources::allows_device
177    #[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    // NodeAllowedSources::from_config
216    #[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}