HTTP Parameter Pollution in Practice: Real Attacks and Fixes
Send the same parameter twice, and every layer of your stack may believe a different copy.
Most web code assumes a parameter arrives once. Nothing in HTTP says it has to. This request is perfectly legal:
GET /search?q=shoes&q=boots HTTP/1.1
So which one is q? It depends on who you ask. Your framework follows one rule, your proxy or firewall may follow another, and the internal service your app calls may follow a third. HTTP Parameter Pollution (HPP) lives in those disagreements: the attacker repeats a parameter so that the layer that checks the request and the layer that acts on it read different values.
Every platform answers differently
Here is what common platforms give you for ?a=1&a=2:
| Platform | Code | What you get |
|---|---|---|
| PHP | $_GET['a'] |
2, the last copy |
| ASP.NET | Request.QueryString["a"] |
1,2, both joined |
| Java Servlet | request.getParameter("a") |
1, the first copy |
| Python Flask | request.args.get('a') |
1, the first copy |
| Python Django | request.GET['a'] |
2, the last copy |
| Ruby on Rails | params[:a] |
2, the last copy |
| Node.js Express | req.query.a |
['1', '2'], an array |
| Go | r.URL.Query().Get("a") |
1, the first copy |
None of these is wrong on its own. The danger starts when two of them sit in the same request path.
Attack 1: Changing the account on a transfer
Many apps take user input and pass it on to an internal API. If the app builds that internal request by gluing strings together, the user can add parameters of their own.
// Node.js (Express): forwards a transfer to the internal payments service
app.post('/transfer', async (req, res) => {
const url = 'http://payments.internal/api/transfer' +
'?from=' + req.user.accountId +
'&to=' + req.body.to +
'&amount=' + req.body.amount;
await axios.post(url);
res.send('Transfer sent');
});
A normal user sends to=40021&amount=50, and the internal call is:
/api/transfer?from=10001&to=40021&amount=50
An attacker sends this instead, with the & and = encoded so the outer app keeps them inside to:
to=40021%26from%3D77777&amount=50
The body parser decodes the value, the app pastes it in, and the internal service receives:
/api/transfer?from=10001&to=40021&from=77777&amount=50
If the payments service runs on PHP, Django or Rails, it reads the last from. The money leaves account 77777, someone else’s account, while every log on the front end shows the attacker’s own account.
The fix: never build a query string by hand. Validate each value, let an encoder build the string, and stop letting the internal service take the paying account from a parameter at all.
const querystring = require('querystring');
app.post('/transfer', async (req, res) => {
const to = String(req.body.to);
const amount = String(req.body.amount);
if (!/^\d{5,12}$/.test(to) || !/^\d+(\.\d{1,2})?$/.test(amount)) {
return res.status(400).json({ error: 'invalid_input' });
}
const query = querystring.stringify({ from: req.user.accountId, to: to, amount: amount });
await axios.post('http://payments.internal/api/transfer?' + query);
res.send('Transfer sent');
});
querystring.stringify encodes & as %26, so injected text can never start a new parameter. The real fix sits in the payments service: the paying account should come from an authenticated identity, never from a value anyone can repeat.
Attack 2: Walking past a filter
Suppose a filter in front of a PHP app checks the search box for SQL injection, but it only reads the first q. PHP reads the last.
GET /search.php?q=shoes&q=%27%20UNION%20SELECT%20username,%20password%20FROM%20users--%20 HTTP/1.1
The filter inspects shoes and lets the request through. PHP hands the second value to this code:
<?php
$q = $_GET['q'];
$sql = "SELECT name, price FROM products WHERE name LIKE '%" . $q . "%'";
$rows = $db->query($sql);
The filter did its job on the value it saw. The database ran the value it never saw. HPP did not create this bug, the string-built SQL query did. HPP only walked it past the guard, which is why a filter can be a second lock but never the only one.
The fix: a prepared statement, so the value can never become SQL.
<?php
$q = isset($_GET['q']) ? $_GET['q'] : '';
$stmt = $db->prepare('SELECT name, price FROM products WHERE name LIKE ?');
$stmt->execute(array('%' . $q . '%'));
$rows = $stmt->fetchAll();
Then make the filter inspect every copy of every parameter, or reject repeated parameters before the request reaches the app.
Attack 3: Rigging the links on your own page
HPP also works in the other direction. The attacker injects a parameter into a link your page builds, and the victim’s own click delivers it.
A poll page builds its vote links from the poll ID in the address:
<?php $poll = htmlspecialchars($_GET['poll_id']); ?>
<a href="/vote?poll_id=<?php echo $poll; ?>&candidate=white">Vote for White</a>
<a href="/vote?poll_id=<?php echo $poll; ?>&candidate=green">Vote for Green</a>
htmlspecialchars stops HTML injection, so this looks safe. The attacker shares this link:
https://example.com/poll?poll_id=7%26candidate%3Dgreen
Now poll_id is 7&candidate=green. htmlspecialchars turns & into &, and the browser reads it straight back as &. Both buttons now point to:
/vote?poll_id=7&candidate=green&candidate=white
/vote?poll_id=7&candidate=green&candidate=green
If the vote handler runs on Java, Flask or Go, it takes the first candidate. Every visitor who arrived through the attacker’s link votes Green, whichever button they press.
The fix: check the value, then encode it for the URL before encoding it for HTML.
<?php
$poll = $_GET['poll_id'];
if (!ctype_digit($poll)) {
http_response_code(400);
exit;
}
$poll = htmlspecialchars(rawurlencode($poll));
?>
rawurlencode turns & into %26, so a value can never start a new parameter, even where you cannot restrict it to digits.
Attack 4: An array where you expected a string
Express turns a repeated parameter into an array. Code written for a string then does things nobody tested.
app.get('/api/users', async (req, res) => {
const email = req.query.email.toLowerCase();
const user = await User.findOne({ email: email });
res.json({ exists: Boolean(user) });
});
/api/users?email=a@example.com works. /api/users?email=a@example.com&email=b@example.com throws TypeError: req.query.email.toLowerCase is not a function and returns a 500. Outside production mode, Express sends the full stack trace, with your file paths, to the attacker. Quieter checks fail too: email.length now counts array items, and email.includes('@') now looks for an item equal to @.
The fix: check the type before anything else touches the value.
const email = req.query.email;
if (typeof email !== 'string') {
return res.status(400).json({ error: 'invalid_email' });
}
Stop it in one place
Most applications never need the same parameter twice. Where you do, name those parameters on purpose. Everywhere else, reject the request early, in one place, before any handler reads it.
Node.js (Express). Rejecting anything that is not a plain string also blocks objects such as ?email[$ne]=x:
// Rejects a request when a query parameter is not a single plain string.
function rejectPollutedQuery(req, res, next) {
for (const key of Object.keys(req.query)) {
if (typeof req.query[key] !== 'string') {
return res.status(400).json({ error: 'invalid_parameter' });
}
}
next();
}
app.use(rejectPollutedQuery);
Run the same check on form bodies parsed by express.urlencoded.
Python (Flask)
from flask import Flask, request, abort
app = Flask(__name__)
@app.before_request
def reject_duplicate_params():
for source in (request.args, request.form):
for key in source:
if len(source.getlist(key)) > 1:
abort(400)
PHP. PHP collapses duplicates before your code sees $_GET, so check the raw query string:
<?php
function has_duplicate_params($query) {
$seen = array();
foreach (explode('&', $query) as $pair) {
if ($pair === '') {
continue;
}
$parts = explode('=', $pair, 2);
$name = urldecode($parts[0]);
if (substr($name, -2) === '[]') {
continue; // an array parameter, repeated on purpose
}
if (isset($seen[$name])) {
return true;
}
$seen[$name] = true;
}
return false;
}
$query = isset($_SERVER['QUERY_STRING']) ? $_SERVER['QUERY_STRING'] : '';
if (has_duplicate_params($query)) {
http_response_code(400);
exit;
}
For form posts, run the same function on file_get_contents('php://input').
Java (Servlet filter). getParameterMap() merges the query string and the form body, so this also catches a parameter sent once in the address and again in the body:
import java.io.IOException;
import javax.servlet.*;
import javax.servlet.http.HttpServletResponse;
public class RejectDuplicateParamsFilter implements Filter {
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
for (String[] values : request.getParameterMap().values()) {
if (values.length > 1) {
((HttpServletResponse) response).sendError(HttpServletResponse.SC_BAD_REQUEST);
return;
}
}
chain.doFilter(request, response);
}
@Override
public void init(FilterConfig config) { }
@Override
public void destroy() { }
}
How to test your own app
Send each parameter alone, then twice in both orders, and compare:
curl -s "https://app.example.com/search?q=apple"
curl -s "https://app.example.com/search?q=orange"
curl -s "https://app.example.com/search?q=apple&q=orange"
curl -s "https://app.example.com/search?q=orange&q=apple"
If the last two responses differ, the app picks a copy by position. Write down which one wins. Then try the body, and a split between the address and the body:
curl -s -X POST "https://app.example.com/transfer?amount=1" -d "to=40021&amount=1000"
Then put an encoded separator, %26, inside a value. If it shows up as a new parameter in a link on the page or in a call to another service, you have found attack 1 or attack 3. In Burp Suite, Repeater makes all of this quick: duplicate the parameter, change one value, send.
Finally, map every layer the request passes through (CDN, firewall, load balancer, app, internal APIs) and note which copy each one reads. HPP lives wherever two of them disagree.
Checklist
- Reject repeated parameters by default. Allow them only where you name them.
- Never build a URL or query string by joining strings. Use an encoder.
- Encode for the URL, then for HTML, when user input goes into a link.
- In Node.js, check the type of every parameter before using it.
- Make every filter and proxy inspect every copy of a parameter, not just one.
- Take identity, such as whose account or which user, from the session, never from a parameter.
- Fix the bug underneath. HPP is usually how an attacker reaches SQL injection or a missing authorization check, not the flaw itself.
HPP is cheap to try and easy to miss, because every layer works correctly on its own. The weakness is the disagreement between them. Decide once what a repeated parameter means in your system, which for most apps is “rejected”, and enforce it before any code reads the request.