packages feed

nova-nix-0.9.0.0: src/Nix/Eval/Policy.hs

-- | The evaluation policy: upstream's @restrict-eval@ and @pure-eval@
-- settings, the @allowed-uris@ list, and the pure decisions they drive.
--
-- Upstream gates evaluation in two layers (@src/libexpr/eval.cc@ at
-- 2.24.9).  Under either setting the root filesystem is wrapped in an
-- @AllowListSourceAccessor@ that admits a path when it lies within an
-- allowed prefix or is an ancestor of one (@CanonPath::isAllowed@), and
-- the allowed set starts as the search path roots and grows as the
-- evaluation copies, fetches or writes store objects (@allowPath@).
-- Under @restrict-eval@ alone a fetch is also checked against
-- @allowed-uris@ (@checkURI@).  @pure-eval@ additionally empties the
-- search path, hides the impure constants, and makes the fetchers demand
-- a hash or revision; those decisions live with the builtins and consult
-- 'EvalPolicy' through 'Nix.Eval.Types.MonadEval'.
--
-- Everything here is pure so each rule can be tested on its own.  The
-- filesystem side (walking a path through its symlinks, prefix by
-- prefix) lives in "Nix.Eval.IO".
module Nix.Eval.Policy
  ( -- * Policy
    EvalPolicy (..),
    unrestrictedPolicy,
    pathsRestricted,
    modeInformation,

    -- * Allowed paths
    AllowedPaths,
    noAllowedPaths,
    allowPathIn,
    isAllowedPath,
    isAllowedPrefix,
    pathComponents,
    joinComponents,
    isAbsolutePath,
    forbiddenPathMessage,

    -- * Allowed URIs
    isAllowedUri,
    uriAccess,
    forbiddenUriMessage,
  )
where

import Data.Char (isAsciiLower, isAsciiUpper, isDigit)
import Data.List (inits, isPrefixOf)
import Data.Set (Set)
import qualified Data.Set as Set
import Data.Text (Text)
import qualified Data.Text as T
import Nix.Eval.CanonPath (canonPathValue)

-- ---------------------------------------------------------------------------
-- Policy
-- ---------------------------------------------------------------------------

-- | What an evaluation may reach.  The two flags are independent, as
-- upstream's settings are: @pure-eval@ does not imply the URI check
-- (@checkURI@ consults @restrictEval@ alone), and @restrict-eval@ does
-- not hide the impure constants or empty the search path.
data EvalPolicy = EvalPolicy
  { -- | Upstream @restrict-eval@: filesystem reads are confined to the
    -- allowed prefixes, @getEnv@ answers empty, and a fetch must match
    -- 'epAllowedUris'.
    epRestrictEval :: !Bool,
    -- | Upstream @pure-eval@: the same filesystem confinement, no search
    -- path, no @currentTime@ or @currentSystem@, no @storePath@, and the
    -- fetchers require a hash or revision.
    epPureEval :: !Bool,
    -- | Upstream @allowed-uris@: prefixes a fetch may reach under
    -- @restrict-eval@.
    epAllowedUris :: ![Text]
  }
  deriving (Eq, Show)

-- | Upstream's default: both settings off, nothing listed.
unrestrictedPolicy :: EvalPolicy
unrestrictedPolicy = EvalPolicy {epRestrictEval = False, epPureEval = False, epAllowedUris = []}

-- | Whether filesystem access goes through the allow list: upstream wraps
-- the root filesystem when either setting is on.
pathsRestricted :: EvalPolicy -> Bool
pathsRestricted policy = epRestrictEval policy || epPureEval policy

-- | The clause a path refusal ends with.  Upstream names pure mode when
-- it is on, restricted mode otherwise (@eval.cc@, the accessor's error
-- callback).  The @--impure@ hint is upstream's text, kept for parity.
modeInformation :: EvalPolicy -> Text
modeInformation policy
  | epPureEval policy = "in pure evaluation mode (use '--impure' to override)"
  | otherwise = "in restricted mode"

-- ---------------------------------------------------------------------------
-- Allowed paths
-- ---------------------------------------------------------------------------

-- | The allowed prefixes, as component lists under lexicographic order.
-- That order is upstream's @CanonPath@ order (a separator sorts before
-- every other character), which is what makes "the smallest element at
-- or after a path is its descendant or nothing is" a correct test.
newtype AllowedPaths = AllowedPaths (Set [Text])
  deriving (Eq, Show)

-- | Nothing allowed yet.
noAllowedPaths :: AllowedPaths
noAllowedPaths = AllowedPaths Set.empty

-- | Grant access to a prefix (upstream @allowPrefix@).  The text is
-- canonicalized first, so the spelling that arrives does not matter.
allowPathIn :: Text -> AllowedPaths -> AllowedPaths
allowPathIn path (AllowedPaths allowed) = AllowedPaths (Set.insert (pathComponents path) allowed)

-- | Upstream @CanonPath::isAllowed@: access is allowed when the path is
-- within an allowed prefix (the prefix itself included) or when an
-- allowed prefix is within the path, which keeps the ancestors of an
-- allowed path reachable.
isAllowedPath :: AllowedPaths -> Text -> Bool
isAllowedPath allowed = isAllowedPrefix allowed . pathComponents

