vtc-service 0.12.0

Service for Verifiable Trust Communities
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
//! Filesystem path-safety helpers (§12.1, Phase 5 M5.4.1).
//!
//! Every request to the static handler walks through
//! [`canonical_within_root`]:
//!
//! 1. Decode the request path; reject NUL / control characters.
//! 2. NFC-normalise; reject non-NFC originals.
//! 3. Reject any segment starting with `.` (hidden files).
//! 4. Reject blocklisted extensions (`.cgi` / `.php` / `.exe` by
//!    default).
//! 5. Canonicalise (resolve `.`, `..`, symlinks) against
//!    `root_dir`; reject any result that escapes `root_dir`.
//! 6. Reject regular files with the executable bit set on Unix.
//!
//! The function returns the canonical filesystem path on success
//! so the caller can pass it to the FD cache directly. Path-safety
//! failures surface as typed [`PathError`] values; callers map
//! these into HTTP responses.

use std::path::{Path, PathBuf};

use unicode_normalization::{IsNormalized, UnicodeNormalization, is_nfc_quick};

/// Reasons a request path can fail the safety checks. Mapped to
/// HTTP responses by [`crate::website::serve::serve`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PathError {
    /// Path contained NUL / control characters.
    ControlChars,
    /// Path bytes were not NFC-normalised.
    NonNfc,
    /// Path component starts with `.` (hidden file).
    Hidden,
    /// Extension matches the configured blocklist.
    BlockedExtension(String),
    /// Canonicalised path escaped `root_dir`.
    Escape,
    /// `std::fs::canonicalize` failed (file not found, broken
    /// symlink, permission denied, etc.).
    NotFound,
    /// File on disk has the executable bit set (Unix).
    ExecBit,
}

/// Validate + canonicalise `request_path` against `root_dir`,
/// applying the full §12.1 safety chain.
///
/// Returns the canonical filesystem path on success; one of the
/// [`PathError`] variants on rejection. **Does not** open the
/// file — that's the FD cache's job.
pub fn canonical_within_root(
    root_dir: &Path,
    request_path: &str,
    executable_blocklist: &[String],
) -> Result<PathBuf, PathError> {
    // Steps 1–4 are the pure-string component checks (control chars,
    // NFC, hidden, blocklist) shared with the create path.
    let trimmed = validate_path_components(request_path, executable_blocklist)?;

    // 5 — canonicalise against root_dir.
    let candidate = root_dir.join(&trimmed);
    let canonical = std::fs::canonicalize(&candidate).map_err(|_| PathError::NotFound)?;
    let root_canonical = std::fs::canonicalize(root_dir).map_err(|_| PathError::NotFound)?;

    if !canonical.starts_with(&root_canonical) {
        return Err(PathError::Escape);
    }

    // 6 — exec bit on regular files (Unix only).
    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        if let Ok(meta) = canonical.metadata()
            && meta.is_file()
            && meta.permissions().mode() & 0o111 != 0
        {
            return Err(PathError::ExecBit);
        }
    }

    Ok(canonical)
}

/// Steps 1–4 of the safety chain: the pure-string checks that don't
/// touch the filesystem and so apply equally to a path whose target
/// doesn't exist yet (a `PUT` creating a file). Returns the trimmed,
/// NFC-normalised relative path (no leading `/`) on success.
fn validate_path_components(
    request_path: &str,
    executable_blocklist: &[String],
) -> Result<String, PathError> {
    // 1 — NUL / control char check. Decoded URL paths shouldn't
    //     carry control bytes; surface explicitly so the audit
    //     log records the rejection reason.
    if request_path
        .as_bytes()
        .iter()
        .any(|&b| b < 0x20 || b == 0x7f)
    {
        return Err(PathError::ControlChars);
    }

    // 2 — NFC normalisation. Requests must arrive NFC-normalised;
    //     non-NFC paths can map to different files on
    //     case-insensitive filesystems and on platforms that
    //     normalise differently (macOS HFS+ used NFD historically).
    if is_nfc_quick(request_path.chars()) != IsNormalized::Yes {
        return Err(PathError::NonNfc);
    }

    let nfc: String = request_path.chars().nfc().collect();

    // 3 — hidden-file check. Any component starting with `.` is
    //     refused. `.` and `..` are path-traversal markers, not
    //     hidden files; canonicalisation surfaces a traversal as
    //     `Escape` / `NotFound` instead (the create path rejects
    //     `..` outright — see [`canonical_within_root_for_create`]).
    let trimmed = nfc.trim_start_matches('/');
    for segment in trimmed.split('/') {
        if segment == "." || segment == ".." || segment.is_empty() {
            continue;
        }
        if segment.starts_with('.') {
            return Err(PathError::Hidden);
        }
    }

    // 4 — blocklisted extension check.
    if let Some(dot_idx) = trimmed.rfind('.') {
        let ext = &trimmed[dot_idx..];
        let ext_lower = ext.to_ascii_lowercase();
        if executable_blocklist
            .iter()
            .any(|b| b.eq_ignore_ascii_case(&ext_lower))
        {
            return Err(PathError::BlockedExtension(ext_lower));
        }
    }

    Ok(trimmed.to_string())
}

