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 (case-sensitive, as Solid paths are case-sensitive).
38#[derive(Debug, Clone)]
39pub struct DotfileAllowlist {
40    allowed: Vec<String>,
41    metrics: Option<SecurityMetrics>,
42}
43
44impl DotfileAllowlist {
45    /// Load from `DOTFILE_ALLOWLIST` (comma-separated). Falls back to
46    /// the default allowlist (`.acl`, `.meta`) when unset or empty.
47    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    /// Construct the default allowlist: `.acl`, `.meta`, `.account`.
65    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    /// Construct with an explicit allowlist. Each entry is normalised
73    /// to include the leading `.`.
74    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    /// Attach a metrics sink; counter is incremented on every deny.
87    pub fn with_metrics(mut self, metrics: SecurityMetrics) -> Self {
88        self.metrics = Some(metrics);
89        self
90    }
91
92    /// Return the current allowlist entries (normalised; each begins
93    /// with `.`).
94    pub fn entries(&self) -> &[String] {
95        &self.allowed
96    }
97
98    /// Returns `false` if ANY path component starts with `.` AND is
99    /// not on the allowlist. Returns `true` if the path is free of
100    /// dotfile components, or if every dotfile component present is
101    /// on the allowlist.
102    ///
103    /// `.` and `..` navigation components are always rejected
104    /// (callers MUST normalise paths before reaching this primitive,
105    /// but we defend in depth).
106    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                        // Non-UTF-8: refuse (Solid paths are UTF-8).
113                        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                    // Defensive: reject navigation components even
125                    // though callers should have normalised the path.
126                    self.record_deny();
127                    return false;
128                }
129                Component::Prefix(_) | Component::RootDir => {
130                    // Scheme prefix / leading `/`: no dotfile concern.
131                }
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// --- Sprint 9: row 115 free-function primitive ---------------------------
151//
152// The JSS-parity row 115 deliverable is a plain string-path allowlist used
153// at framework-agnostic call sites (middleware, route guards, provision
154// dry-runs) where an owning `DotfileAllowlist` is not wired up. Semantics
155// are a superset of the default allowlist: Solid metadata sidecars
156// (`.acl`, `.meta`), the service container (`.well-known`), quota
157// sidecars (`.quota.json`), and resource-specific ACL/meta trailers
158// (`foo.acl`, `foo.meta`) are permitted. Every other leading-dot segment
159// blocks the whole path. `..` traversal is refused as defence-in-depth.
160
161/// Dotfile allowlist errors used by the row-115 free primitive.
162#[derive(Debug, Clone, PartialEq, Eq, Hash, Error)]
163pub enum DotfilePathError {
164    /// A path segment started with `.` and was not on the allowlist.
165    #[error("dotfile segment '{segment}' not allowed in path '{path}'")]
166    NotAllowed { segment: String, path: String },
167
168    /// A `..` (parent) segment was encountered — rejected as defence
169    /// in depth against directory traversal.
170    #[error("parent-directory traversal segment '..' not allowed in path '{0}'")]
171    ParentTraversal(String),
172
173    /// A non-UTF-8 or otherwise malformed segment (Solid paths are
174    /// UTF-8). Currently unreachable from `&str` but kept for
175    /// forward-compat with OsStr-keyed callers.
176    #[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` — meta sidecar for an ACL; authorised by the union of
186    // the two rules above but spelled out here so the match is O(1)
187    // without a trailing-suffix check on the main path.
188    ".acl.meta",
189    // `.account` — IdP login endpoint. JSS commit 32c0db2 allows this
190    // through the dotfile filter so that the local identity provider can
191    // serve account-related resources (login, registration, password
192    // reset) at `/.account/…`.
193    ".account",
194];
195
196/// Decide whether `path` may be served, purely by inspecting its
197/// segments. Returns `Ok(())` when every segment is admissible.
198///
199/// A segment is admissible when any of the following hold:
200///   - it does not start with `.`
201///   - it is one of the statically-allowed dotfiles (`.acl`, `.meta`,
202///     `.well-known`, `.quota.json`)
203///
204/// Resource-specific ACL/metadata sidecars like `foo.acl` / `foo.meta`
205/// are admissible because their segment does not start with `.`; the
206/// trailing-suffix form is therefore handled implicitly by the
207/// first rule above.
208///
209/// Explicitly blocked:
210///   - `.env`, `.git`, `.ssh`, any other leading-dot name
211///   - `..` (parent-dir traversal) anywhere in the path
212///
213/// The check is applied to every segment: a blocked segment anywhere in
214/// the path fails the whole path (e.g. `/pod/.git/HEAD` is blocked).
215///
216/// Empty segments and `.` (current-dir) are ignored — they carry no
217/// authorisation information. Leading `/` is honoured as the root.
218///
219/// Upstream parity: `JavaScriptSolidServer/src/server.js:265-281` +
220/// Solid §Identity Provider service container rules.
221pub 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
243// --- helpers -------------------------------------------------------------
244
245fn 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// --- unit tests ----------------------------------------------------------
267
268#[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    // ----- Sprint 9 row 115: free-function primitive --------------------
320
321    #[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        // Resource-specific ACLs per Solid WAC: `foo.acl` is the ACL
353        // for `foo`. Segment does not start with `.` so admissible
354        // without consulting the dotfile allowlist.
355        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    // ----- Sprint 12: `.account` in dotfile allowlist (JSS 32c0db2) ------
415
416    #[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}