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 ]
48
49 -- | The ONLY way to obtain a 'SafePath'. Canonicalizes the path (following
50 -- symlinks), verifies it stays within the workspace, checks the blocked list,
51 -- and verifies the path exists.
52 --
53 -- Checks run in order, each as an early return:
54 -- 1. Reject @..@ traversal (prevents workspace escape)
55 -- 2. Reject absolute paths outside workspace
56 -- 3. Reject blocked paths (@.env@, @.ssh@, etc.)
57 -- 4. Reject non-existent paths
58 -- 5. Reject symlinks that resolve outside workspace
59 mkSafePath :: WorkspaceRoot -> FilePath -> IO (Either PathError SafePath)
60 mkSafePath (WorkspaceRoot root) requested = do
61 canonRoot <- canonicalizePath root
62 let raw = if isAbsolute requested then requested else canonRoot </> requested
63 relative = makeRelative canonRoot raw
64 -- Pure checks first (no IO, no filesystem access)
65 let pureCheck
66 | hasParentTraversal requested = Just (PathEscapesWorkspace requested raw)
67 | isAbsolute requested && not (canonRoot `isPrefixOf` raw) = Just (PathEscapesWorkspace requested raw)
68 | isBlockedPath relative = Just (PathIsBlocked requested "blocked path")
69 | otherwise = Nothing
70 case pureCheck of
71 Just err -> pure (Left err)
72 Nothing -> do
73 exists <- doesPathExist raw
74 if not exists
75 then pure $ Left (PathDoesNotExist requested)
76 else do
77 -- Canonicalize resolves symlinks — a symlink inside the workspace
78 -- could point outside it, so we re-check containment.
79 canonical <- canonicalizePath raw
80 pure $ if canonRoot `isPrefixOf` canonical
81 then Right (SafePath canonical)
82 else Left (PathEscapesWorkspace requested canonical)
83
84 -- | Check if a path contains ".." components that could traverse upward.
85 hasParentTraversal :: FilePath -> Bool
86 hasParentTraversal path = ".." `elem` splitDirectories path
87
88 -- | Check if a relative path matches any blocked path.
89 -- Matches on the first path component (e.g. ".env" matches ".env" and ".env/foo").
90 isBlockedPath :: FilePath -> Bool
91 isBlockedPath relative =
92 let firstComponent = Prelude.takeWhile (\c -> c /= '/' && c /= '\\') relative
93 in Set.member firstComponent blockedPaths
94