Skip to content

ESI.ts Security Model ​

ESI.ts is a library embedded in someone else's application, so it has one trust boundary: the consumer's code and configuration are trusted, and every byte that arrives from the network is not. The library's job is to stop network data, and end-user values passed through the consumer, from choosing where a request goes, which token it carries, whose cache entry is served, or what reaches a log.

This is the scope statement for reporters, scanners, agents and the maintainer. SECURITY.md says how to report and guides/SECURITY.md lists the controls; this document adds what both lack: who is trusted, what is claimed, what is disclaimed, and a closed set of triage outcomes. The machine-readable index of this document is guides/security-model.yaml; the prose is canonical and npm run security:model holds the two together.

The structure is distilled from the Apache Camel security model into seven moves:

  1. Name every role and give it a trust level.
  2. Draw the boundary and list the untrusted inputs, including what a trusted service reports back.
  3. State the claimed properties as a table, each with a severity and a test.
  4. State the properties not provided, and the false friends that look like one.
  5. List in-scope vulnerability classes and the out-of-scope positions.
  6. Keep a list of known non-findings and a closed set of dispositions, where an unclassifiable report forces a revision of this document.
  7. Publish a machine-readable index and gate it in CI, so the prose cannot drift from the code.

Trust model ​

The consumer is fully trusted and ESI is trusted only to hold data: what it sends back is input.

RoleTrustWhat follows
Maintainer and release pipelineTrustedChooses defaults and publishes. The library relies on them for safe defaults.
Consumer developer: application code, middleware, a custom fetch, token storage, loggerFully trustedMay set unsafeAllowCustomHost or validateResponse: false. Anything consumer code does is by design.
The consumer's end users, whose values the application passes as parametersUntrustedTheir values reach buildEndpointPath. This is the primary attacker.
A third party handing the application a tokenUntrustedClaims count only after verifyAccessToken, or after ESI accepts the token.
ESI and EVE SSOTrusted to hold data, untrusted for what they returnBodies, X-Pages, cursors, ETag, Expires, redirect targets, token responses and the JWKS are all input.
SDE download hostTrusted to hold data, untrusted for what it returnsArchive entry names, YAML content and the build number are input.
Network path attackerUntrustedHTTPS is enforced with no off switch.
Other code in the same processOutside the modelThe library is not a sandbox.

Untrusted inputs and where each one enters ​

InputEnters atMust never
Path and query parametersvalidatePathParam, validateQueryParam in src/core/util/validation.tsChange the route or the host
Response bodyZod schemas under src/schemas, when validateResponse is onBe returned as a typed value it does not match
X-Pages and cursor tokenssrc/core/pagination/, src/core/util/headersUtil.tsDrive unbounded work, or be sent anywhere but back to the same host as an encoded query value
ETag, Expiressrc/core/cache/Serve one identity's entry to another
JWT claimssrc/auth/jwt.ts, src/auth/jwks.tsBe believed before the signature or ESI's acceptance
Redirect targetThe fetch in src/core/requestPipeline/fetchExecution.tsCarry the token or the request off the allowlisted host
SDE archive and build infosrc/sde/ingestion/Write outside the directory the caller named
URLs and error textsanitizeUrl, src/core/logger/redactLog.tsPut a credential in a log line or an error

Adversary ​

The attacker controls parameter values, any token they hand over, and whatever ESI, SSO or the SDE host can be made to return. The attacker does not have code execution in the consumer's process, the consumer's configuration, its token storage or its filesystem. A report that needs one of those is out of model.

Security properties claimed ​

Eleven properties are claimed for the default configuration. A property the index marks status: gap is claimed but not yet held: its gap is recorded under Rollout and open gaps with an issue and an expiry date, and a report that reaches it is triaged against that gap. Severity is the tier a violation starts at, not a score.

