Skip to main content

solid_pod_rs/security/
dotfile.rs

1//! Dotfile allowlist (F2).
2//!
3//! Rejects any inbound request whose path contains a component starting
4//! with `.` unless that component is explicitly allowlisted. Default
5//! allowlist mirrors JSS: `.acl` and `.meta` — the standard Solid
6//! metadata sidecars.
7//!
8//! Upstream parity: `JavaScriptSolidServer/src/server.js:265-281`.
9//! Design context: `docs/design/jss-parity/01-security-primitives-context.md`.
10
11use std::path::{Component, Path};
12
13use thiserror::Error;
14
15use crate::metrics::SecurityMetrics;
16
17/// Environment variable: comma-separated dotfile names permitted by the
18/// allowlist. Each entry may or may not include the leading `.`; the
19/// allowlist stores them normalised (leading `.` present).
20pub const ENV_DOTFILE_ALLOWLIST: &str = "DOTFILE_ALLOWLIST";
21
22/// Default allowlist entries. Matches JSS behaviour for standard Solid
23/// metadata sidecars and the IdP login endpoint (JSS commit 32c0db2).
24pub const DEFAULT_ALLOWED: &[&str] = &[".acl", ".meta", ".account"];
25
26/// Reason a path was rejected.
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Error)]
28pub enum DotfileError {
29    /// Path contained a dotfile component not on the allowlist.
30    #[error("dotfile path component is not on the allowlist")]
31    NotAllowed,
32}
33
34/// Dotfile allowlist (aggregate root).
35///
36/// Immutable after construction. Matching is by exact component
37/// equality, but segments that would collide with a guarded name on a
38/// case-insensitive filesystem (`doc.ACL`, `X.META.JSON`) are refused:
39/// see `is_non_canonical_guarded` (private helper).
40#[derive(Debug, Clone)]
41pub struct DotfileAllowlist {
42    allowed: Vec<String>,
43    metrics: Option<SecurityMetrics>,
44}
45
46impl DotfileAllowlist {
47    /// Load from `DOTFILE_ALLOWLIST` (comma-separated). Falls back to
48    /// the default allowlist (`.acl`, `.meta`) when unset or empty.
49    pub fn from_env() -> Self {
50        match std::env::var(ENV_DOTFILE_ALLOWLIST) {
51            Ok(raw) => {
52                let parsed = parse_csv(&raw);
53                if parsed.is_empty() {
54                    Self::with_defaults()
55                } else {
56                    Self {
57                        allowed: parsed,
58                        metrics: None,
59                    }
60                }
61            }
62            Err(_) => Self::with_defaults(),
63        }
64    }
65
66    /// Construct the default allowlist: `.acl`, `.meta`, `.account`.
67    pub fn with_defaults() -> Self {
68        Self {
69            allowed: DEFAULT_ALLOWED.iter().map(|s| (*s).to_string()).collect(),
70            metrics: None,
71        }
72    }
73
74    /// Construct with an explicit allowlist. Each entry is normalised
75    /// to include the leading `.`.
76    pub fn new(entries: Vec<String>) -> Self {
77        let allowed = entries
78            .into_iter()
79            .map(|e| normalise_entry(&e))
80            .filter(|e| !e.is_empty() && e != ".")
81            .collect();
82        Self {
83            allowed,
84            metrics: None,
85        }
86    }
87
88    /// Attach a metrics sink; counter is incremented on every deny.
89    pub fn with_metrics(mut self, metrics: SecurityMetrics) -> Self {
90        self.metrics = Some(metrics);
91        self
92    }
93
94    /// Return the current allowlist entries (normalised; each begins
95    /// with `.`).
96    pub fn entries(&self) -> &[String] {
97        &self.allowed
98    }
99
100    /// Returns `false` if ANY path component starts with `.` AND is
101    /// not on the allowlist. Returns `true` if the path is free of
102    /// dotfile components, or if every dotfile component present is
103    /// on the allowlist.
104    ///
105    /// `.` and `..` navigation components are always rejected
106    /// (callers MUST normalise paths before reaching this primitive,
107    /// but we defend in depth).
108    pub fn is_allowed(&self, path: &Path) -> bool {
109        for component in path.components() {
110            match component {
111                Component::Normal(os) => {
112                    let s = match os.to_str() {
113                        Some(s) => s,
114                        // Non-UTF-8: refuse (Solid paths are UTF-8).
115                        None => {
116                            self.record_deny();
117                            return false;
118                        }
119                    };
120                    if s.starts_with('.') && !self.allowed.iter().any(|a| a == s) {
121                        self.record_deny();
122                        return false;
123                    }
124                    // Reserve the internal storage-sidecar namespace. The fs
125                    // backend stores each resource's content-type in a
126                    // `<name>.meta.json` sidecar; because that name does not
127                    // start with `.` it slips the dotfile rule above. A direct
128                    // HTTP write to `/foo.meta.json` would hide `/foo` from
129                    // container listings AND poison the Content-Type served
130                    // for a sibling (a stored-XSS vector). These are never
131                    // directly addressable over HTTP.
132                    if fold_case(s).ends_with(".meta.json") || is_non_canonical_guarded(s) {
133                        self.record_deny();
134                        return false;
135                    }
136                }
137                Component::CurDir | Component::ParentDir => {
138                    // Defensive: reject navigation components even
139                    // though callers should have normalised the path.
140                    self.record_deny();
141                    return false;
142                }
143                Component::Prefix(_) | Component::RootDir => {
144                    // Scheme prefix / leading `/`: no dotfile concern.
145                }
146            }
147        }
148        true
149    }
150
151    fn record_deny(&self) {
152        if let Some(m) = &self.metrics {
153            m.record_dotfile_deny();
154        }
155    }
156}
157
158impl Default for DotfileAllowlist {
159    fn default() -> Self {
160        Self::with_defaults()
161    }
162}
163
164// --- Sprint 9: row 115 free-function primitive ---------------------------
165//
166// The JSS-parity row 115 deliverable is a plain string-path allowlist used
167// at framework-agnostic call sites (middleware, route guards, provision
168// dry-runs) where an owning `DotfileAllowlist` is not wired up. Semantics
169// are a superset of the default allowlist: Solid metadata sidecars
170// (`.acl`, `.meta`), the service container (`.well-known`), quota
171// sidecars (`.quota.json`), and resource-specific ACL/meta trailers
172// (`foo.acl`, `foo.meta`) are permitted. Every other leading-dot segment
173// blocks the whole path. `..` traversal is refused as defence-in-depth.
174
175/// Dotfile allowlist errors used by the row-115 free primitive.
176#[derive(Debug, Clone, PartialEq, Eq, Hash, Error)]
177pub enum DotfilePathError {
178    /// A path segment started with `.` and was not on the allowlist.
179    #[error("dotfile segment '{segment}' not allowed in path '{path}'")]
180    NotAllowed { segment: String, path: String },
181
182    /// A `..` (parent) segment was encountered — rejected as defence
183    /// in depth against directory traversal.
184    #[error("parent-directory traversal segment '..' not allowed in path '{0}'")]
185    ParentTraversal(String),
186
187    /// A non-UTF-8 or otherwise malformed segment (Solid paths are
188    /// UTF-8). Currently unreachable from `&str` but kept for
189    /// forward-compat with OsStr-keyed callers.
190    #[error("malformed path segment in '{0}'")]
191    Malformed(String),
192}
193
194const STATIC_ALLOWED_DOTFILES: &[&str] = &[
195    ".acl",
196    ".meta",
197    ".well-known",
198    ".quota.json",
199    // `.acl.meta` — meta sidecar for an ACL; authorised by the union of
200    // the two rules above but spelled out here so the match is O(1)
201    // without a trailing-suffix check on the main path.
202    ".acl.meta",
203    // `.account` — IdP login endpoint. JSS commit 32c0db2 allows this
204    // through the dotfile filter so that the local identity provider can
205    // serve account-related resources (login, registration, password
206    // reset) at `/.account/…`.
207    ".account",
208];
209
210/// Decide whether `path` may be served, purely by inspecting its
211/// segments. Returns `Ok(())` when every segment is admissible.
212///
213/// A segment is admissible when any of the following hold:
214///   - it does not start with `.`
215///   - it is one of the statically-allowed dotfiles (`.acl`, `.meta`,
216///     `.well-known`, `.quota.json`)
217///
218/// Resource-specific ACL/metadata sidecars like `foo.acl` / `foo.meta`
219/// are admissible because their segment does not start with `.`; the
220/// trailing-suffix form is therefore handled implicitly by the
221/// first rule above.
222///
223/// Explicitly blocked:
224///   - `.env`, `.git`, `.ssh`, any other leading-dot name
225///   - `..` (parent-dir traversal) anywhere in the path
226///
227/// The check is applied to every segment: a blocked segment anywhere in
228/// the path fails the whole path (e.g. `/pod/.git/HEAD` is blocked).
229///
230/// Empty segments and `.` (current-dir) are ignored — they carry no
231/// authorisation information. Leading `/` is honoured as the root.
232///
233/// Upstream parity: `JavaScriptSolidServer/src/server.js:265-281` +
234/// Solid §Identity Provider service container rules.
235pub fn is_path_allowed(path: &str) -> Result<(), DotfilePathError> {
236    for segment in path.split('/') {
237        if segment.is_empty() || segment == "." {
238            continue;
239        }
240        if segment == ".." {
241            return Err(DotfilePathError::ParentTraversal(path.to_string()));
242        }
243        // Reserve the internal storage-sidecar namespace (`<name>.meta.json`):
244        // it does not start with `.`, so without this it would slip the
245        // dotfile rule below and let an HTTP writer hide or content-type-
246        // poison a sibling resource. See `DotfileAllowlist::is_allowed`.
247        if fold_case(segment).ends_with(".meta.json") || is_non_canonical_guarded(segment) {
248            return Err(DotfilePathError::NotAllowed {
249                segment: segment.to_string(),
250                path: path.to_string(),
251            });
252        }
253        if !segment.starts_with('.') {
254            continue;
255        }
256        if STATIC_ALLOWED_DOTFILES.contains(&segment) {
257            continue;
258        }
259        return Err(DotfilePathError::NotAllowed {
260            segment: segment.to_string(),
261            path: path.to_string(),
262        });
263    }
264    Ok(())
265}
266
267// --- helpers -------------------------------------------------------------
268
269/// `true` when `segment` is *not* spelled in its canonical (Unicode
270/// lower-case) form but its folded form is a guarded sidecar name
271/// (`*.acl`, `*.meta`, `*.meta.json`, or any allowlisted dotfile).
272///
273/// On case-insensitive filesystems (default macOS/APFS, Windows/NTFS)
274/// `doc.ACL` and `doc.acl` are the same file, so a guard keyed on the
275/// canonical spelling would be bypassed by a variant one. Folding uses
276/// [`fold_case`].
277/// Canonical spellings are unaffected: `doc.acl` is still an ordinary
278/// sidecar name.
279fn is_non_canonical_guarded(segment: &str) -> bool {
280    let folded = fold_case(segment);
281    if folded == segment {
282        return false;
283    }
284    folded.ends_with(".acl")
285        || folded.ends_with(".meta")
286        || folded.ends_with(".meta.json")
287        || STATIC_ALLOWED_DOTFILES.contains(&folded.as_str())
288}
289
290/// Fold `s` for comparison against a guarded name, the way a case-insensitive
291/// filesystem would.
292///
293/// [`str::to_lowercase`] is Unicode *simple* lower-casing, which already maps
294/// the Kelvin sign U+212A to `k`. Full case folding also maps a few characters
295/// to ASCII that lower-casing leaves alone: the long s U+017F to `s`, the sharp
296/// s to `ss`, and the Latin ligatures (U+FB00 to U+FB06) to their letters. A
297/// guard built on lower-casing alone let `x.meta.j\u{17F}on` through.
298///
299/// ```
300/// use solid_pod_rs::security::dotfile::fold_case;
301/// assert_eq!(fold_case("X.META.J\u{17F}ON"), "x.meta.json");
302/// assert_eq!(fold_case("to\u{212A}en"), "token");
303/// assert_eq!(fold_case("\u{FB01}le"), "file");
304/// ```
305#[must_use]
306pub fn fold_case(s: &str) -> String {
307    let mut out = String::with_capacity(s.len());
308    for c in s.chars() {
309        match c {
310            '\u{017F}' => out.push('s'),
311            '\u{00DF}' | '\u{1E9E}' => out.push_str("ss"),
312            '\u{FB00}' => out.push_str("ff"),
313            '\u{FB01}' => out.push_str("fi"),
314            '\u{FB02}' => out.push_str("fl"),
315            '\u{FB03}' => out.push_str("ffi"),
316            '\u{FB04}' => out.push_str("ffl"),
317            '\u{FB05}' | '\u{FB06}' => out.push_str("st"),
318            c => out.extend(c.to_lowercase()),
319        }
320    }
321    out
322}
323
324fn parse_csv(raw: &str) -> Vec<String> {
325    raw.split(',')
326        .map(|s| s.trim())
327        .filter(|s| !s.is_empty())
328        .map(normalise_entry)
329        .filter(|s| !s.is_empty() && s != ".")
330        .collect()
331}
332
333fn normalise_entry(entry: &str) -> String {
334    let trimmed = entry.trim().trim_start_matches('/');
335    if trimmed.is_empty() {
336        return String::new();
337    }
338    if trimmed.starts_with('.') {
339        trimmed.to_string()
340    } else {
341        format!(".{trimmed}")
342    }
343}
344
345// --- unit tests ----------------------------------------------------------
346
347#[cfg(test)]
348mod tests {
349    use super::*;
350    use std::path::PathBuf;
351
352    #[test]
353    fn case_variants_of_guarded_names_are_refused() {
354        let al = DotfileAllowlist::default();
355        for bad in [
356            "/p/doc.ACL",
357            "/p/doc.Acl",
358            "/p/doc.META",
359            "/p/X.META.JSON",
360            "/p/x.Meta.Json",
361            "/p/.ACL",
362            "/p/.Meta",
363        ] {
364            assert!(!al.is_allowed(&PathBuf::from(bad)), "{bad}");
365            assert!(is_path_allowed(bad).is_err(), "{bad}");
366        }
367        // Kelvin sign folds to ASCII `k`; make sure the fold is Unicode-aware.
368        assert_eq!("\u{212A}".to_lowercase(), "k");
369        // Lower-casing leaves the long s alone; folding must not.
370        assert!(is_path_allowed("/x.meta.j\u{17F}on").is_err());
371        assert!(
372            is_path_allowed("/doc.\u{17F}cl").is_ok(),
373            "not a guarded name"
374        );
375        assert!(is_path_allowed("/doc.ACL").is_err());
376        for ok in ["/p/doc.acl", "/p/doc.meta", "/p/Readme.TXT", "/p/Photo.JPG"] {
377            assert!(al.is_allowed(&PathBuf::from(ok)), "{ok}");
378            assert!(is_path_allowed(ok).is_ok(), "{ok}");
379        }
380    }
381
382    #[test]
383    fn default_permits_acl_and_meta() {
384        let al = DotfileAllowlist::default();
385        assert!(al.is_allowed(&PathBuf::from("/resource/.acl")));
386        assert!(al.is_allowed(&PathBuf::from("/resource/.meta")));
387    }
388
389    #[test]
390    fn default_blocks_env() {
391        let al = DotfileAllowlist::default();
392        assert!(!al.is_allowed(&PathBuf::from("/.env")));
393        assert!(!al.is_allowed(&PathBuf::from("/x/y/.env")));
394    }
395
396    #[test]
397    fn explicit_allowlist_accepts_listed_entries() {
398        let al = DotfileAllowlist::new(vec![".env".into(), ".config".into()]);
399        assert!(al.is_allowed(&PathBuf::from("/.env")));
400        assert!(al.is_allowed(&PathBuf::from("/.config")));
401        assert!(!al.is_allowed(&PathBuf::from("/.secret")));
402    }
403
404    #[test]
405    fn entry_without_dot_prefix_is_normalised() {
406        let al = DotfileAllowlist::new(vec!["notifications".into()]);
407        assert!(al.is_allowed(&PathBuf::from("/.notifications")));
408    }
409
410    #[test]
411    fn nested_dotfile_rejected() {
412        let al = DotfileAllowlist::default();
413        assert!(!al.is_allowed(&PathBuf::from("foo/.secret/bar")));
414    }
415
416    #[test]
417    fn path_without_dotfiles_accepted() {
418        let al = DotfileAllowlist::default();
419        assert!(al.is_allowed(&PathBuf::from("/a/b/c/file.ttl")));
420    }
421
422    #[test]
423    fn parent_dir_rejected() {
424        let al = DotfileAllowlist::default();
425        assert!(!al.is_allowed(&PathBuf::from("foo/..")));
426    }
427
428    // ----- Sprint 9 row 115: free-function primitive --------------------
429
430    #[test]
431    fn allows_acl_file() {
432        assert!(is_path_allowed("/.acl").is_ok());
433        assert!(is_path_allowed("/pod/.acl").is_ok());
434        assert!(is_path_allowed("/pod/container/.acl").is_ok());
435    }
436
437    #[test]
438    fn allows_meta_file() {
439        assert!(is_path_allowed("/.meta").is_ok());
440        assert!(is_path_allowed("/pod/.meta").is_ok());
441        assert!(is_path_allowed("/pod/container/.meta").is_ok());
442    }
443
444    #[test]
445    fn allows_well_known_subtree() {
446        assert!(is_path_allowed("/.well-known").is_ok());
447        assert!(is_path_allowed("/.well-known/openid-configuration").is_ok());
448        assert!(is_path_allowed("/.well-known/solid").is_ok());
449        assert!(is_path_allowed("/pod/.well-known/nested").is_ok());
450    }
451
452    #[test]
453    fn allows_quota_sidecar() {
454        assert!(is_path_allowed("/.quota.json").is_ok());
455        assert!(is_path_allowed("/pod/.quota.json").is_ok());
456        assert!(is_path_allowed("/pod/container/.quota.json").is_ok());
457    }
458
459    #[test]
460    fn allows_resource_specific_acl() {
461        // Resource-specific ACLs per Solid WAC: `foo.acl` is the ACL
462        // for `foo`. Segment does not start with `.` so admissible
463        // without consulting the dotfile allowlist.
464        assert!(is_path_allowed("/foo.acl").is_ok());
465        assert!(is_path_allowed("/foo.meta").is_ok());
466        assert!(is_path_allowed("/pod/data.ttl.acl").is_ok());
467        assert!(is_path_allowed("/pod/image.jpg.meta").is_ok());
468    }
469
470    #[test]
471    fn allows_normal_path() {
472        assert!(is_path_allowed("/foo/bar.ttl").is_ok());
473        assert!(is_path_allowed("/").is_ok());
474        assert!(is_path_allowed("/pod/data/doc.ttl").is_ok());
475        assert!(is_path_allowed("").is_ok());
476    }
477
478    #[test]
479    fn blocks_env_file() {
480        match is_path_allowed("/.env") {
481            Err(DotfilePathError::NotAllowed { segment, .. }) => assert_eq!(segment, ".env"),
482            other => panic!("expected NotAllowed for /.env, got {other:?}"),
483        }
484        assert!(is_path_allowed("/pod/.env").is_err());
485        assert!(is_path_allowed("/deep/path/.env").is_err());
486    }
487
488    #[test]
489    fn blocks_git_dir() {
490        match is_path_allowed("/pod/.git/config") {
491            Err(DotfilePathError::NotAllowed { segment, .. }) => assert_eq!(segment, ".git"),
492            other => panic!("expected NotAllowed for /pod/.git/config, got {other:?}"),
493        }
494        assert!(is_path_allowed("/.git").is_err());
495        assert!(is_path_allowed("/.git/HEAD").is_err());
496        assert!(is_path_allowed("/.ssh/id_rsa").is_err());
497    }
498
499    #[test]
500    fn blocks_hidden_file_anywhere() {
501        assert!(is_path_allowed("/foo/.hidden/bar.ttl").is_err());
502        assert!(is_path_allowed("/a/b/c/.secret").is_err());
503        assert!(is_path_allowed("/.DS_Store").is_err());
504        assert!(is_path_allowed("/pod/.npmrc").is_err());
505    }
506
507    #[test]
508    fn blocks_double_dot() {
509        match is_path_allowed("/pod/../etc/passwd") {
510            Err(DotfilePathError::ParentTraversal(_)) => {}
511            other => panic!("expected ParentTraversal for /pod/../etc/passwd, got {other:?}"),
512        }
513        assert!(matches!(
514            is_path_allowed(".."),
515            Err(DotfilePathError::ParentTraversal(_))
516        ));
517        assert!(matches!(
518            is_path_allowed("/a/../b"),
519            Err(DotfilePathError::ParentTraversal(_))
520        ));
521    }
522
523    // ----- Sprint 12: `.account` in dotfile allowlist (JSS 32c0db2) ------
524
525    #[test]
526    fn default_permits_account() {
527        let al = DotfileAllowlist::default();
528        assert!(
529            al.is_allowed(&PathBuf::from("/.account")),
530            ".account must be on default allowlist"
531        );
532        assert!(
533            al.is_allowed(&PathBuf::from("/pod/.account")),
534            ".account nested under pod must pass"
535        );
536    }
537
538    #[test]
539    fn allows_account_path_free_function() {
540        assert!(
541            is_path_allowed("/.account").is_ok(),
542            ".account must pass the free-function check"
543        );
544        assert!(
545            is_path_allowed("/.account/login").is_ok(),
546            ".account subtree must pass"
547        );
548        assert!(
549            is_path_allowed("/pod/.account/register").is_ok(),
550            ".account under pod must pass"
551        );
552    }
553
554    #[test]
555    fn account_in_default_allowed_constant() {
556        assert!(
557            DEFAULT_ALLOWED.contains(&".account"),
558            "DEFAULT_ALLOWED must include .account"
559        );
560    }
561}