What Maintaining a Forked npm Package Actually Buys

CodeMegapixel99/CAS-Authentication-User

cas-authentication-user is my fork of cas-authentication, which is kayleecodes1’s library. The four middleware functions it exposes (bounce, block, bounce_redirect, logout) are their design and their names, and the fork still answers to all four; what I have added since 2019 is eighteen published versions on top of that. So when I describe the fork’s value I reach for the word “maintained”, and I had assumed that word was carrying a broad load: dependency upkeep, protocol coverage, Express 4 compatibility, the accumulated weight of eighteen releases against one.

Measured, it is one digit.

$ npm i cas-authentication && npm audit
xml2js  <0.5.0
Severity: moderate
xml2js is vulnerable to prototype pollution - https://github.com/advisories/GHSA-776f-qx25-q3cc
No fix available

2 moderate severity vulnerabilities
$ npm i cas-authentication-user && npm audit
found 0 vulnerabilities

Both installs are five packages. Both packages declare exactly one runtime dependency, and it is the same dependency. Upstream asks for xml2js: ^0.4.8 and resolves to 0.4.23; the fork asks for ^0.6.2 and resolves to 0.6.2. That single character position is the whole of the difference the audit can see.

Why npm says there is no fix

No fix available is the interesting line, because it is false as a statement about the world and exactly right as a statement about the range. xml2js patched the prototype pollution in 0.5.0, and 0.5.0, 0.6.0, 0.6.1 and 0.6.2 have all been published. Four fixed versions exist. The caret range can reach none of them.

A caret range means “no breaking changes”, and npm’s rule for what counts as breaking changes below 1.0.0 treats the minor as the major: ^0.4.8 expands to >=0.4.8 <0.5.0, so the upper bound sits at precisely the version where the fix landed. Off by one minor, permanently. Had xml2js been at 1.4.8 and shipped the same patch as 1.5.0, ^1.4.8 would have picked it up on the next clean install and nobody would have needed a fork; the identical caret, under 1.0.0, walls it off.

This is why the audit reports no fix rather than telling you to upgrade. npm resolved the range correctly, found the newest version inside it, and found that version vulnerable. There is nothing it can suggest without violating the constraint the package author wrote, so it declines to suggest anything, and a moderate advisory becomes a permanent property of installing that package.

Nobody did anything wrong

Upstream’s last commit and its last publish are the same day, 2015-11-09. The advisory, GHSA-776f-qx25-q3cc, was published 2023-04-05, which is more than seven years later. There was no window in which the author could have seen this and shrugged; the package had been finished for seven years before the vulnerability had a name. ^0.4.8 was an ordinary thing to write in 2015, and it stayed correct for eight years, and then a security fix arrived in a minor bump instead of a patch bump and the range that had always been right became the reason the fix could not arrive. The repository is not archived and has 72 stars and 19 open issues, all of which is what a useful small library looks like after its author moves on.

What that means for anyone reading an npm page is worth stating plainly, because I had the wrong instinct here too. I would have said the risk in an old package is the accumulated absence of attention. The mechanism is narrower and much less forgiving than that: a dormant package’s ranges keep resolving, so it silently inherits every advisory filed against anything its bounds cannot escape. Dormancy is not what makes it vulnerable. Dormancy plus a hard upper bound is, and the upper bound is often invisible because a caret looks permissive.

What the fork is actually worth

Two figures, and I want to be careful about what they support. cas-authentication-user does 2,086 downloads in the last month against upstream’s 614, so the fork has about 3.4x the installs of the package it forked. I cannot show that the advisory caused that. Both numbers include CI, mirrors and dependency-graph traffic, npm’s download counts do not distinguish a human choosing a package from a lockfile reinstalling one, and I have no evidence about how any of those 2,086 arrived. The gap is real and its cause is unmeasured.

The part I can support is narrower. Upstream’s 614 installs a month each land a moderate advisory that npm declares unfixable, and one dependency range is the entire reason. Every other difference between the two packages is invisible to npm audit and always will be: 0.3.0 is 720 lines to upstream’s 376, and what it adds on top of upstream’s four CAS protocol versions is gateway mode, a login endpoint, TypeScript declarations, 202 tests, and a Passport strategy that reuses the same validation path rather than reimplementing it. The audit measures one thing, and on that one thing the eighteen releases reduce to a version bump that anyone could have made.

The vulnerability the audit could not see

There is a second security difference between these packages, and it is larger than the first one, and no audit has ever reported it in either direction.

Upstream reads returnTo off the query string and redirects to it without checking where it points. My fork inherited that line in 2019 and shipped it until 0.3.0 went out yesterday. That is an open redirect on a login route, which is the placement that matters most, because the flow it enables sends a victim through a genuine CAS login and then lands them wherever the link’s author chose. My own README now says so in the upgrade notes; it did not say so for seven years, because I did not know.

The point is what npm audit reported during those seven years:

$ npm i cas-authentication-user@0.2.4 && npm audit
found 0 vulnerabilities

Zero. The same clean bill of health I quoted at the top of this post as the fork’s advantage, on a version with an unvalidated redirect in it. The dependency range was already correct in 0.2.4, and that is the entire question the audit asks. Nobody had filed an advisory against the package’s own code, so there was nothing in the database to match, and a tool that resolves declared ranges against a list of published advisories cannot find a bug that nobody has published.

So the honest ordering of the two findings is the reverse of the one I started with. The unfixable dependency range is real and worth the fork; it is also the smaller problem, and it is the only one of the two that any tooling was ever going to tell me about.

Credit, which was missing until today

Until today my README did not say this package was a fork. It named no upstream, no author and no origin, and the description field in my package.json is upstream’s sentence, unchanged since 2015. That was my omission and not a subtle one, and I only noticed it while writing this post.

It now has an Origins section recording the division: the four middleware entry points, ten of the option names, the dev-mode design, all four CAS protocol versions with their separate endpoints and parsers, and the structure that resolves endpoint and parser together in the constructor are all upstream’s work. Gateway mode, login, the Passport strategy, the declarations, the tests and the 0.3.0 changes were added here, and two of those are repairs to behaviour I inherited rather than features.

The part that generalises

Both findings are the same mistake, made in opposite directions, and the mistake is mine rather than the tool’s. npm audit asks one narrow question with complete precision: do the versions your declared ranges resolve to appear in a database of published advisories. I read the answer as though it were a broad one, in both of the ways that go wrong.

No fix available does not mean nobody has fixed it. It means the range cannot get there from here, and on a package whose author has moved on, nobody is going to widen it. That is why a publish date is the wrong thing to check: the date tells you about attention, the range tells you about reachability, and only reachability decides whether a fix can arrive. A package published last week with ^0.4.8 in it has the same problem as one published in 2015.

found 0 vulnerabilities does not mean the code is sound. It means nothing your ranges resolve to has been reported yet, which on a small package with no security researchers reading it is close to guaranteed regardless of what the code does. My 0.2.4 sat there for years returning zero with an open redirect in the login path, and it would have kept returning zero indefinitely, because the number describes the advisory database rather than the package.

So the useful pair of questions is not “is this maintained”. For each dependency: does the declared range have an upper bound below the version that fixes its known advisories, so that escaping means editing the range rather than the lockfile. And for the package itself: given that nobody has audited this code, what would I find if I read the places where it hands user input to something that acts on it. The first question a tool can answer. The second one is the one that found the redirect, and I only asked it because I sat down to write about the first.