DEV Community

Stefan
Stefan

Posted on • Originally published at codereviewlab.com

Fix HTTP Parameter Pollution: Spring Boot REST API Code Review

Fix HTTP Parameter Pollution: Spring Boot REST API Code Review

A Spring Boot controller binding ?role=user&role=admin to a plain String will quietly take the last value, or the first, depending on the servlet container. That non-determinism is the attack surface. Proxies strip, reorder, or concatenate duplicates differently from Tomcat, so an attacker who knows your stack can craft a request where your WAF sees role=user and your controller sees role=admin. No injection required, just a second query parameter and a server that does not reject it.

How HTTP Parameter Pollution Breaks Spring Boot Endpoints

The core problem is that HTTP has no spec-level rule about duplicate parameter names. RFC 3986 allows it. What happens next is implementation-defined, and every layer in your stack makes its own choice.

Tomcat's request.getParameter("role") returns the first occurrence. request.getParameterValues("role") returns all of them. Spring MVC's @RequestParam String role uses getParameter, so it takes the first. A List<String> binding takes all of them. A raw MultiValueMap<String,String> keeps everything. If your WAF or Nginx proxy is configured to forward only the last value, you now have a mismatch an attacker can exploit.

The reason this is hard to spot in review is that no single line of code is wrong. Each layer behaves correctly according to its own documentation. The vulnerability lives in the seam between two correct implementations that disagree. A Tomcat-first-value policy and an Nginx-last-value policy are each defensible in isolation; chained together they produce a request where the security control and the business logic read different values from the same parameter name. That is the entire bug class in one sentence, and it is why grepping for a single bad function call will never find all instances.

Here's what that looks like in a request and then in the controller:

// GET /api/orders?status=pending&status=approved
// Attacker's intent: WAF evaluates "pending", controller sees "approved"

@RestController
@RequestMapping("/api/orders")
public class OrderController {

    @GetMapping
    public List<Order> getOrders(@RequestParam String status) {
        // Spring calls request.getParameter("status"), returns first value "pending"
        // BUT: some proxy configs forward only the last value to Spring
        // Result depends on which layer is upstream and its duplicate-handling policy
        return orderService.findByStatus(status); // no validation, no rejection
    }
}
Enter fullscreen mode Exit fullscreen mode

The same ambiguity shows up in form POST bodies and in @ModelAttribute binding. If a form submits role=user&role=admin and your DTO has a String role field, Spring's DataBinder picks one silently. Worse, the choice is not stable across Spring versions: binding behavior for collection-to-scalar coercion changed subtly between Spring 5.2 and 5.3, so an audit you did two years ago may no longer describe how your current container resolves duplicates. Pin the behavior with a test, not with a memory of how it used to work.

This is not a theoretical concern. CVE-2021-22112 (Spring Security privilege retention on authentication) and the broader history of WAF bypass research show that ambiguity between proxy and origin is a repeatable, exploitable pattern, not an edge case that only matters in CTFs.

The OWASP HTTP Parameter Pollution guidance covers the cross-framework version of this problem, and the HTTP Parameter Pollution lesson on Code Review Lab walks through several Spring-specific bypass scenarios worth reading before you start a controller audit.

The Fix: Strict Parameter Binding and Single-Value Enforcement

The most reliable fix is a servlet filter that runs before any controller code and rejects requests that contain duplicate parameter names. Validation annotations in the controller are a second layer, not a substitute for rejecting early.

import javax.servlet.*;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.Map;

@Component
@Order(Ordered.HIGHEST_PRECEDENCE) // runs before Spring Security, before controllers
public class DuplicateParameterFilter implements Filter {

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
            throws IOException, ServletException {

        HttpServletRequest httpReq = (HttpServletRequest) request;
        HttpServletResponse httpResp = (HttpServletResponse) response;

        Map<String, String[]> params = httpReq.getParameterMap();
        for (Map.Entry<String, String[]> entry : params.entrySet()) {
            if (entry.getValue().length > 1) {
                // Reject before any controller code runs. Spring's @RequestParam
                // will already have chosen a winner by the time an interceptor fires.
                httpResp.sendError(HttpServletResponse.SC_BAD_REQUEST,
                    "Duplicate parameter not allowed: " + entry.getKey());
                return;
            }
        }

        chain.doFilter(request, response);
    }
}
Enter fullscreen mode Exit fullscreen mode

A few things about this filter are deliberate and worth walking through, because the obvious version of it has holes.