/// Validate a `PUT` target path against the **full** safety chain
/// *before* any filesystem mutation, returning the (non-canonical,
/// possibly not-yet-existing) target path inside `root_dir`.
///
/// [`canonical_within_root`] requires the target to exist (it
/// `canonicalize`s it), so it can't gate a create. This applies the
/// same component checks (control / NFC / hidden / blocklist), then
/// proves containment without the target existing:
///
/// - `.` / `..` segments are rejected outright (`Escape`) — a create
///   never legitimately traverses.
/// - the **nearest existing ancestor** of the target is canonicalised
///   and must stay within the canonical root, so a symlinked ancestor
///   pointing outside the root is caught before we write or `mkdir`.
///
/// The caller creates the parent and writes only after this returns
/// `Ok`.
pub fn canonical_within_root_for_create(
    root_dir: &Path,
    request_path: &str,
    executable_blocklist: &[String],
) -> Result<PathBuf, PathError> {
    let trimmed = validate_path_components(request_path, executable_blocklist)?;

    // Reject traversal + a directory-valued target (empty final
    // segment). The create path must name a concrete file.
    if trimmed.is_empty() || trimmed.ends_with('/') {
        return Err(PathError::NotFound);
    }
    for segment in trimmed.split('/') {
        if segment == "." || segment == ".." {
            return Err(PathError::Escape);
        }
    }

    let target = root_dir.join(&trimmed);
    let root_canonical = std::fs::canonicalize(root_dir).map_err(|_| PathError::NotFound)?;

    // Walk up from the target to the first ancestor that exists on
    // disk and canonicalise it — that resolves any symlinks in the
    // existing prefix. With no `..` in the path, an ancestor that
    // canonicalises within root guarantees the target does too.
    let mut existing = target.as_path();
    let canonical_ancestor = loop {
        match std::fs::canonicalize(existing) {
            Ok(c) => break c,
            Err(_) => match existing.parent() {
                Some(p) => existing = p,
                // Should be unreachable — root_dir always exists and
                // is an ancestor — but fail closed.
                None => return Err(PathError::Escape),
            },
        }
    };

    if !canonical_ancestor.starts_with(&root_canonical) {
        return Err(PathError::Escape);
    }

    Ok(target)
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::fs::{File, write};
    use std::io::Write;

    fn block() -> Vec<String> {
        vec![".cgi".into(), ".php".into(), ".exe".into()]
    }

    fn setup_root() -> (tempfile::TempDir, PathBuf) {
        let dir = tempfile::tempdir().expect("tempdir");
        let root = dir.path().to_path_buf();
        write(root.join("index.html"), "<html></html>").unwrap();
        std::fs::create_dir(root.join("assets")).unwrap();
        write(root.join("assets/logo.png"), [0x89u8, b'P', b'N', b'G']).unwrap();
        write(root.join(".hidden"), "secret").unwrap();
        write(root.join("evil.cgi"), "#!/bin/sh\n").unwrap();
        (dir, root)
    }

    #[test]
    fn happy_path_resolves_within_root() {
        let (_d, root) = setup_root();
        let result = canonical_within_root(&root, "/index.html", &block()).unwrap();
        assert!(result.ends_with("index.html"));
    }

    #[test]
    fn rejects_dotdot_escape() {
        let (_d, root) = setup_root();
        let err =
            canonical_within_root(&root, "/../../etc/passwd", &block()).expect_err("must reject");
        assert!(
            matches!(err, PathError::NotFound | PathError::Escape),
            "got {err:?}"
        );
    }

    #[test]
    fn rejects_hidden_file() {
        let (_d, root) = setup_root();
        let err = canonical_within_root(&root, "/.hidden", &block()).expect_err("must reject");
        assert_eq!(err, PathError::Hidden);
    }

    #[test]
    fn rejects_blocklisted_extension() {
        let (_d, root) = setup_root();
        let err = canonical_within_root(&root, "/evil.cgi", &block()).expect_err("must reject");
        assert_eq!(err, PathError::BlockedExtension(".cgi".into()));
    }

    #[test]
    fn rejects_non_nfc() {
        let (_d, root) = setup_root();
        // U+00E9 (NFC `é`) vs U+0065 + U+0301 (NFD `é`). The NFD
        // form is not NFC-normalised; must reject.
        let non_nfc = "/cafe\u{0301}.html";
        let err = canonical_within_root(&root, non_nfc, &block()).expect_err("must reject");
        assert_eq!(err, PathError::NonNfc);
    }

    #[test]
    fn rejects_nul_byte() {
        let (_d, root) = setup_root();
        let err = canonical_within_root(&root, "/index\0.html", &block()).expect_err("must reject");
        assert_eq!(err, PathError::ControlChars);
    }

    #[test]
    fn rejects_nested_hidden_segment() {
        let (_d, root) = setup_root();
        let err =
            canonical_within_root(&root, "/assets/.cache", &block()).expect_err("must reject");
        assert_eq!(err, PathError::Hidden);
    }

    #[cfg(unix)]
    #[test]
    fn rejects_exec_bit_file() {
        use std::os::unix::fs::PermissionsExt;

        let (_d, root) = setup_root();
        let exec_path = root.join("script.bin");
        let mut f = File::create(&exec_path).unwrap();
        f.write_all(b"#!/bin/sh\n").unwrap();
        let mut perms = std::fs::metadata(&exec_path).unwrap().permissions();
        perms.set_mode(0o755);
        std::fs::set_permissions(&exec_path, perms).unwrap();

        let err = canonical_within_root(&root, "/script.bin", &block()).expect_err("must reject");
        assert_eq!(err, PathError::ExecBit);
    }

    // ── canonical_within_root_for_create (PUT path) ──────────────

    #[test]
    fn create_allows_new_file_in_existing_dir() {
        let (_d, root) = setup_root();
        let target = canonical_within_root_for_create(&root, "/assets/new.css", &block()).unwrap();
        assert!(target.ends_with("assets/new.css"));
        // Validation must not create anything.
        assert!(!target.exists());
    }

    #[test]
    fn create_allows_new_nested_dir() {
        let (_d, root) = setup_root();
        // The parent dir doesn't exist yet — the create validator must
        // still accept it (the caller mkdirs after).
        let target =
            canonical_within_root_for_create(&root, "/blog/2026/post.html", &block()).unwrap();
        assert!(target.ends_with("blog/2026/post.html"));
        assert!(!root.join("blog").exists(), "validation must not mkdir");
    }

    #[test]
    fn create_rejects_blocklisted_extension() {
        let (_d, root) = setup_root();
        let err = canonical_within_root_for_create(&root, "/evil.php", &block())
            .expect_err("must reject");
        assert_eq!(err, PathError::BlockedExtension(".php".into()));
    }

    #[test]
    fn create_rejects_hidden_target() {
        let (_d, root) = setup_root();
        // `.htaccess`, `.git/config`, etc. — caught by the hidden check.
        for p in ["/.htaccess", "/.git/config", "/assets/.secret"] {
            let err = canonical_within_root_for_create(&root, p, &block()).unwrap_err();
            assert_eq!(err, PathError::Hidden, "path {p}");
        }
    }

    #[test]
    fn create_rejects_control_and_non_nfc() {
        let (_d, root) = setup_root();
        assert_eq!(
            canonical_within_root_for_create(&root, "/x\0.html", &block()).unwrap_err(),
            PathError::ControlChars,
        );
        assert_eq!(
            canonical_within_root_for_create(&root, "/cafe\u{0301}.html", &block()).unwrap_err(),
            PathError::NonNfc,
        );
    }

    #[test]
    fn create_rejects_dotdot_and_creates_nothing() {
        let (_d, root) = setup_root();
        let err = canonical_within_root_for_create(&root, "/../escape/x.html", &block())
            .expect_err("must reject");
        assert_eq!(err, PathError::Escape);
        // The pre-validation `create_dir_all` bug created directories
        // outside the root before rejecting; the validator must touch
        // nothing.
        assert!(!root.parent().unwrap().join("escape").exists());
    }

    #[test]
    fn create_rejects_empty_and_dir_target() {
        let (_d, root) = setup_root();
        assert_eq!(
            canonical_within_root_for_create(&root, "/", &block()).unwrap_err(),
            PathError::NotFound,
        );
        assert_eq!(
            canonical_within_root_for_create(&root, "/assets/", &block()).unwrap_err(),
            PathError::NotFound,
        );
    }

    #[cfg(unix)]
    #[test]
    fn create_rejects_symlinked_ancestor_escape() {
        use std::os::unix::fs::symlink;

        let (_d, root) = setup_root();
        // An attacker-controlled symlink inside root pointing outside
        // it: PUT through it must be rejected by the nearest-existing-
        // ancestor canonicalisation, even though the target file
        // doesn't exist yet.
        //
        // The escape target gets its own `TempDir` rather than a fixed name
        // beside this one. It used to be `<system tmp>/vtc-escape-target` —
        // a shared path every concurrent run of this test raced on, and the
        // test removed it on the way out, so two test binaries could delete
        // each other's target mid-assertion. That made the test fail under
        // `cargo test --workspace` and pass in isolation, which reads as
        // flakiness rather than as the shared-fixture bug it was.
        let outside_dir = tempfile::tempdir().expect("tempdir");
        let outside = outside_dir.path();
        symlink(outside, root.join("link")).unwrap();

        let err = canonical_within_root_for_create(&root, "/link/pwned.html", &block())
            .expect_err("symlinked ancestor must be rejected");
        assert_eq!(err, PathError::Escape);
        assert!(!outside.join("pwned.html").exists());
    }
}