-- | 'isAllowedPath' over components, for a caller walking a path one
-- component at a time.
isAllowedPrefix :: AllowedPaths -> [Text] -> Bool
isAllowedPrefix (AllowedPaths allowed) path =
  descendantAllowed || any (`Set.member` allowed) (inits path)
  where
    descendantAllowed = maybe False (path `isPrefixOf`) (Set.lookupGE path allowed)

-- | A canonical path's components: the root has none, and a Windows
-- drive designator is the first one.
pathComponents :: Text -> [Text]
pathComponents = filter (not . T.null) . T.splitOn "/" . canonPathValue

-- | The canonical text of a component list, inverse of 'pathComponents'.
joinComponents :: [Text] -> Text
joinComponents components = case components of
  (drive : rest)
    | isDriveDesignator drive -> drive <> "/" <> T.intercalate "/" rest
  _ -> "/" <> T.intercalate "/" components
  where
    isDriveDesignator d = T.length d == driveDesignatorLength && T.isSuffixOf ":" d

-- | @X:@
driveDesignatorLength :: Int
driveDesignatorLength = 2

-- | Whether a path's text is rooted: a leading @/@, or a drive designator
-- (a canonical Windows path value spells @C:/...@).  The gates refuse
-- anything else, since a relative path would be checked against one
-- location and opened at another.
isAbsolutePath :: Text -> Bool
isAbsolutePath path = T.isPrefixOf "/" path || hasDrive
  where
    hasDrive = case T.unpack (T.take (driveDesignatorLength + 1) path) of
      [letter, ':', '/'] -> isAsciiLetter letter
      _ -> False
    isAsciiLetter c = isAsciiLower c || isAsciiUpper c

-- | Upstream's refusal, from the accessor's error callback in @eval.cc@.
forbiddenPathMessage :: EvalPolicy -> Text -> Text
forbiddenPathMessage policy path =
  "access to absolute path '" <> path <> "' is forbidden " <> modeInformation policy

-- ---------------------------------------------------------------------------
-- Allowed URIs
-- ---------------------------------------------------------------------------

-- | Upstream @isAllowedURI@ (@eval.cc@ at 2.24.9): a URI is allowed when
-- it equals a prefix, or extends a prefix at a @/@ boundary (the prefix
-- ends in @/@, or the URI's next character is @/@), or the prefix is a
-- bare scheme ending in @:@.  @https://github.co@ therefore does not
-- admit @https://github.com@.
isAllowedUri :: [Text] -> Text -> Bool
isAllowedUri prefixes uri = any admits prefixes
  where
    admits prefix =
      uri == prefix
        || ( T.length uri > T.length prefix
               && not (T.null prefix)
               && T.isPrefixOf prefix uri
               && ( T.isSuffixOf "/" prefix
                      || T.isPrefixOf "/" (T.drop (T.length prefix) uri)
                      || isJustSchemePrefix prefix
                  )
           )

-- | Upstream @isJustSchemePrefix@: a scheme name followed by a colon and
-- nothing else, the scheme spelled as @url-parts.hh@ allows
-- (@[a-z][a-z0-9+.-]*@).
isJustSchemePrefix :: Text -> Bool
isJustSchemePrefix prefix = case T.unsnoc prefix of
  Just (scheme, ':') -> isValidSchemeName scheme
  _ -> False

isValidSchemeName :: Text -> Bool
isValidSchemeName scheme = case T.uncons scheme of
  Just (leading, rest) -> isAsciiLower leading && T.all schemeChar rest
  Nothing -> False
  where
    schemeChar c = isAsciiLower c || isDigit c || c `elem` schemePunctuation

-- | The punctuation a scheme name may carry after its first character.
schemePunctuation :: [Char]
schemePunctuation = "+.-"

-- | Upstream @EvalState::checkURI@: nothing is checked unless
-- @restrict-eval@ is on; an allowed URI passes; a URI that is a path, or
-- a @file://@ URL, is checked against the allowed paths instead
-- (upstream passes the remainder through @CanonPath@, so a rootless
-- remainder is rooted, and one that spells a drive, @file://C:/...@, is
-- rooted already); anything else is refused.
uriAccess :: EvalPolicy -> AllowedPaths -> Text -> Either Text ()
uriAccess policy allowed uri
  | not (epRestrictEval policy) = Right ()
  | isAllowedUri (epAllowedUris policy) uri = Right ()
  | T.isPrefixOf "/" uri = pathAccess uri
  | Just rest <- T.stripPrefix fileUrlPrefix uri = pathAccess (rooted rest)
  | otherwise = Left (forbiddenUriMessage uri)
  where
    rooted rest = if isAbsolutePath rest then rest else "/" <> rest
    pathAccess path =
      let canonical = canonPathValue path
       in if isAllowedPath allowed canonical then Right () else Left (forbiddenPathMessage policy canonical)

fileUrlPrefix :: Text
fileUrlPrefix = "file://"

-- | Upstream's refusal from @checkURI@.
forbiddenUriMessage :: Text -> Text
forbiddenUriMessage uri = "access to URI '" <> uri <> "' is forbidden in restricted mode"