IDPropertyA violation looks likeSeverityVerified by
P1A token goes only to the allowlisted HTTPS host, and only on endpoints whose definition sets requiresAuthAn Authorization header on a public endpoint or on another hostCriticaltests/tdd/core/security.test.ts, npm run validate:auth-scopes
P2An untrusted parameter cannot change the route or the host.., ?, # or @ in a parameter reaching a different ESI routeHighsecurity.test.ts, tests/fuzz/parameter-fuzz.test.ts, feature 0059-path-parameters
P3A token never appears in a log line, an error, a URL or a cache keyA bearer token or refresh token in output a user would paste into a bug reportHighCHARTER SEC-02 evidence, tests/tdd/core/redactLog.test.ts, feature 0058-logging
P4One identity is never served another's cached body or ETag, and an unverified claim grants nothingA forged token naming a character reads that character's cacheCriticaltests/tdd/core/cacheKey.test.ts, tests/fuzz/cache-key.property.test.ts
P5verifyAccessToken accepts a token only with an RS256 signature from EVE SSO's key set, the right issuer and audience, and an unexpired expAlgorithm confusion, a missing audience check, a key from another URLCriticaltests/tdd/auth/jwks.test.ts, tests/tdd/auth/jwt.test.ts
P6Values a response supplies (cursor, page count, redirect target) never select a host and never carry the token off the allowlisted hostA redirect or cursor that sends a request elsewhereHighFeature 0062-security-boundary (@sec-P6)
P7With validation on, a body that does not match its schema is an error, never a typed valueDrifted or hostile JSON returned as a typed objectMediumFeature 0053-runtime-validation, tests/fuzz/schema-fuzz.test.ts
P8A count reported by the remote bounds no more than 1,000 page requests, at every pagination entry pointX-Pages: 100000000 starting that many requestsMediumFeature 0062-security-boundary (@sec-P8), paginationOrchestration.test.ts, PaginationHandler.test.ts
P9SDE ingestion writes only inside the directory the caller namedAn archive entry landing outside the output directoryHighFeature 0062-security-boundary (@sec-P9), tests/tdd/sde/ingestion/SdeExtractor.test.ts
P10The token file is written atomically with mode 0o600A world-readable token file, or a torn writeMediumtests/tdd/auth/FileTokenStorage.test.ts
P11The published tarball is what this repository's workflow built: provenance, cosign bundle, SBOMA release asset with no verifiable originHighrelease.yml, tests/tdd/workflows/workflow-permissions.test.ts (SEC-03 to SEC-06)

All eleven are claimed with zero security configuration. A violation reachable only after the consumer sets an unsafe option is out of model. P6, P8 and P9 had gaps when this document was first read against the code; they were closed by #629, and the index holds no gap today.

P12 is retired, not renumbered: it claimed that SDE content never altered the structure of the SQL SdeDatabaseBuilder built. The builder, the only code in the library that built SQL, was removed after 12.2.0 with the better-sqlite3 optional peer, so there is nothing left for the property to hold. The id is not reused. A report against a release that still ships the builder (12.2.0 and earlier) is triaged against P12 as it stood there.

Properties not provided ​

Listing what is not claimed is what makes the scope a closed set: a report that says "feature X failed to do Y" can be answered with "X was not built to do Y".

  • D1 · No sandbox for consumer code. Middleware, interceptors, a custom fetch, a custom token storage and a custom logger see every request, response and token.
  • D2 · No availability guarantee. The rate limiter and circuit breaker protect ESI from the client. Beyond the page cap in P8, resource use under hostile responses is the consumer's to bound.
  • D3 · No protection once an opt-out is set. unsafeAllowCustomHost: true removes the host check; validateResponse: false removes P7.
  • D4 · No sanitisation of strings ESI returns. Character names, mail bodies and descriptions are returned as received. Escaping them for HTML or SQL is the consumer's job.
  • D5 · No encryption at rest. FileTokenStorage is plain text protected by file mode only.
  • D6 · No redaction of secrets the consumer adds. Redaction covers a fixed list of URL query parameters, not headers or bodies a consumer builds.
  • D7 · No shield against a dependency advisory that this code does not reach. The audit gate decides exploitability; the advisory belongs upstream.
  • D8 · No constant-time comparison.
  • D9 · No integrity check on the SDE archive. CCP publishes no digest for the export, so the download is trusted to hold data like ESI is; P9 bounds where its entries land, and the download is bounded in size.