Placing it at Ordered.HIGHEST_PRECEDENCE matters. If the filter runs after Spring Security, an authentication or authorization decision may have already read the polluted parameter and made its call before you ever get to reject. The whole point is to fail the request before any consumer reads getParameter, so it has to be the first thing in the chain.

Calling getParameterMap() is what triggers Tomcat to parse the body for application/x-www-form-urlencoded POSTs. That is intentional here: you want both query-string and form-body duplicates caught. But be aware it has a side effect. Once the parameters are parsed, the request input stream is consumed, so a downstream component that tries to read the raw body itself (a custom multipart handler, for example) may find it empty. If you have such a component, wrap the request in a ContentCachingRequestWrapper so the body stays readable.

Note: getParameterMap() merges query string and form body parameters. If you only care about query string duplicates, parse request.getQueryString() manually. Both sources can be exploited, so rejecting from either is usually correct.

In the controller itself, use the narrowest type that fits the domain and pair it with Bean Validation:

@RestController
@RequestMapping("/api/users")
@Validated // activates method-level constraint validation
public class UserController {

    @GetMapping("/{id}")
    public UserDto getUser(
            @PathVariable @Positive Long id,           // path vars are unambiguous
            @RequestParam @NotNull @Pattern(           // belt-and-suspenders after filter
                regexp = "^(active|inactive|pending)$",
                message = "status must be active, inactive, or pending"
            ) String status) {

        return userService.findByIdAndStatus(id, status);
    }
}
Enter fullscreen mode Exit fullscreen mode

Using Long instead of String for identifiers means a duplicated ?id=1&id=2 will cause a MethodArgumentTypeMismatchException rather than silently binding. Spring cannot coerce two values into a single Long. This is a useful property, not a bug to work around. The same goes for enums: declaring status as an enum type instead of a validated string makes Spring reject any value outside the allowed set without you writing a regex, and it documents the contract in the type signature where the next reviewer will actually see it.

One tradeoff to name honestly: the strict filter will break any legitimate client that sends repeated parameters on purpose. Some search APIs intentionally accept ?tag=a&tag=b as a multi-value filter. If you have endpoints like that, the global reject-everything filter is wrong for them. Maintain an allowlist of parameter names that are permitted to repeat, scoped per route, rather than weakening the filter globally. A global exception erodes faster than a narrow one.

Code Review Checklist for Controllers and DTOs

When reviewing Spring Boot controllers for HPP, these are the patterns that get missed:

@RequestParam with String types for security-relevant parameters. Any parameter that gates access, selects a role, or filters data is security-relevant. String status, String role, String action are all candidates. They should be enums or validated strings, and the filter above should back them up.

@ModelAttribute binding on DTOs. Spring's data binding will happily set any field on a DTO that matches a parameter name. If the DTO has a role field and the form submits role=admin, it gets set. Explicitly whitelist bindable fields with @InitBinder and setAllowedFields.

// BEFORE: vulnerable. Spring binds all matching parameter names to DTO fields
@PostMapping("/profile")
public ResponseEntity<Void> updateProfile(@ModelAttribute UserProfileDto dto) {
    userService.update(dto); // dto.role could be attacker-supplied
    return ResponseEntity.ok().build();
}

// AFTER: explicit allowed fields, typed constraint, and narrowed DTO
@InitBinder("userProfileDto")
public void initBinder(WebDataBinder binder) {
    // Reject before deserialization. Gadget chains can fire from constructors,
    // and privilege escalation via unexpected field binding is a common finding
    binder.setAllowedFields("displayName", "email", "avatarUrl");
}

@PostMapping("/profile")
public ResponseEntity<Void> updateProfile(
        @Valid @ModelAttribute UserProfileDto dto,
        BindingResult result) {
    if (result.hasErrors()) {
        return ResponseEntity.badRequest().build();
    }
    userService.update(dto);
    return ResponseEntity.ok().build();
}
Enter fullscreen mode Exit fullscreen mode

Be aware that setAllowedFields uses prefix matching with wildcards, not exact matching, so setAllowedFields("user*") will allow userRole as well as username. Spell out each field name explicitly. The denylist counterpart, setDisallowedFields, is the wrong default because it fails open: any field you forget to list stays bindable. Allowlist, always.

MultiValueMap<String, String> as a catch-all parameter receiver. This is occasionally used to handle variable query strings. It accepts every duplicate by design. If you see it in a controller that feeds downstream services, treat it as an HPP sink. Any forwarding logic built on top of it must normalize before passing values on.

