How KLUE Found an Axios Bug That Can Turn Your GET Into a DELETE
axios is the HTTP client that a huge number of JavaScript and Node projects reach for whenever they need to make a request. It is almost everywhere, which is exactly why we pointed KLUE, our security agent, at it. KLUE read through axios the way a careful engineer would, following a request from the moment you call it to the moment it leaves for the network. Along the way it found a bug in something most people never think about: how axios decides which HTTP method to send.
The short version is this. When you call axios without naming a method, it is supposed to default to GET. On the main axios object, it works out that default in a way that can be hijacked. If some other bug in your application has polluted Object.prototype, which is a common kind of JavaScript flaw, then an attacker gets to choose the method instead of you. A request you wrote as a harmless read can go out as a DELETE, a POST, a PUT, or a PATCH.
axios cannot cause this by itself. It needs a separate prototype pollution bug somewhere else in your app first. That is why we call it a gadget: code that is harmless on its own but hands real power to a bug that would otherwise be minor. Prototype pollution turns up often in JavaScript projects, and when it does, this is what takes it from "an attacker can set a property" to "an attacker can change what your HTTP calls actually do."
At a glance
| Component | axios, in the request path of the default instance (lib/core/Axios.js) |
| Vulnerability | Prototype pollution gadget: an inherited property can override the HTTP method |
| Affected versions | >= 0.27.2 and >= 1.0.0 |
| Fixed in | 0.34.0 and 1.20.0 |
| Precondition | A separate prototype pollution flaw already present in the application |
| Affected calls | axios.request({ url }) and axios({ url }) with no explicit method |
| Not affected | Named methods (axios.get and friends), an explicit method, or an axios.create() instance |
| Discovered by | KLUE, Shellvoide's autonomous security agent |
Which calls are affected
The strange part, and the reason this is easy to miss, is that it only bites some ways of calling axios. Write the request one way and an attacker can flip the method. Write it another way, two lines over, and nothing happens.
| How you call axios | After Object.prototype.method = 'DELETE' |
|---|---|
axios.request({ url }) | Sent as DELETE |
axios({ url }) | Sent as DELETE |
axios.get(url) | Still a GET |
axios.request({ url, method: 'GET' }) | Still a GET |
axios.create().request({ url }) | Still a GET |
If you always use axios.get, axios.post, and the other named methods, you were never exposed. If you use the shorthand and let axios fill in the method, you were. Most codebases do both, in different files, which is what makes a bug like this worth writing up.
How KLUE found it
KLUE does not scan for known patterns and it does not throw payloads at a running server. It reads code and reasons about it, one hypothesis at a time. Pick a place a bug could live, pull the exact code needed to check it, think it through in the open, then either confirm the bug or discard it with a reason.
Reading axios, KLUE was tracing how a request config becomes a real outbound request. When it reached the line that settles the method, it stopped on a habit that pays off constantly in JavaScript: any time a default is filled in with the a || b || c pattern, look hard at the middle option. In JavaScript, reading a property that does not exist on an object does not simply give you undefined. It searches up the prototype chain, and on a normal object the last place it looks is Object.prototype, which prototype pollution can write to. So the question KLUE asked was simple. Is that middle value read off a plain object, and if the property is missing, could it come from Object.prototype instead?
Here is the line that answers it. When axios dispatches a request, it picks the method like this:
// lib/core/Axios.js
// Set config.method
config.method = (config.method || this.defaults.method || 'get').toLowerCase();
In plain English: use the method on this request; if there is not one, use the instance default; and if there is not one of those either, use get. The 'get' at the end looks like a promise that nothing can go wrong. The problem is the middle piece, this.defaults.method, and what this.defaults actually is:
// lib/core/Axios.js
class Axios {
constructor(instanceConfig) {
this.defaults = instanceConfig || {};
It is a plain object. On the default axios instance, nobody ever sets a method on it, which is the exact situation the 'get' fallback exists for. But this.defaults.method does not stop at undefined just because no one set it. It keeps looking up the prototype chain, and if Object.prototype.method has been polluted, it finds a value there and returns it. That value is truthy, so the || chain stops on it and never reaches 'get'.
That last step is the one that made this real rather than a curiosity. Instead of stopping at "the method can be overridden," KLUE went and checked which ways of calling axios leave the method unset, because that is the difference between a scary-sounding note and an actual finding.
The answer came straight out of the order things happen in. The named methods like axios.get(url) set the method before the request is ever dispatched. By the time execution reaches that line, config.method is already 'get', so the first part of the || is truthy and this.defaults.method is never even read. The prototype gets no say. Passing the method yourself, as in axios.request({ url, method: 'GET' }), does the same thing. An instance made with axios.create() is safe for the same reason: its defaults belong to it, and the method still gets set or defaulted the normal way. The only calls that fall through to the tainted read are the ones that use the default axios object and leave the method out, axios.request({ url }) and axios({ url }).
The same mistake, one field over
Once KLUE understood the shape of the bug, it did the obvious next thing and looked for the same shape elsewhere in the file. It found it a few lines up, on the setting that decides whether axios will follow an absolute URL:
// lib/core/Axios.js
// Set config.allowAbsoluteUrls
if (config.allowAbsoluteUrls !== undefined) {
// do nothing
} else if (this.defaults.allowAbsoluteUrls !== undefined) {
config.allowAbsoluteUrls = this.defaults.allowAbsoluteUrls;
} else {
config.allowAbsoluteUrls = true;
}
Same tell. this.defaults.allowAbsoluteUrls is read off that same plain object, so a polluted Object.prototype.allowAbsoluteUrls passes the !== undefined check and feeds an attacker-chosen value into the branch that controls URL handling. It is the same bug wearing a different field name, which is why KLUE reported the pattern and both spots rather than just the one line. A fix for method alone would have left this one sitting right next to it.
Proof of concept
The proof is short, which is part of the point. Pollute the prototype the way an upstream bug would, then make an ordinary default call with no method, and watch it leave as a delete:
// with a small server running on 127.0.0.1:<port>
Object.prototype.method = 'DELETE';
try {
await axios.request({ url: 'http://127.0.0.1:<port>/resource' });
// server logs: DELETE /resource (you wrote what looks like a GET)
} finally {
delete Object.prototype.method;
}
Run the same thing with axios.get('http://127.0.0.1:<port>/resource') and the server logs a GET, unchanged, because the named method set the method before the tainted default was ever consulted. Put the two side by side and the whole bug fits on one screen: the shorthand call flips to DELETE under pollution, and the named call does not budge.
Impact
How bad this is depends entirely on the endpoint at the other end. A call the developer wrote and reasoned about as a safe read can be redirected to a method that changes state, and land on a route that behaves very differently for a DELETE or a POST than for a GET. Think of a request that was only ever meant to fetch a record arriving as a delete, or a read of a resource turning into a write against it. axios does not create the pollution and it does not choose the endpoint. What it takes away is the one thing a developer should be able to count on when they write a read: that it goes out as a read.
The allowAbsoluteUrls twin lands in a similar place. It governs whether axios will honor an absolute URL, which is exactly the kind of control an app leans on when it builds request URLs from a fixed base and a caller-supplied path. Flip it through the same inherited read and you weaken a check that was meant to keep requests pointed where the developer intended.
Mitigation
The real fix is upstream, and it is already out. Upgrade to axios 1.20.0 (or 0.34.0 on the 0.x line), which changes the method lookup so an inherited property is ignored. If you cannot upgrade right away, any one of these avoids the gadget:
- Use the named methods (
axios.get,axios.post, and so on), which always set the method explicitly. - Pass the method yourself:
axios.request({ url, method: 'GET' }). - Use an instance from
axios.create()rather than the default object.
And because this is only ever reachable through a separate prototype pollution bug, the most important thing is to make sure your app does not have one: audit any code that merges untrusted objects, and keep your other dependencies patched.
Under the hood, the upstream change is small. The default should come from a value the instance actually has, not one that only shows up because the prototype chain was polluted, so the lookup checks that this.defaults owns the property before trusting it:
// take the default from OWN properties only, never an inherited one
const ownDefault =
Object.prototype.hasOwnProperty.call(this.defaults, 'method')
? this.defaults.method
: undefined;
config.method = (config.method || ownDefault || 'get').toLowerCase();
With that check in place, a polluted Object.prototype.method is invisible to the lookup. An app with no pollution behaves exactly as before, and a polluted one falls through to 'get' the way it always should have. The same guard fixes allowAbsoluteUrls.
Why the usual tools miss this
Automated scanners look at requests and responses and match them against known-bad patterns. There is no pattern here to match. The request that triggers the bug, axios.request({ url }), is the most ordinary call in the library, and it looks completely normal, because the dangerous part is not in the request at all. It is in a write to Object.prototype that happened somewhere else in the app, possibly much earlier.
Static analysis has the same trouble. this.defaults.method is a perfectly normal property read, and (config.method || this.defaults.method || 'get') looks like careful defaulting, right down to the hard-coded 'get' at the end. Nothing about it flags as suspicious. The bug is not in the value or where it flows. It is in the fact that a missing property on a plain object quietly resolves through Object.prototype, and no tool marks an object as dangerous just for having a prototype.
Even a person reading this code has to fight the fact that the line is correct for almost every case. It only goes wrong on the default instance, where the method was never set and the property therefore falls through to the prototype. You have to be holding all of that in your head at once to think to ask the question. That is the kind of reading KLUE is built to do: work through the whole path, keep the details in mind, and ask the awkward question at the exact spot it matters. We were straight with the maintainers about the limits too, that axios cannot pollute anything on its own and an app with no pollution is untouched, because saying exactly what a bug can and cannot do is what earns a careful triage.
Disclosure
KLUE discovered the bug during an autonomous run against axios. We reported it to the maintainers with a working proof of concept showing the method flip on the default instance, the named methods and isolated instances staying safe for contrast, and the matching allowAbsoluteUrls read. It was reviewed and accepted, and a fix that limits the default lookup to own properties shipped in axios 0.34.0 and 1.20.0. Thanks to the maintainers for a quick, straightforward triage.
References
- axios source, the file this lives in:
lib/core/Axios.js - The axios project: github.com/axios/axios
- Background on the bug class: Prototype pollution (MDN)
- The check the fix relies on:
Object.hasOwn()(MDN)
Want a run like this against your own codebase? Book a time-boxed engagement at shellvoide.com/book, or reach us at info@shellvoide.com.