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 if s.ends_with(".meta.json") {
131 self.record_deny();
132 return false;
133 }
134 }
135 Component::CurDir | Component::ParentDir => {
136 self.record_deny();
139 return false;
140 }
141 Component::Prefix(_) | Component::RootDir => {
142 }
144 }
145 }
146 true
147 }
148
149 fn record_deny(&self) {
150 if let Some(m) = &self.metrics {
151 m.record_dotfile_deny();
152 }
153 }
154}
155
156impl Default for DotfileAllowlist {
157 fn default() -> Self {
158 Self::with_defaults()
159 }
160}
161
162#[derive(Debug, Clone, PartialEq, Eq, Hash, Error)]
175pub enum DotfilePathError {
176 #[error("dotfile segment '{segment}' not allowed in path '{path}'")]
178 NotAllowed { segment: String, path: String },
179
180 #[error("parent-directory traversal segment '..' not allowed in path '{0}'")]
183 ParentTraversal(String),
184
185 #[error("malformed path segment in '{0}'")]
189 Malformed(String),
190}
191
192const STATIC_ALLOWED_DOTFILES: &[&str] = &[
193 ".acl",
194 ".meta",
195 ".well-known",
196 ".quota.json",
197 ".acl.meta",
201 ".account",
206];
207
208pub fn is_path_allowed(path: &str) -> Result<(), DotfilePathError> {
234 for segment in path.split('/') {
235 if segment.is_empty() || segment == "." {
236 continue;
237 }
238 if segment == ".." {
239 return Err(DotfilePathError::ParentTraversal(path.to_string()));
240 }
241 if segment.ends_with(".meta.json") {
246 return Err(DotfilePathError::NotAllowed {
247 segment: segment.to_string(),
248 path: path.to_string(),
249 });
250 }
251 if !segment.starts_with('.') {
252 continue;
253 }
254 if STATIC_ALLOWED_DOTFILES.contains(&segment) {
255 continue;
256 }
257 return Err(DotfilePathError::NotAllowed {
258 segment: segment.to_string(),
259 path: path.to_string(),
260 });
261 }
262 Ok(())
263}
264
265fn parse_csv(raw: &str) -> Vec<String> {
268 raw.split(',')
269 .map(|s| s.trim())
270 .filter(|s| !s.is_empty())
271 .map(normalise_entry)
272 .filter(|s| !s.is_empty() && s != ".")
273 .collect()
274}
275
276fn normalise_entry(entry: &str) -> String {
277 let trimmed = entry.trim().trim_start_matches('/');
278 if trimmed.is_empty() {
279 return String::new();
280 }
281 if trimmed.starts_with('.') {
282 trimmed.to_string()
283 } else {
284 format!(".{trimmed}")
285 }
286}
287
288#[cfg(test)]
291mod tests {
292 use super::*;
293 use std::path::PathBuf;
294
295 #[test]
296 fn default_permits_acl_and_meta() {
297 let al = DotfileAllowlist::default();
298 assert!(al.is_allowed(&PathBuf::from("/resource/.acl")));
299 assert!(al.is_allowed(&PathBuf::from("/resource/.meta")));
300 }
301
302 #[test]
303 fn default_blocks_env() {
304 let al = DotfileAllowlist::default();
305 assert!(!al.is_allowed(&PathBuf::from("/.env")));
306 assert!(!al.is_allowed(&PathBuf::from("/x/y/.env")));
307 }
308
309 #[test]
310 fn explicit_allowlist_accepts_listed_entries() {
311 let al = DotfileAllowlist::new(vec![".env".into(), ".config".into()]);
312 assert!(al.is_allowed(&PathBuf::from("/.env")));
313 assert!(al.is_allowed(&PathBuf::from("/.config")));
314 assert!(!al.is_allowed(&PathBuf::from("/.secret")));
315 }
316
317 #[test]
318 fn entry_without_dot_prefix_is_normalised() {
319 let al = DotfileAllowlist::new(vec!["notifications".into()]);
320 assert!(al.is_allowed(&PathBuf::from("/.notifications")));
321 }
322
323 #[test]
324 fn nested_dotfile_rejected() {
325 let al = DotfileAllowlist::default();
326 assert!(!al.is_allowed(&PathBuf::from("foo/.secret/bar")));
327 }
328
329 #[test]
330 fn path_without_dotfiles_accepted() {
331 let al = DotfileAllowlist::default();
332 assert!(al.is_allowed(&PathBuf::from("/a/b/c/file.ttl")));
333 }
334
335 #[test]
336 fn parent_dir_rejected() {
337 let al = DotfileAllowlist::default();
338 assert!(!al.is_allowed(&PathBuf::from("foo/..")));
339 }
340
341 #[test]
344 fn allows_acl_file() {
345 assert!(is_path_allowed("/.acl").is_ok());
346 assert!(is_path_allowed("/pod/.acl").is_ok());
347 assert!(is_path_allowed("/pod/container/.acl").is_ok());
348 }
349
350 #[test]
351 fn allows_meta_file() {
352 assert!(is_path_allowed("/.meta").is_ok());
353 assert!(is_path_allowed("/pod/.meta").is_ok());
354 assert!(is_path_allowed("/pod/container/.meta").is_ok());
355 }
356
357 #[test]
358 fn allows_well_known_subtree() {
359 assert!(is_path_allowed("/.well-known").is_ok());
360 assert!(is_path_allowed("/.well-known/openid-configuration").is_ok());
361 assert!(is_path_allowed("/.well-known/solid").is_ok());
362 assert!(is_path_allowed("/pod/.well-known/nested").is_ok());
363 }
364
365 #[test]
366 fn allows_quota_sidecar() {
367 assert!(is_path_allowed("/.quota.json").is_ok());
368 assert!(is_path_allowed("/pod/.quota.json").is_ok());
369 assert!(is_path_allowed("/pod/container/.quota.json").is_ok());
370 }
371
372 #[test]
373 fn allows_resource_specific_acl() {
374 assert!(is_path_allowed("/foo.acl").is_ok());
378 assert!(is_path_allowed("/foo.meta").is_ok());
379 assert!(is_path_allowed("/pod/data.ttl.acl").is_ok());
380 assert!(is_path_allowed("/pod/image.jpg.meta").is_ok());
381 }
382
383 #[test]
384 fn allows_normal_path() {
385 assert!(is_path_allowed("/foo/bar.ttl").is_ok());
386 assert!(is_path_allowed("/").is_ok());
387 assert!(is_path_allowed("/pod/data/doc.ttl").is_ok());
388 assert!(is_path_allowed("").is_ok());
389 }
390
391 #[test]
392 fn blocks_env_file() {
393 match is_path_allowed("/.env") {
394 Err(DotfilePathError::NotAllowed { segment, .. }) => assert_eq!(segment, ".env"),
395 other => panic!("expected NotAllowed for /.env, got {other:?}"),
396 }
397 assert!(is_path_allowed("/pod/.env").is_err());
398 assert!(is_path_allowed("/deep/path/.env").is_err());
399 }
400
401 #[test]
402 fn blocks_git_dir() {
403 match is_path_allowed("/pod/.git/config") {
404 Err(DotfilePathError::NotAllowed { segment, .. }) => assert_eq!(segment, ".git"),
405 other => panic!("expected NotAllowed for /pod/.git/config, got {other:?}"),
406 }
407 assert!(is_path_allowed("/.git").is_err());
408 assert!(is_path_allowed("/.git/HEAD").is_err());
409 assert!(is_path_allowed("/.ssh/id_rsa").is_err());
410 }
411
412 #[test]
413 fn blocks_hidden_file_anywhere() {
414 assert!(is_path_allowed("/foo/.hidden/bar.ttl").is_err());
415 assert!(is_path_allowed("/a/b/c/.secret").is_err());
416 assert!(is_path_allowed("/.DS_Store").is_err());
417 assert!(is_path_allowed("/pod/.npmrc").is_err());
418 }
419
420 #[test]
421 fn blocks_double_dot() {
422 match is_path_allowed("/pod/../etc/passwd") {
423 Err(DotfilePathError::ParentTraversal(_)) => {}
424 other => panic!("expected ParentTraversal for /pod/../etc/passwd, got {other:?}"),
425 }
426 assert!(matches!(
427 is_path_allowed(".."),
428 Err(DotfilePathError::ParentTraversal(_))
429 ));
430 assert!(matches!(
431 is_path_allowed("/a/../b"),
432 Err(DotfilePathError::ParentTraversal(_))
433 ));
434 }
435
436 #[test]
439 fn default_permits_account() {
440 let al = DotfileAllowlist::default();
441 assert!(
442 al.is_allowed(&PathBuf::from("/.account")),
443 ".account must be on default allowlist"
444 );
445 assert!(
446 al.is_allowed(&PathBuf::from("/pod/.account")),
447 ".account nested under pod must pass"
448 );
449 }
450
451 #[test]
452 fn allows_account_path_free_function() {
453 assert!(
454 is_path_allowed("/.account").is_ok(),
455 ".account must pass the free-function check"
456 );
457 assert!(
458 is_path_allowed("/.account/login").is_ok(),
459 ".account subtree must pass"
460 );
461 assert!(
462 is_path_allowed("/pod/.account/register").is_ok(),
463 ".account under pod must pass"
464 );
465 }
466
467 #[test]
468 fn account_in_default_allowed_constant() {
469 assert!(
470 DEFAULT_ALLOWED.contains(&".account"),
471 "DEFAULT_ALLOWED must include .account"
472 );
473 }
474}