Jackson and duplicate JSON keys. The JSON spec (RFC 8259, section 4) says duplicate keys in an object are undefined behavior. Jackson's default behavior is to use the last value. This is a different attack surface from query-string HPP, but the same concept: two keys with the same name, and the application picks one. Configure DeserializationFeature.FAIL_ON_TRAILING_CONTENT and, for sensitive DTOs, a custom JsonParser that rejects duplicate keys.

ObjectMapper mapper = new ObjectMapper();
mapper.configure(JsonParser.Feature.STRICT_DUPLICATE_DETECTION, true);
// Now {"role":"user","role":"admin"} throws JsonParseException instead of silently using "admin"
Enter fullscreen mode Exit fullscreen mode

Register this mapper as your primary Spring bean rather than patching it per-endpoint. The application security engineer track covers the broader deserialization attack surface if you want the full picture alongside HPP. If you want a starting point for the rest of the controller review checklist, the material on codereviewlab.com groups these input-canonicalization bugs together so you can review them as a family rather than one at a time.

Hardening the Reverse Proxy and WebClient Layer

The filter approach above covers your application. But if Nginx or Spring Cloud Gateway sits in front, those layers also interpret duplicate parameters before traffic reaches Tomcat. Nginx by default uses the last value of a duplicate parameter in $arg_ variables. Gateway behavior is configurable.

A Spring Cloud Gateway filter that normalizes duplicate parameters before forwarding:

import org.springframework.cloud.gateway.filter.GatewayFilter;
import org.springframework.cloud.gateway.filter.factory.AbstractGatewayFilterFactory;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
import org.springframework.web.util.UriComponentsBuilder;
import reactor.core.publisher.Mono;
import java.util.List;

@Component
public class StrictQueryParamFilterFactory
        extends AbstractGatewayFilterFactory<Object> {

    @Override
    public GatewayFilter apply(Object config) {
        return (exchange, chain) -> {
            var params = exchange.getRequest().getQueryParams();

            for (List<String> values : params.values()) {
                if (values.size() > 1) {
                    // Reject at the gateway edge rather than letting
                    // inconsistent upstream behavior become an attack path
                    exchange.getResponse().setStatusCode(HttpStatus.BAD_REQUEST);
                    return exchange.getResponse().setComplete();
                }
            }

            return chain.filter(exchange);
        };
    }
}
Enter fullscreen mode Exit fullscreen mode

Apply this filter globally in your application.yml via the filter name, or register it on specific routes where parameter sensitivity is highest. Note one subtlety: Gateway's getQueryParams() returns already-decoded values, so a parameter smuggled through double URL-encoding (%2566 decoding to %66 decoding to f) can still slip a duplicate past a naive check at this layer if a downstream service decodes a second time. Decode once, canonicalize, then compare. Never compare raw and decoded forms in the same pass.

For outbound WebClient calls to internal services, the risk runs in the opposite direction: your code receives a MultiValueMap from the incoming request and forwards it wholesale to a downstream API. That downstream API may be less hardened.

// VULNERABLE: forwarding attacker-controlled params directly
public Mono<InventoryDto> getInventory(MultiValueMap<String, String> incomingParams) {
    return webClient.get()
        .uri(builder -> builder.path("/inventory").queryParams(incomingParams).build())
        .retrieve()
        .bodyToMono(InventoryDto.class);
}

// FIXED: build the outbound URI from validated, typed values only
public Mono<InventoryDto> getInventory(String warehouseId, String sku) {
    // warehouseId and sku are validated single values from @RequestParam
    return webClient.get()
        .uri(builder -> builder.path("/inventory")
            .queryParam("warehouseId", warehouseId)
            .queryParam("sku", sku)
            .build())
        .retrieve()
        .bodyToMono(InventoryDto.class);
}
Enter fullscreen mode Exit fullscreen mode

This is closely related to request smuggling at the proxy boundary: both classes of bug exploit ambiguity between what a proxy parses and what the origin server sees. The remediation philosophy is the same. Normalize aggressively at the edge, and don't pass raw attacker input downstream.

Testing for HPP Regressions in CI

A filter that rejects duplicates is only as good as the tests that will catch someone disabling it. These tests belong in CI, not in a penetration testing report.

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

@SpringBootTest
@AutoConfigureMockMvc
class DuplicateParameterFilterTest {