False friends ​

  • decodeAccessToken decodes, it does not verify. Only verifyAccessToken checks a signature.
  • The host allowlist is checked once, on the base URL at construction. It says nothing about where a redirect leads; P6 is held by refusing redirects in the fetch itself.
  • sanitizeUrl is a redactor for eleven named query parameters. It is not a general secret scrubber.
  • validatePathParam protects the route. It does not make a value safe for the consumer's own use.
  • Zod validation detects drift. Passthrough keeps unknown fields, and a well-shaped value can still be false.
  • requiresAuth is generated from the specification. A wrong flag is a token leak, which is why validate:auth-scopes exists.
  • The Scorecard number measures pipeline hygiene. It makes no statement about runtime behaviour.

Scope ​

A report is in scope when it shows the library, in its default configuration, letting an untrusted input break one of P1 to P11.

In-scope classes ​

  • Token exfiltration. A token sent to a public endpoint, another host, a redirect target, a log, an error or a cache key (P1, P3, P6).
  • Route or host injection. A parameter, cursor or header value that changes where a request goes (P2, P6).
  • Cross-identity disclosure. A cached body, ETag or in-flight response served under the wrong identity, including through state shared by two clients in one process (P4).
  • Verification bypass. Any way verifyAccessToken accepts a token EVE SSO did not sign for this client (P5).
  • Path traversal. An archive entry or storage path escaping its directory (P9, P10).
  • Insecure default. Any of the above reachable by constructing a client and making a call, with no option set.
  • Published artefact integrity. A release whose provenance, signature or SBOM does not verify (P11).

A guard that exists in the source but is not on the live path counts as absent. A guard applied at one entry point and not at its sibling is a gap, not a configuration difference.

Out of scope ​

  • Anything consumer code does: middleware, interceptors, a custom fetch, a custom storage.
  • Behaviour that needs unsafeAllowCustomHost: true or validateResponse: false.
  • Denial of service by volume or response size, beyond the page cap in P8.
  • Vulnerabilities in ESI or EVE SSO; report those to CCP Games.
  • Cross-site scripting in a consumer application that renders ESI strings unescaped.
  • Query injection. The library builds no SQL since the release after 12.2.0 (P12, retired); SQL a consumer builds from ESI or SDE strings is the consumer's to escape (D4).
  • An advisory in a development dependency, docs-site/ or examples/ that never ships in the tarball.
  • The local scripts under scripts/auth/, which use a loopback callback by design.
  • Versions 7.x and 8.x, which are unsupported.

Triage ​

Every report, scanner finding or agent review ends in exactly one of nine dispositions, each citing a section of this document. Closed reports are logged one line each in guides/security-triage-log.md.

DispositionMeaningLicensed by
VALIDAn untrusted input breaks a claimed property in the default configuration. Fixed, released, published as a GitHub Security Advisory.Security properties claimed, In-scope classes
VALID-HARDENINGNo claimed property is broken, but the default is tightened anyway. Shipped with a changelog entry, usually no advisory.Maintainer discretion
OUT-OF-MODEL: trusted-inputNeeds control of consumer code or configuration.Trust model
OUT-OF-MODEL: adversary-not-in-scopeNeeds a capability the adversary section excludes.Adversary
OUT-OF-MODEL: opt-inAppears only with an unsafe option or validation turned off.Out of scope
OUT-OF-MODEL: unsupportedAffects only 7.x, 8.x, examples or the docs site.Out of scope
BY-DESIGN: property-disclaimedConcerns a property this document says is not provided.Properties not provided
KNOWN-NON-FINDINGMatches an entry in the list below.Known non-findings
MODEL-GAPFits none of the above. The response is a pull request against this document, not an ad hoc call.This section

An incomplete fix is a new finding with its own advisory. "Already fixed in an earlier release" is not a disposition: the question is whether the path in the new report is closed.

Known non-findings ​

  • N1 · "unsafeAllowCustomHost allows SSRF." A named opt-in whose name is meant to show in code review.
  • N2 · "decodeAccessToken does not verify the signature." Documented. The cache counts a claim only after ESI accepts the token.
  • N3 · "The cache key uses a truncated SHA-256." It partitions a cache; it authenticates nothing. A finding needs a demonstrated collision that crosses identities.
  • N4 · "The token script listens on http://localhost." A loopback PKCE callback in a developer script.
  • N5 · "A token is stored in .env." Git-ignored local state; .env.example holds no values.
  • N6 · "Zod passthrough accepts unknown fields." By design, so new ESI fields do not break consumers.
  • N7 · "Dependency X has an advisory." In scope only if X is pino or zod, or an optional SDE peer, and the vulnerable path is reachable from library code.
  • N8 · "The library does not rate-limit hostile input." Availability is disclaimed.

