ecluse:ecluse-core
Safe HaskellNone
LanguageGHC2021

Ecluse.Core.Security.Limits

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

Response bounds

data Limits Source #

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

Instances details
Show Limits Source # 
Instance details

Defined in Ecluse.Core.Security.Limits

Eq Limits Source # 
Instance details

Defined in Ecluse.Core.Security.Limits

Methods

(==) :: Limits -> Limits -> Bool #

(/=) :: Limits -> Limits -> Bool #

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 maxBodyBytes; carries the configured ceiling.

TooManyVersions Int Int

The packument carried more than maxVersionCount versions; carries the count seen and the ceiling.

TooDeeplyNested Int

JSON nesting exceeded maxNestingDepth; carries the ceiling.

Instances

Instances details
Show LimitError Source # 
Instance details

Defined in Ecluse.Core.Security.Limits

Eq LimitError Source # 
Instance details

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 Left (BodyTooLarge …) -- fail-closed, never a truncated body. A zero or negative 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 Left (TooManyVersions count cap), otherwise the document unchanged so it threads through a parse pipeline.

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.