    @Autowired
    private MockMvc mockMvc;

    @Test
    void duplicateQueryParameter_shouldReturn400() throws Exception {
        mockMvc.perform(
                get("/api/users")
                    .queryParam("status", "active")
                    .queryParam("status", "inactive") // MockMvc appends both to the query string
            )
            .andExpect(status().isBadRequest());
    }

    @Test
    void singleQueryParameter_shouldReturn200() throws Exception {
        mockMvc.perform(
                get("/api/users")
                    .queryParam("status", "active")
            )
            .andExpect(status().isOk());
    }

    @Test
    void duplicateSecuritySensitiveParameter_roleEscalation_shouldReturn400() throws Exception {
        // Simulates the WAF-bypass pattern: first value is benign, second escalates privilege
        mockMvc.perform(
                get("/api/orders")
                    .queryParam("role", "user")
                    .queryParam("role", "admin")
            )
            .andExpect(status().isBadRequest());
    }
}
Enter fullscreen mode Exit fullscreen mode

Note: MockMvcRequestBuilders.queryParam(String, String...) appends each call as a separate occurrence in the query string. The filter's getParameterMap() will see both. This is the correct way to test; do not manually concatenate query strings and pass them to .param(), which goes through a different code path.

For integration tests that exercise Nginx or Gateway layers, use RestTemplate or WebTestClient with a manually constructed URI string:

URI uri = URI.create("http://localhost:" + port + "/api/orders?role=user&role=admin");
ResponseEntity<String> response = restTemplate.getForEntity(uri, String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
Enter fullscreen mode Exit fullscreen mode

Add these as a tagged test group (@Tag("security")) so they can be run as a mandatory gate in your CI pipeline separate from unit tests. One more case worth a dedicated test: the form-body variant. MockMvc's .param() simulates form parameters rather than query-string ones, so a test that posts role=user&role=admin as application/x-www-form-urlencoded verifies the filter catches body pollution and not just query-string pollution. Teams that test only the query-string path ship the body bypass without noticing.

Related Bypass Patterns to Review Alongside HPP

When you find HPP in a code review, the underlying issue is almost always a failure to canonicalize input before trusting it. That same failure shows up in adjacent issues that share the review checklist.

Mass assignment / parameter binding. If Spring binds arbitrary request parameters to model objects, an attacker doesn't need duplicates: one well-chosen extra parameter is enough. @ModelAttribute without setAllowedFields is the typical culprit. This is structurally the same problem as HPP, just on the field name axis rather than the parameter count axis.

Broken authentication flows triggered by parameter confusion. OAuth2 redirect flows, SAML assertion consumers, and multi-step login sequences often read state from URL parameters. If any step in that flow passes parameters through without deduplication, an attacker can inject a second value that the final step reads. The vector is HPP; the impact is authentication bypass.

Inconsistent API versioning as an attack surface. Applications that support ?version=1 and /v1/ path prefixes simultaneously may route to different controller implementations. If a request can carry both signals with conflicting values, and the routing logic picks one while security logic picks the other, you have a logic bypass. This is the same parser ambiguity that powers HPP, applied to the versioning layer.

Header-based HPP. Most HPP discussions focus on query strings. But X-Forwarded-For, X-Original-URL, and X-Rewrite-URL headers are also duplicatable in many proxy configurations. A WAF that reads the first X-Forwarded-For value for IP allowlisting while your app reads the last is the same attack geometry.

When you sit down to fix HPP in a codebase, pull the thread on all of these. They cluster. A team that did not think carefully about duplicate query parameters probably did not think carefully about any of them.

Further Reading

  • HTTP Parameter Pollution lesson for Spring-specific bypass walkthroughs
  • The request smuggling review material for the proxy/origin ambiguity that underpins both bug classes
  • OWASP Testing Guide section on Testing for HTTP Parameter Pollution (WSTG-INPV-04)
  • RFC 3986 (URI generic syntax) and RFC 8259 section 4 (duplicate JSON object keys)
  • CVE-2021-22112, for a concrete example of state-handling ambiguity in the Spring ecosystem

If you're already writing the servlet filter, also check whether your @ExceptionHandler returns consistent error shapes for MethodArgumentTypeMismatchException, ConstraintViolationException, and the 400 from sendError. Attackers probe error messages for information about your parameter handling logic, and inconsistent error responses tell them which bypass paths are still open.

Top comments (0)