This list is written to be handed to a scanner or an agent as a suppression set; guides/security-model.yaml carries it as non_findings.

Technical implementation ​

The model becomes rigid through seven additions that reuse machinery the repository already has: the spec audit's fixture pattern, the audit-exception expiry rule, the charter, and ci-success. All seven are in place; Rollout and open gaps records the order they landed in.

AddWhat it does
guides/SECURITY-MODEL.mdThis document. The prose is canonical.
guides/security-model.yamlDerived index of roles, inputs, properties, disclaimed properties, non-findings and dispositions. Read by the audit script, scanners and agents.
scripts/quality/security-model-audit.ts, run as npm run security:modelThe gate. Negative fixtures under tests/tdd/scripts/fixtures/security-model/, one per rule below.
tests/bdd/features/core/0062-security-boundary.featureOne Rule per property it holds, in the unwanted-behaviour form.
tests/tdd/core/untrusted-input-sites.test.tsDiscovers every network call and every response-header read under src/ and fails when one is missing from the index.
guides/security-triage-log.mdOne line per closed report: date, source, disposition, section cited.
CHARTER rows SEC-09 to SEC-12, and a security-model job in the needs of ci-successMakes the four rules below requirements with a status.

What the audit fails on ​

  • A property in the prose table that is missing from the index, or the reverse, or a different severity (SEC-09).
  • A claimed property with no Rule tagged with its id in the feature files, no existing test file, or a gap with no issue, no expiry date, or an expired date; the count of gaps may only shrink against master (SEC-10).
  • A Rule that claims a disclaimed property.
  • A network call site or response-header read in src/ that the index does not list, or an input with no hostile-value generator (SEC-11).
  • A non-finding whose cited section does not exist, or a triage-log line whose disposition is outside the closed set (SEC-12).

For agents and scanners ​

AGENTS.md and CLAUDE.md carry one paragraph: review security against guides/security-model.yaml, treat non_findings as suppressions, and end every finding with a disposition from the closed set.

Rollout and open gaps ​

Reading master f527e88 (v11.2.0) against the model found three gaps. Each got a failing scenario before its fix, and all three are closed (#629). A gap found later is recorded here and in the index with status: gap, an issue and an expiry date.

#GapWherePropertyHandling
1X-Pages is read with no cap in the async iterators, while two other paths cap at 1,000src/core/pagination/AsyncPaginationIterator.tsP8VALID-HARDENING: one shared bounded page count used by every path
2No fetch in src/ sets a redirect option, so redirects are followed; the allowlist is checked on the base URL onlysrc/core/requestPipeline/fetchExecution.tsP6Refuse redirects. Node's fetch drops Authorization on a cross-origin redirect, so this is hardening: the request, not the token, would have left
3The SDE download has no size bound, and extractAll hands entry names to adm-zip with a peer floor of 0.4.11src/sde/ingestion/SdeDownloader.ts, SdeExtractor.tsP9Check every entry name against the output directory before extracting, with a hostile-archive fixture; bound the download size

SdeDatabaseBuilder read clean against the query-injection class (P12): values were bound through prepared statements and column names quoted with doubled quotes. The builder was removed after 12.2.0 and P12 retired with it.

Rollout, tracked by #627:

  1. Land this document, the index, a link from SECURITY.md and the agent pointer. Documentation only. Done in #628.
  2. Fix gaps 1 to 3, each with its Rule in feature 0062. Done in #629.
  3. Land the audit script, its fixtures and the input-site test, add security-model to ci-success, and add SEC-09 to SEC-12 to the charter as Enforced. Done, with step 4.
  4. Point the nightly payload fuzz and the fault catalogue at the input registry, so every registered input has a hostile-value generator. Done: each input in the index lists its generators (fuzz and unit test files, the nightly payload fuzz, and fault:<id> entries of the fault catalogue, which gains x-pages-beyond-the-cap and redirect-to-another-host); the audit fails an input with none, and the catalogue's self-test fails a reference to a fault it does not hold.

Released under the GPL-3.0-or-later license.