solid_pod_rs/security/
dotfile.rs1use std::path::{Component, Path};
12
13use thiserror::Error;
14
15use crate::metrics::SecurityMetrics;
16
17pub const ENV_DOTFILE_ALLOWLIST: &str = "DOTFILE_ALLOWLIST";
21
22pub const DEFAULT_ALLOWED: &[&str] = &[".acl", ".meta", ".account"];
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Error)]
28pub enum DotfileError {
29 #[error("dotfile path component is not on the allowlist")]
31 NotAllowed,
32}
33
34#[derive(Debug, Clone)]
39pub struct DotfileAllowlist {
40 allowed: Vec<String>,
41 metrics: Option<SecurityMetrics>,
42}
43
44impl DotfileAllowlist {
45 pub fn from_env() -> Self {
48 match std::env::var(ENV_DOTFILE_ALLOWLIST) {
49 Ok(raw) => {
50 let parsed = parse_csv(&raw);
51 if parsed.is_empty() {
52 Self::with_defaults()
53 } else {
54 Self {
55 allowed: parsed,
56 metrics: None,
57 }
58 }
59 }
60 Err(_) => Self::with_defaults(),
61 }
62 }
63
64 pub fn with_defaults() -> Self {
66 Self {
67 allowed: DEFAULT_ALLOWED.iter().map(|s| (*s).to_string()).collect(),
68 metrics: None,
69 }
70 }
71
72 pub fn new(entries: Vec<String>) -> Self {
75 let allowed = entries
76 .into_iter()
77 .map(|e| normalise_entry(&e))
78 .filter(|e| !e.is_empty() && e != ".")
79 .collect();
80 Self {
81 allowed,
82 metrics: None,
83 }
84 }
85
86 pub fn with_metrics(mut self, metrics: SecurityMetrics) -> Self {
88 self.metrics = Some(metrics);
89 self
90 }
91
92 pub fn entries(&self) -> &[String] {
95 &self.allowed
96 }
97
98 pub fn is_allowed(&self, path: &Path) -> bool {
107 for component in path.components() {
108 match component {
109 Component::Normal(os) => {
110 let s = match os.to_str() {
111 Some(s) => s,
112 None => {
114 self.record_deny();
115 return false;
116 }
117 };
118 if s.starts_with('.') && !self.allowed.iter().any(|a| a == s) {
119 self.record_deny();
120 return false;
121 }
122 }
123 Component::CurDir | Component::ParentDir => {
124 self.record_deny();
127 return false;
128 }
129 Component::Prefix(_) | Component::RootDir => {
130 }
132 }
133 }
134 true
135 }
136
137 fn record_deny(&self) {
138 if let Some(m) = &self.metrics {
139 m.record_dotfile_deny();
140 }
141 }
142}
143
144impl Default for DotfileAllowlist {
145 fn default() -> Self {
146 Self::with_defaults()
147 }
148}
149
150#[derive(Debug, Clone, PartialEq, Eq, Hash, Error)]
163pub enum DotfilePathError {
164 #[error("dotfile segment '{segment}' not allowed in path '{path}'")]
166 NotAllowed { segment: String, path: String },
167
168 #[error("parent-directory traversal segment '..' not allowed in path '{0}'")]
171 ParentTraversal(String),
172
173 #[error("malformed path segment in '{0}'")]
177 Malformed(String),
178}
179
180const STATIC_ALLOWED_DOTFILES: &[&str] = &[
181 ".acl",
182 ".meta",
183 ".well-known",
184 ".quota.json",
185 ".acl.meta",
189 ".account",
194];
195
196pub fn is_path_allowed(path: &str) -> Result<(), DotfilePathError> {
222 for segment in path.split('/') {
223 if segment.is_empty() || segment == "." {
224 continue;
225 }
226 if segment == ".." {
227 return Err(DotfilePathError::ParentTraversal(path.to_string()));
228 }
229 if !segment.starts_with('.') {
230 continue;
231 }
232 if STATIC_ALLOWED_DOTFILES.contains(&segment) {
233 continue;
234 }
235 return Err(DotfilePathError::NotAllowed {
236 segment: segment.to_string(),
237 path: path.to_string(),
238 });
239 }
240 Ok(())
241}
242
243fn parse_csv(raw: &str) -> Vec<String> {
246 raw.split(',')
247 .map(|s| s.trim())
248 .filter(|s| !s.is_empty())
249 .map(normalise_entry)
250 .filter(|s| !s.is_empty() && s != ".")
251 .collect()
252}
253
254fn normalise_entry(entry: &str) -> String {
255 let trimmed = entry.trim().trim_start_matches('/');
256 if trimmed.is_empty() {
257 return String::new();
258 }
259 if trimmed.starts_with('.') {
260 trimmed.to_string()
261 } else {
262 format!(".{trimmed}")
263 }
264}
265
266#[cfg(test)]
269mod tests {
270 use super::*;
271 use std::path::PathBuf;
272
273 #[test]
274 fn default_permits_acl_and_meta() {
275 let al = DotfileAllowlist::default();
276 assert!(al.is_allowed(&PathBuf::from("/resource/.acl")));
277 assert!(al.is_allowed(&PathBuf::from("/resource/.meta")));
278 }
279
280 #[test]
281 fn default_blocks_env() {
282 let al = DotfileAllowlist::default();
283 assert!(!al.is_allowed(&PathBuf::from("/.env")));
284 assert!(!al.is_allowed(&PathBuf::from("/x/y/.env")));
285 }
286
287 #[test]
288 fn explicit_allowlist_accepts_listed_entries() {
289 let al = DotfileAllowlist::new(vec![".env".into(), ".config".into()]);
290 assert!(al.is_allowed(&PathBuf::from("/.env")));
291 assert!(al.is_allowed(&PathBuf::from("/.config")));
292 assert!(!al.is_allowed(&PathBuf::from("/.secret")));
293 }
294
295 #[test]
296 fn entry_without_dot_prefix_is_normalised() {
297 let al = DotfileAllowlist::new(vec!["notifications".into()]);
298 assert!(al.is_allowed(&PathBuf::from("/.notifications")));
299 }
300
301 #[test]
302 fn nested_dotfile_rejected() {
303 let al = DotfileAllowlist::default();
304 assert!(!al.is_allowed(&PathBuf::from("foo/.secret/bar")));
305 }
306
307 #[test]
308 fn path_without_dotfiles_accepted() {
309 let al = DotfileAllowlist::default();
310 assert!(al.is_allowed(&PathBuf::from("/a/b/c/file.ttl")));
311 }
312
313 #[test]
314 fn parent_dir_rejected() {
315 let al = DotfileAllowlist::default();
316 assert!(!al.is_allowed(&PathBuf::from("foo/..")));
317 }
318
319 #[test]
322 fn allows_acl_file() {
323 assert!(is_path_allowed("/.acl").is_ok());
324 assert!(is_path_allowed("/pod/.acl").is_ok());
325 assert!(is_path_allowed("/pod/container/.acl").is_ok());
326 }
327
328 #[test]
329 fn allows_meta_file() {
330 assert!(is_path_allowed("/.meta").is_ok());
331 assert!(is_path_allowed("/pod/.meta").is_ok());
332 assert!(is_path_allowed("/pod/container/.meta").is_ok());
333 }
334
335 #[test]
336 fn allows_well_known_subtree() {
337 assert!(is_path_allowed("/.well-known").is_ok());
338 assert!(is_path_allowed("/.well-known/openid-configuration").is_ok());
339 assert!(is_path_allowed("/.well-known/solid").is_ok());
340 assert!(is_path_allowed("/pod/.well-known/nested").is_ok());
341 }
342
343 #[test]
344 fn allows_quota_sidecar() {
345 assert!(is_path_allowed("/.quota.json").is_ok());
346 assert!(is_path_allowed("/pod/.quota.json").is_ok());
347 assert!(is_path_allowed("/pod/container/.quota.json").is_ok());
348 }
349
350 #[test]
351 fn allows_resource_specific_acl() {
352 assert!(is_path_allowed("/foo.acl").is_ok());
356 assert!(is_path_allowed("/foo.meta").is_ok());
357 assert!(is_path_allowed("/pod/data.ttl.acl").is_ok());
358 assert!(is_path_allowed("/pod/image.jpg.meta").is_ok());
359 }
360
361 #[test]
362 fn allows_normal_path() {
363 assert!(is_path_allowed("/foo/bar.ttl").is_ok());
364 assert!(is_path_allowed("/").is_ok());
365 assert!(is_path_allowed("/pod/data/doc.ttl").is_ok());
366 assert!(is_path_allowed("").is_ok());
367 }
368
369 #[test]
370 fn blocks_env_file() {
371 match is_path_allowed("/.env") {
372 Err(DotfilePathError::NotAllowed { segment, .. }) => assert_eq!(segment, ".env"),
373 other => panic!("expected NotAllowed for /.env, got {other:?}"),
374 }
375 assert!(is_path_allowed("/pod/.env").is_err());
376 assert!(is_path_allowed("/deep/path/.env").is_err());
377 }
378
379 #[test]
380 fn blocks_git_dir() {
381 match is_path_allowed("/pod/.git/config") {
382 Err(DotfilePathError::NotAllowed { segment, .. }) => assert_eq!(segment, ".git"),
383 other => panic!("expected NotAllowed for /pod/.git/config, got {other:?}"),
384 }
385 assert!(is_path_allowed("/.git").is_err());
386 assert!(is_path_allowed("/.git/HEAD").is_err());
387 assert!(is_path_allowed("/.ssh/id_rsa").is_err());
388 }
389
390 #[test]
391 fn blocks_hidden_file_anywhere() {
392 assert!(is_path_allowed("/foo/.hidden/bar.ttl").is_err());
393 assert!(is_path_allowed("/a/b/c/.secret").is_err());
394 assert!(is_path_allowed("/.DS_Store").is_err());
395 assert!(is_path_allowed("/pod/.npmrc").is_err());
396 }
397
398 #[test]
399 fn blocks_double_dot() {
400 match is_path_allowed("/pod/../etc/passwd") {
401 Err(DotfilePathError::ParentTraversal(_)) => {}
402 other => panic!("expected ParentTraversal for /pod/../etc/passwd, got {other:?}"),
403 }
404 assert!(matches!(
405 is_path_allowed(".."),
406 Err(DotfilePathError::ParentTraversal(_))
407 ));
408 assert!(matches!(
409 is_path_allowed("/a/../b"),
410 Err(DotfilePathError::ParentTraversal(_))
411 ));
412 }
413
414 #[test]
417 fn default_permits_account() {
418 let al = DotfileAllowlist::default();
419 assert!(
420 al.is_allowed(&PathBuf::from("/.account")),
421 ".account must be on default allowlist"
422 );
423 assert!(
424 al.is_allowed(&PathBuf::from("/pod/.account")),
425 ".account nested under pod must pass"
426 );
427 }
428
429 #[test]
430 fn allows_account_path_free_function() {
431 assert!(
432 is_path_allowed("/.account").is_ok(),
433 ".account must pass the free-function check"
434 );
435 assert!(
436 is_path_allowed("/.account/login").is_ok(),
437 ".account subtree must pass"
438 );
439 assert!(
440 is_path_allowed("/pod/.account/register").is_ok(),
441 ".account under pod must pass"
442 );
443 }
444
445 #[test]
446 fn account_in_default_allowed_constant() {
447 assert!(
448 DEFAULT_ALLOWED.contains(&".account"),
449 "DEFAULT_ALLOWED must include .account"
450 );
451 }
452}