The security risks of front-end dependencies
Front-end apps are built with hundreds of dependencies, each one a potential risk. This year alone, thousands of JavaScript vulnerabilities were reported, yet many developers still underestimate the security risks of dependencies in front-end apps. These dependencies introduce unique risks distinct from those faced in server-side applications. It's crucial for professional developers to understand and address their potential impact.
In this article, we'll dive into these risks and explore their specific impact on front-end projects. The key concerns are:
- Unintentional security vulnerabilities
- Deliberately introduced malicious code
- Install scripts
- Bundling and transpilation
- Package (maintainer) trustworthiness
- Sub-dependencies
Finally, we'll wrap up with a set of recommendations to help you mitigate these risks in your own projects.
Unintentional security vulnerabilities
The most common security risks come from unintentional bugs that create vulnerabilities. Most of these issues will (eventually) get reported in a security advisory, which you can monitor via automated tools like GitHub, GitLab, npm, or yarn. Most of those can be resolved simply by updating to the latest version.
Monitoring and resolving these is the least you can do, but don't assume it's enough to keep your app secure.
exploited via the network, these have zero impact. The rest are usually either ReDoS or only an issue if the package is used in a server context. There are few vulnerabilities that actually matter for the front end. But since assessing the real impact of vulnerabilities is tricky, your safest bet is to resolve them all.
Most front-end security advisories are boring (i.e. unimpactful). A large portion affects development tooling, and unless they can beDeliberately introduced malicious code
The scariest kind of vulnerability is when malicious code is deliberately added to a package—whether by a lone hacker, a group, or even state-sponsored attackers. This turns an otherwise useful dependency into a Trojan Horse capable of almost anything: hijacking your codebase, stealing data, installing ransomware, or worse.
For purely front-end apps, the risks are less severe since the code runs sandboxed in the user's browser without privileged access to your dev machine or servers. But malicious packages can still:
- Steal cookies or sensitive user data
- Log user credentials or credit card information
- Install cryptominers in the background
- Inject ads or unwanted (political) messages into your app
These attacks are harder to mitigate. HTTP-only cookies can prevent theft of cookie data, and a well-configured Content Security Policy (CSP) helps block data exfiltration and unauthorized external scripts. However, a CSP cannot stop harmful code bundled directly into your app-- even if you use nonces.
The risks are higher for apps using SSR and SSG, where dependencies execute on the server. Here, malicious code may get access to everything on your server, giving attackers access to critical data such as your entire database.
Detecting Trojan Horses
You wouldn't detect a really well-made Trojan Horse easily. A skilled attacker might:
- Obfuscate the malicious code
- Trigger it only under specific conditions (e.g., every 100th request or two weeks after installation)
- Only run it in CI
- Limit its behavior to production builds
By the time a security advisory is published, the damage may already be done.
xz
was almost compromised by a backdoor. It was sheer luck that the right person found it at all. Hackers are only getting smarter, and next time we'll probably not be so lucky. (There's a lot of history in the npm ecosystem)
Install scripts
Install scripts are another often overlooked attack vector. These scripts run automatically when you install a dependency (or a sub-dependency) and can do anything on your machine-- just like if you were to run unverified software from the internet. Maybe it just builds a C-executable to speed up CSS compilation, or maybe it executes or installs malware.
It's easy to imagine what could happen if you installed random unverified software from the internet. Yet many developers install npm packages without a second thought.
Mitigation
Disabling install scripts is fairly straightforward, all you need to do is add ignore-scripts true
to your .yarnrc
or ignore-scripts=true
to your .npmrc
. But this will unfortunately probably be a bit more involved with dependencies that rely on install scripts. You can find out which these may be by running a tool like Can I Ignore. Essential build scripts can sometimes be run manually, or you can add them to your "start" script and in CI.
npx
script is equally as risky since they have full access to your machine. I like to run these scripts in a Docker container to minimize the security risk*.docker run -v "$PWD":/usr/src/app -w /usr/src/app node:20 npx can-i-ignore-scripts
Bundling and transpilation
Another challenge lies in how packages are bundled and transpiled. Most packages are written in a modern language like TypeScript or ESNext, but in order to be widely usable, they get transpiled to an older version of JavaScript. Sometimes they're even minified/obfuscated. As a result, the code you get when you install a package usually does not match the code that you can inspect on GitHub.
It's almost impossible to detect subtle malicious changes hidden in a bundled package. They won't show up in commit messages, GitHub diffs, changelogs, or dependabot updates. The only way to verify a dependency is by inspecting the (often hard-to-read) code in the published package, which is rarely feasible.
Package (maintainer) trustworthiness
A common way to mitigate the risk of malicious code is to look into the trustworthiness of the package maintainers. If the package looks trustworthy and a lot of people use it, it's probably safe to use. Some go further and look into issues, how the maintainers respond to them, and other projects the developer has worked on.
A common rule of thumb I hear is to only use packages with a certain amount of stars on GitHub. Unfortunately, stars on GitHub are easy to game and don't reflect real-world usage or trustworthiness. As anything can be gamed, it's hard to determine trustworthiness based on anything other than the package code itself.
We need better ways to determine trustworthiness.
Even if you were to check the trustworthiness of package managers when you first select a package, the maintainers of a package rarely stay the same forever. You would have to check again before every update.
Sub-dependencies
The risks don't stop with your direct dependencies. Most front-end projects rely on dozens of direct dependencies, but each of those often comes with a long chain of sub-dependencies. Every sub-dependency carries the same security risks.
Attackers know that direct dependencies are more visible and likely to be scrutinized. That's why they target sub-dependencies. While you might review the code or maintainer history of a direct dependency, doing the same for every sub-dependency is rarely feasible.
This creates a compounding problem: trusting a single dependency means implicitly trusting its entire chain. One dependency hidden deep in your dependency tree, can compromise your app.
Recommendations
Every dependency in a front-end app brings security risks with it. Some risks can be mitigated, such as through a Content Security Policy (CSP) and disabling install scripts, other mitigations, such as analyzing maintainers and package code, would require such a tremendous amount of effort that they are usually not feasible.
The real solutions will need to happen at the platform level such as a module-level permissions system (experimental) and denying install scripts by default (RFC).
Until then, my recommendations to mitigate these risks are:
- Set up automatic security advisory alerts
- Disable install scripts
- Set up a Content Security Policy
- Use as few packages as possible
- Copy the code for small utility functions instead of adding a new dependency
- Remove small, abandoned, and/or untrustworthy dependencies
- Avoid obfuscated packages
- Increase the barrier of adding new dependencies to your project
- Keep dependencies (reasonably) up-to-date so that it's easy to update when a security advisory is published
- Delay dependency updates until they're widely used
So far, most developers and projects have been lucky. But luck isn't a security strategy. It's only a matter of time before a major dependency-related incident happens. With these recommendations, you're much less likely to be affected.
Don't forget your CI tooling. For example, GitHub Actions automatically updates to the latest version of actions by default. These can inject nasty stuff into your builds as well, and their code is even less visible.