never executed always true always false
1 module PureClaw.Security.Path
2 ( -- * Safe path type (constructor intentionally NOT exported)
3 SafePath
4 -- * Path errors
5 , PathError (..)
6 -- * Construction (the ONLY way to obtain a SafePath)
7 , mkSafePath
8 -- * Read-only accessor
9 , getSafePath
10 ) where
11
12 import Data.List
13 import Data.Set (Set)
14 import Data.Set qualified as Set
15 import Data.Text (Text)
16 import System.Directory
17 import System.FilePath
18
19 import PureClaw.Core.Types
20
21 -- | A filesystem path that has been validated to be within the workspace
22 -- and not on the blocked list. Constructor is intentionally NOT exported —
23 -- the only way to obtain a 'SafePath' is through 'mkSafePath'.
24 newtype SafePath = SafePath { getSafePath :: FilePath }
25 deriving stock (Eq, Ord)
26
27 instance Show SafePath where
28 show sp = "SafePath " ++ show (getSafePath sp)
29
30 -- | Errors that can occur during path validation.
31 data PathError
32 = PathEscapesWorkspace FilePath FilePath -- ^ requested, resolved
33 | PathIsBlocked FilePath Text -- ^ requested, reason
34 | PathDoesNotExist FilePath -- ^ the requested path does not exist
35 deriving stock (Show, Eq)
36
37 -- | Paths that must never be readable or writable, regardless of workspace.
38 -- Checked against the first component of the relative path within the workspace.
39 blockedPaths :: Set String
40 blockedPaths = Set.fromList
41 [ ".env"
42 , ".env.local"
43 , ".env.production"
44 , ".ssh"
45 , ".gnupg"
46 , ".netrc"
47 , ".pureclaw" -- protects vault and config from agent file tools
48 ]
49
50 -- | The ONLY way to obtain a 'SafePath'. Canonicalizes the path (following
51 -- symlinks), verifies it stays within the workspace, checks the blocked list,
52 -- and verifies the path exists.
53 --
54 -- Checks run in order, each as an early return:
55 -- 1. Reject @..@ traversal (prevents workspace escape)
56 -- 2. Reject absolute paths outside workspace
57 -- 3. Reject blocked paths (@.env@, @.ssh@, etc.)
58 -- 4. Reject non-existent paths
59 -- 5. Reject symlinks that resolve outside workspace
60 mkSafePath :: WorkspaceRoot -> FilePath -> IO (Either PathError SafePath)
61 mkSafePath (WorkspaceRoot root) requested = do
62 canonRoot <- canonicalizePath root
63 let raw = if isAbsolute requested then requested else canonRoot </> requested
64 relative = makeRelative canonRoot raw
65 -- Pure checks first (no IO, no filesystem access)
66 let pureCheck
67 | hasParentTraversal requested = Just (PathEscapesWorkspace requested raw)
68 | isAbsolute requested && not (canonRoot `isPrefixOf` raw) = Just (PathEscapesWorkspace requested raw)
69 | isBlockedPath relative = Just (PathIsBlocked requested "blocked path")
70 | otherwise = Nothing
71 case pureCheck of
72 Just err -> pure (Left err)
73 Nothing -> do
74 exists <- doesPathExist raw
75 if not exists
76 then pure $ Left (PathDoesNotExist requested)
77 else do
78 -- Canonicalize resolves symlinks — a symlink inside the workspace
79 -- could point outside it, so we re-check containment.
80 canonical <- canonicalizePath raw
81 pure $ if canonRoot `isPrefixOf` canonical
82 then Right (SafePath canonical)
83 else Left (PathEscapesWorkspace requested canonical)
84
85 -- | Check if a path contains ".." components that could traverse upward.
86 hasParentTraversal :: FilePath -> Bool
87 hasParentTraversal path = ".." `elem` splitDirectories path
88
89 -- | Check if a relative path matches any blocked path.
90 -- Matches on the first path component (e.g. ".env" matches ".env" and ".env/foo").
91 isBlockedPath :: FilePath -> Bool
92 isBlockedPath relative =
93 let firstComponent = Prelude.takeWhile (\c -> c /= '/' && c /= '\\') relative
94 in Set.member firstComponent blockedPaths
95