yum upgrades for production use, this is the repository for you.
Active subscription is required.
Technical Briefing: Safe JSONP in NGINX with ngx_http_xss_module
Today’s source is a GetPageSpeed article on ngx_http_xss_module, an established NGINX module from the xss-nginx-module project. The module generates JSONP responses and validates the callback name before sending any data. The article covers when JSONP is still needed, how the module decides whether to wrap a response, a hardened configuration, verification steps, and troubleshooting.
The Problem: When CORS Isn’t an Option
CORS is the modern way to serve JSON across origins, but it doesn’t work in every environment:
- static CDNs where you can’t set headers
- corporate proxies that strip
Access-Control-Allow-Origin - third-party widget embeds
- legacy browsers such as IE8
In these cases JSONP may be the only choice. The article warns that hand-rolled JSONP is “one sloppy regex away from reflected XSS.” If the callback sanitizer misses a single character, an attacker can inject arbitrary JavaScript. Known JSONP attack classes include:
- Rosetta Flash
- content-type confusion
- stored XSS via reflected callback names
How the Module Works
ngx_http_xss_module is a combined header and body filter. It produces JSONP automatically and validates the callback before any byte is sent.
Conditions for wrapping
A response is wrapped only if all of the following are true:
- The request method is GET. POST, HEAD and OPTIONS are skipped.
- The status is 200 or 201, unless
xss_check_statusis off. - The upstream Content-Type matches
xss_input_types. The default isapplication/json. - The query string contains the argument named by
xss_callback_arg. - The URL-decoded callback passes the validator.
What wrapping does
- It prepends
callbackName(and appends);. - It rewrites Content-Type to
xss_output_type. The default isapplication/x-javascript. - It clears Content-Length, so the response streams as
Transfer-Encoding: chunked.
The callback validator
The validator is a Ragel state machine, not a regex. It implements this grammar:
identifier = [$A-Za-z_] [$A-Za-z0-9_]*
index = [0-9]* '.' [0-9]+ | [0-9]+
callback = identifier ('.' identifier)* ('[' index ']')?
- Allowed: dotted namespaces such as
App.handlers.onUsers, and one trailing numeric index such aswindow.handlers[0]. - Rejected: parentheses, quotes, angle brackets, spaces, backticks and commas.
- Whole-input matching: The entire input must match the grammar. A single stray byte causes rejection.
- On rejection: The original, unwrapped response is served.
- Logging: Each rejection is logged at error level with the literal value. The article notes this is useful for WAF analytics.
Installation
RHEL/CentOS/AlmaLinux/Rocky:
sudo dnf install https://extras.getpagespeed.com/release-latest.rpm
sudo dnf install nginx-module-xss
Then add:
load_module modules/ngx_http_xss_filter_module.so;
Debian/Ubuntu. First set up the corresponding APT repository, then run:
sudo apt-get update
sudo apt-get install nginx-module-xss
The module loads automatically on these systems, so no load_module line is needed.
Directives
All six directives are valid in http, server, location and if in location.
| Directive | Default | Notes |
|---|---|---|
xss_get on \| off; |
off |
Master switch. |
xss_callback_arg name; |
none | If this is unset while xss_get is on, the module logs xss_get is enabled but no xss_callback_arg specified and wraps nothing. |
xss_input_types mime-type ...; |
application/json |
Keeps HTML error pages and binaries from being wrapped. Listing application/json explicitly on top of the default triggers a duplicate-MIME-type warning. Either rely on the default or override it entirely. |
xss_output_type mime-type; |
application/x-javascript |
application/javascript and text/javascript also work in modern browsers. |
xss_check_status on \| off; |
on |
Only 200/201 responses are wrapped. |
xss_override_status on \| off; |
on |
When xss_check_status is off, this rewrites 3xx/4xx/5xx statuses to 200, because <script> handlers never fire on non-200 responses. Set it off to keep real status codes for observability. |
No variables are exported. To log whether a response was wrapped, infer it from the query string with a map block.
Hardened Example
The following goes in /etc/nginx/conf.d/api.conf:
limit_req_zone $binary_remote_addr zone=jsonp:10m rate=30r/s;
server {
listen 8080;
server_name _;
add_header X-Content-Type-Options nosniff always;
location /api/ {
root /var/www;
default_type application/json;
limit_req zone=jsonp burst=60 nodelay;
xss_get on;
xss_callback_arg callback;
xss_check_status on;
xss_output_type application/javascript;
}
}
For test data, place {"users":[...]} at /var/www/api/users.json. Then test the configuration and reload:
sudo nginx -t
sudo systemctl reload nginx
Verification
| Request | Result |
|---|---|
| No callback | Plain JSON. |
?callback=handleUsers |
handleUsers({...} ); served as application/javascript. |
?callback=App.handlers.onUsers |
Wrapped. This matters for jQuery-style names such as jQuery112.callbacks_0. |
?callback=%3Cscript%3Ealert(1)%3C%2Fscript%3E |
Raw JSON with type application/json, so a <script> tag won’t execute it. It is logged as [error] xss: bad callback argument: "<script>alert(1)</script>". |
?callback=alert(1) |
Rejected and logged. |
JSONP vs. CORS
JSONP is legacy. It is GET-only and 200-or-nothing, and the response executes in the caller’s JavaScript context.
Use CORS when you can. It provides real status codes, POST support, cookies, custom headers and preflight. If you control both the client and the server, use CORS.
Use the module when you can’t. It suits read-only APIs bolted onto infrastructure you don’t fully own.
Hardening Checklist
- Set
xss_callback_argexplicitly, and use one documented parameter name everywhere. - Keep
xss_check_status onto avoid leaking stack traces or upstream details. - Keep
xss_override_status onfor public APIs. - Keep
xss_input_typesas narrow as possible. - Add
X-Content-Type-Options: nosniff. - Give the JSONP location its own
limit_reqzone. - Never enable
xss_geton locations serving session-specific or authenticated data. JSONP responses are cross-origin readable by design.
Troubleshooting
Nothing gets wrapped:
– Raise logging with error_log /var/log/nginx/error.log info; to see every decision the module makes.
– Check whether the request method is something other than GET.
– Check the Content-Type with curl -I. It may be text/html or application/octet-stream.
– Check for a 301, 302 or 5xx status.
– Check for a missing or empty callback. An empty callback is treated as no callback.
Callback silently rejected:
– Run sudo grep "bad callback argument" /var/log/nginx/error.log.
– Look for trailing whitespace or non-ASCII characters. Only strict ASCII is accepted, and Unicode identifiers are not supported.
Subrequests not wrapped:
– This is a deliberate limitation. Content produced by ngx_echo, ngx_addition or similar modules won’t be wrapped.
– The module author recommends using ngx_lua‘s ngx.location.capture() as the content handler instead.
Unexpected chunked encoding:
– This is expected, because the module clears Content-Length.
Practical Payoff
A single set of directives gives you JSONP that blocks the reflected-code attack class common in homegrown implementations. The validator is a strict state machine, and any rejected callback falls back to plain JSON. The MIME-type and status gates also keep error pages and non-JSON bodies out of the JSONP channel. Source code and issue tracking are in the xss-nginx-module GitHub repository.
Read the full article: NGINX JSONP: Safe Cross-Origin APIs with the XSS Module
