| Safe Haskell | None |
|---|---|
| Language | GHC2021 |
Ecluse.Core.Security.Limits
Contents
Description
Response-bound guards for the proxy's data plane.
Écluse parses whatever an upstream returns. This module provides the pure guard layer that prevents those steps from exhausting resources due to hostile or oversized input.
How much an upstream may cost: A Limits budget plus boundedRead (abort a
streamed body past maxBodyBytes) and checkVersionCount / checkNestingDepth
(reject an oversized or deeply-nested parsed document) bound algorithmic-complexity
DoS from a hostile or compromised upstream. Every limit fails closed: exceeding
one yields Left, never a truncated or partial result.
Synopsis
- data Limits = Limits {}
- defaultLimits :: Limits
- data LimitError
- boundedRead :: Monad m => Limits -> m ByteString -> m (Either LimitError ByteString)
- checkVersionCount :: Limits -> PackageInfo -> Either LimitError PackageInfo
- checkNestingDepth :: Limits -> Value -> Either LimitError Value
- withinNestingBudget :: Int -> Value -> Bool
Response bounds
Resource budget for a single upstream response. Every field is a hard
ceiling enforced fail-closed: exceeding one aborts with a LimitError rather
than returning a truncated or partially-parsed result. These bound the
algorithmic-complexity DoS a hostile or compromised upstream can inflict by
returning a huge or pathological document.
The metadata ceilings are layered. maxBodyBytes (through boundedRead) is the
primary, pre-decode bound: it caps the parse spend before aeson runs, so a
hostile body is aborted while still streaming and never reaches the decoder whole.
The post-projection maxVersionCount (checkVersionCount) is a __deliberate
defence-in-depth__ semantic backstop behind it -- it refuses an over-versioned
packument after projection, bounding per-version work the byte cap already keeps
finite.
Constructors
| Limits | |
Fields
| |
Instances
defaultLimits :: Limits Source #
Sane defaults for Limits. Generous enough for real registry documents and
tight enough to fail closed on pathological input: a 12 MiB metadata body, 100k
versions, and 64 levels of JSON nesting. Override per deployment as needed.
data LimitError Source #
Which Limits ceiling a response exceeded.
Constructors
| BodyTooLarge Int | The body exceeded |
| TooManyVersions Int Int | The packument carried more than |
| TooDeeplyNested Int | JSON nesting exceeded |
Instances
| Show LimitError Source # | |
Defined in Ecluse.Core.Security.Limits Methods showsPrec :: Int -> LimitError -> ShowS # show :: LimitError -> String # showList :: [LimitError] -> ShowS # | |
| Eq LimitError Source # | |
Defined in Ecluse.Core.Security.Limits | |
boundedRead :: Monad m => Limits -> m ByteString -> m (Either LimitError ByteString) Source #
Read a streamed body chunk-by-chunk, aborting as soon as the accumulated
size would exceed maxBodyBytes. Polymorphic over the producing monad so the
streaming fetch can run it in IO while tests drive it purely.
readChunk is a chunk producer following the http-client BodyReader contract:
each call yields the next chunk, and an empty ByteString signals end of
input. boundedRead pulls chunks until EOF and returns the concatenated body, or
stops at the first chunk that pushes the running total past maxBodyBytes and
returns -- fail-closed, never a truncated body. A
zero or negative Left (BodyTooLarge …)maxBodyBytes rejects any non-empty body. The bound is checked
before a chunk is retained, so memory never exceeds the limit plus one chunk.
checkVersionCount :: Limits -> PackageInfo -> Either LimitError PackageInfo Source #
Reject a parsed packument carrying more than maxVersionCount versions,
returning it unchanged when within budget.
Applied after a document is projected to PackageInfo but before
per-version rule evaluation, so the cost of evaluating rules over every version is
bounded by configuration rather than by what an upstream returns. Counts the
infoVersions map; on breach returns , otherwise the document unchanged so it threads through a parse
pipeline.Left (TooManyVersions
count cap)
checkNestingDepth :: Limits -> Value -> Either LimitError Value Source #
Reject a decoded JSON document nested deeper than maxNestingDepth,
returning it unchanged when within budget.
Run on the already-decoded Value -- after the parser has produced it, before
the document is projected to domain types -- so a pathologically nested payload is
refused before any deep domain traversal. It is therefore not the defence
against an unbounded structure: the structure is already bounded-by-body-size by
the time it reaches here, since the maxBodyBytes cap on the streamed read precedes
the decode (a body the parser never finishes reading never produces a Value). This
guard bounds the traversal cost of a within-size-but-deeply-nested document -- the
stack/CPU a recursive walk of it would spend -- which the body cap alone does not
bound (a small body can still nest deeply). Depth counts container nesting: a scalar
is depth 1, and each enclosing Object/Array adds one. An empty container
counts as a leaf (depth 1), since it forces no descent. Traversal short-circuits at
the first sub-tree to breach the ceiling, so a deeply-nested branch costs no more than
the ceiling to reject.
withinNestingBudget :: Int -> Value -> Bool Source #
True iff value nests no deeper than budget levels -- the depth predicate
checkNestingDepth decides against maxNestingDepth, exposed so a selective decode
that never materialises the whole Value (see
Ecluse.Core.Registry.Npm.SelectiveDecode) can bound each sub-tree it walks at the same
budget and so reproduce checkNestingDepth over the document exactly.
Depth counts container nesting: a scalar is depth 1, an empty container is a leaf
(depth 1, it forces no descent), and each enclosing Object/Array adds one.
Decrements per nested container and fails fast at zero, so a huge sub-tree is not fully
walked.