Reducing web application attack surface¶
Defence starts with reducing what is exploitable. Every control below addresses a class of vulnerability that appears consistently in web application testing. None of them is sufficient alone; together they raise the cost of exploitation significantly.
Input handling¶
Parameterised queries (prepared statements) eliminate SQL injection for database calls. This is not an input validation approach: it separates the query structure from the data at the driver level, so no input value can change the query’s meaning regardless of its content.
Auto-escaping engines are the safer choice for server-side template rendering. Passing user
input directly into render(), eval(), or template string construction bypasses the engine’s
protections and is the common path to SSTI. Where a feature genuinely requires dynamic
template content, constraining what is dynamic is more reliable than sanitising the input.
File and URL inputs fed to outbound HTTP clients (image loaders, webhook handlers, document importers) are worth validating against an explicit allowlist of permitted schemes, hosts, and ports. Loopback addresses and link-local ranges (169.254.0.0/16, ::1) belong in the blocked set at the validation layer.
The safe XML parser configuration disables external entity processing and DTD loading explicitly. This is not the default in most libraries:
# Python lxml: disable external entities
from lxml import etree
parser = etree.XMLParser(resolve_entities=False, no_network=True)
// Java SAXParser
factory.setFeature("http://xml.org/sax/features/external-general-entities", false);
factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
Output and response handling¶
Detailed error messages and stack traces in production responses are reconnaissance material: an error page that reveals the ORM type, database schema, or framework version gives an attacker information they would otherwise need to infer. A generic message with a correlation ID that maps to the full detail in server-side logs is the appropriate response.
Security headers worth including on every response:
Content-Security-Policy: default-src 'self'; script-src 'self'; object-src 'none'
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
Strict-Transport-Security: max-age=31536000; includeSubDomains
Referrer-Policy: strict-origin-when-cross-origin
The Content-Security-Policy is the most impactful XSS mitigation. A policy that disallows inline scripts and external script sources forces XSS payloads to find a script source the policy permits, which is significantly harder.
Authentication and session hardening¶
Session cookies with HttpOnly, Secure, and SameSite=Strict (or at minimum
SameSite=Lax) limit the impact of XSS and CSRF. HttpOnly prevents JavaScript from reading
the cookie. Secure prevents transmission over HTTP. SameSite=Strict prevents the cookie
from being sent with cross-origin requests, eliminating most CSRF attack scenarios.
JWT implementations restricted to an explicit algorithm allowlist prevent the class of
attacks where the token controls its own verification method. The none algorithm warrants unconditional rejection. Asymmetric
algorithms (RS256, ES256) are preferable to symmetric ones (HS256) for tokens validated
across multiple services. Key rotation belongs in the production runbook.
Short expiry times on password reset tokens and one-time codes (fifteen minutes is a common ceiling), binding to the issuing email address, and invalidation after first use are the relevant controls.
Access control¶
Access control enforced server-side on every request closes the common pattern of an endpoint that was “internal” during development and accidentally left open. A deny-by-default approach, where every endpoint requires authentication unless explicitly marked public, is the reliable form of this.
Mass assignment is prevented by explicit allowlists at the model or serialiser layer, not by stripping fields from user input. A field with no business reason for client modification is not assignable regardless of what the client sends.
Admin functionality on a separate subdomain or path prefix, accessible only from specific network sources, is harder to test and harder to abuse than admin functionality on the same surface as the main application.
Business logic¶
Atomic database transactions or application-level locks are the right control for shared-state operations. A check-and-write that reads a value and then updates it without holding a lock is exploitable with concurrent requests. Idempotency keys on financial operations prevent double-spend from race conditions and duplicate submissions.
Workflow state transitions validated server-side at every step close the step-skipping attack surface. If a refund is only valid when an order is in “delivered” status, that status check goes at the refund endpoint.
File upload hardening¶
An allowlist of permitted extension types is more reliable than a blocklist of prohibited ones. The set of extensions a feature legitimately needs is small and enumerable; the set an attacker might try is not. Renaming uploaded files on write prevents filename collisions and removes attacker-chosen names before any downstream processing touches them.
Storage that does not execute code is the appropriate destination for uploaded files. A file
stored outside the web root or in a bucket with no execution permissions cannot become a web
shell regardless of its content. Where files are downloadable, Content-Disposition: attachment
and X-Content-Type-Options: nosniff prevent browsers from sniffing and executing the content.
A size limit and rejection of archive formats such as ZIP, which unpack into multiple files,
belong in the upload validation.
File content validated independently of the declared MIME type catches the common bypass where
a file claims to be image/png but carries PHP code in its body: it passes a content-type
check and fails an actual image parse. Libraries such as Pillow (Python) or ImageMagick can
re-encode images to a known-safe format, stripping any embedded payload in the process.
Path traversal prevention¶
The canonical path of any file the application constructs from user input, resolved and confirmed to begin with the allowed root before the file is opened, catches all standard traversal patterns. In most languages:
import os
ALLOWED_ROOT = "/var/www/uploads"
requested = os.path.realpath(os.path.join(ALLOWED_ROOT, user_input))
if not requested.startswith(ALLOWED_ROOT + os.sep):
raise ValueError("path traversal detected")
This check catches encoded sequences, double-dot chains, and absolute path injection,
because realpath resolves all of them before the comparison. Avoiding user input in
filesystem calls entirely is the stronger position; framework static-serving (nginx alias,
Whitenoise) handles this without application code touching filenames.
Insecure deserialisation¶
Deserialising data from untrusted sources is the root of most deserialisation exploitation. The safest position is not to deserialise at all: replacing serialised objects in session cookies, tokens, or parameters with opaque identifiers that map to server-side state removes the attack surface entirely.
Where deserialisation is unavoidable, a type-safe format (JSON or MessagePack) reduces the attack surface.
Native serialisation in use warrants a deserialisation filter that allowlists the permitted
types before any object is instantiated. Java’s serialisation filter API (available natively
since Java 17, and via ObjectInputFilter from Java 9) is the relevant mechanism:
ObjectInputStream ois = new ObjectInputStream(inputStream);
ois.setObjectInputFilter(info -> {
if (info.serialClass() == null) return ObjectInputFilter.Status.UNDECIDED;
if (ALLOWED_CLASSES.contains(info.serialClass().getName()))
return ObjectInputFilter.Status.ALLOWED;
return ObjectInputFilter.Status.REJECTED;
});
pickle.loads() and yaml.load() on untrusted data are unsafe; yaml.safe_load() is the
appropriate alternative.
Prototype pollution¶
In JavaScript applications, freezing the Object prototype prevents attacker-controlled keys from poisoning inherited properties:
Object.freeze(Object.prototype);
The nopp npm package applies this to all common object prototypes automatically. Where
freezing is not feasible, JSON schema validation before merging input into application objects
removes __proto__ and constructor keys before they reach any merge or clone operation.
Map is preferable to plain objects for collections that accept arbitrary string keys; Map
instances do not inherit from Object.prototype and are not affected by prototype pollution.
Recursive-merge and deep-clone libraries are the most common pollution sources and worth
auditing.
CORS controls¶
Access-Control-Allow-Origin configured from an explicit allowlist closes the primary CORS
misconfiguration. A reflected origin means any site can make credentialed cross-origin
requests to the application and read the response:
# nginx: allow only known origins
map $http_origin $cors_origin {
"https://app.example.com" $http_origin;
"https://partner.example.com" $http_origin;
default "";
}
add_header Access-Control-Allow-Origin $cors_origin;
Access-Control-Allow-Origin: null is not a safe value; sandboxed frames and local files
send a null origin, so this effectively permits any embedded context. A wildcard origin
(*) combined with Access-Control-Allow-Credentials: true is rejected by browsers, but
some frameworks fall back to reflecting the origin instead, which is exploitable.
Access-Control-Allow-Methods and Access-Control-Allow-Headers values restricted to those
actually needed reduce the cross-origin surface.
OAuth and SSO hardening¶
Exact string matching against a registered allowlist for redirect_uri values closes the
redirect hijacking surface. Prefix matching (accepting any URI that starts with the registered
domain) and substring matching (accepting any URI that contains the domain) both permit
redirect to attacker subdomains or paths. Path traversal in the URI
(/callback/../../../attacker) also bypasses prefix matching.
A cryptographically random state parameter per authorisation request, bound to the user’s
session and validated on return before exchanging the code, prevents CSRF against the OAuth
flow. An absent or static state parameter is exploitable. For public clients, PKCE
(code_challenge_method=S256) with rejection of code exchanges that arrive without a valid
code_verifier prevents authorisation code interception. Dynamic client registration
restricted to authenticated and authorised callers removes an open registration endpoint that
would otherwise expose JWK Set fetching and logo URL processing as SSRF vectors.
HTTP Host header controls¶
A web server with an explicit server name configured, rejecting requests whose Host header
does not match, is the baseline. In nginx, a catch-all server block that returns 444 (no
response) for unrecognised hosts handles this:
server {
listen 80 default_server;
return 444;
}
server {
listen 80;
server_name app.example.com;
# ... application config
}
Absolute URLs built from a configured constant prevent header poisoning in password reset
links, email confirmation URLs, and redirect targets constructed from the Host header. X-Forwarded-Host and similar override headers
are best disabled unless a specific trusted reverse proxy requires them; where a proxy sets
these headers, validation at the application layer against the same allowlist as the Host
header applies.
HTTP request smuggling¶
The primary mitigation is HTTP/2 end-to-end between the front-end and back-end. HTTP/2 uses
binary framing and has no Content-Length / Transfer-Encoding ambiguity at the protocol
level; the smuggling surface appears only in HTTP/1.1 downgrades.
Where HTTP/1.1 is unavoidable on the back-end path, normalising ambiguous headers at the
front-end closes the most common smuggling vectors: requests that contain both
Content-Length and Transfer-Encoding, or a Transfer-Encoding value other than chunked,
are candidates for rejection. Disabling connection reuse between the front-end proxy and
back-end servers removes the state-sharing that smuggling requires, if the performance
trade-off is acceptable.
Web cache poisoning¶
A cache key covering every input that affects the response prevents a header or parameter
from being excluded while still influencing what the application returns. A header or
parameter that changes the response but is excluded from the key allows a poisoned response
to reach callers who did not send that header. Param Miner and the Vary response header
are useful tools for auditing what a cache is keying on.
Headers not needed to generate the correct response are worth stripping or ignoring:
X-Forwarded-Host, X-Forwarded-Scheme, and similar override headers are frequent unkeyed
inputs in misconfigured caches. GET requests carrying a body (fat GET requests) are
non-standard and their body is typically unkeyed, making rejection the safer default.
Authenticated responses cached without the full session identity in the cache key are a
poisoning risk.
WebSocket security¶
The WebSocket handshake authenticated via the existing session mechanism prevents connection
hijacking in the same way session validation prevents HTTP session hijacking. The Upgrade
handshake is an HTTP request and carries the session cookie; a server that validates it on
the handshake request benefits from the same protection. The Origin header validated
server-side on the handshake, restricting connections to known origins, compensates for the
WebSocket protocol not enforcing same-origin policy on its own.
WebSocket message content warrants the same input validation as HTTP request parameters. Messages arriving over an authenticated connection are not implicitly trusted: they may contain injection payloads, oversized data, or out-of-sequence commands. Message frequency and size limited at the server is the reliable control.
OS command injection¶
Passing user-controlled input to shell execution functions (os.system,
subprocess.call(shell=True), exec, passthru) is the class of mistake this section
addresses. Where OS interaction is unavoidable, an API that accepts the program and its
arguments as a list, never as a shell string, is the correct form:
# avoid: concatenates input into a shell string
subprocess.call(f"convert {user_file}", shell=True)
# use: program and arguments are separate; no shell is invoked
subprocess.run(["convert", user_file], check=True)
An allowlist of permitted argument values where the set is finite (for example, permitted
output formats or permitted target hostnames) further reduces the surface. An argument that
passes through without validation is an injection vector even when the shell is not invoked
directly, because some programs (ffmpeg, ImageMagick) interpret certain argument values
as URIs or file paths and perform their own fetches.
Last updated: 10 July 2026