NGINX NJS Security Hardening vs. Wasm Filter Isolation
Problem
NGINX NJS (NGINX JavaScript) is the official JavaScript runtime embedded in NGINX, available since NGINX 1.9.15. It allows JavaScript code to execute within NGINX worker processes for tasks like: custom authentication logic, header manipulation, routing decisions based on request body content, JWT validation, and A/B testing. NJS is compiled as an NGINX module and runs in the same address space as the NGINX worker process.
The security model of NJS is significantly different from what the term “scripting” might suggest:
NJS has no sandbox boundary. NJS code runs inside the NGINX worker process. A bug or malicious behaviour in an NJS script can access and modify all data in the worker’s memory space: request buffers, SSL context, upstream connection state, and NGINX configuration. There is no memory isolation between NJS scripts and the NGINX core. If an NJS script has a security vulnerability (prototype pollution, unsafe eval, injection via input), the blast radius extends to the entire NGINX worker.
NJS has no resource limits by default. A runaway NJS script — infinite loop, unbounded memory allocation — blocks the NGINX worker thread for that request and consumes worker memory. NGINX workers use an event loop: a blocked worker cannot service other requests. A crafted request that triggers a slow NJS script creates a single-thread DoS on that worker.
NJS can make network connections. The ngx.fetch() API allows NJS scripts to make outbound HTTP requests. An NJS script that processes untrusted input and passes it to ngx.fetch() without validation creates an SSRF vulnerability inside the NGINX worker process.
NJS does not isolate between virtual hosts. An NJS script loaded in one server {} block shares the NJS engine context with scripts in other server blocks. Prototype pollution in one script can affect the JavaScript environment for other scripts.
The CVE interaction. When NGINX worker CVEs (memory corruption, use-after-free) are combined with NJS, the attack surface expands. An NJS script that is reachable via a CVE-vulnerable code path may be exploitable in ways that a purely passive NGINX configuration would not be.
The Wasm alternative. NGINX supports Wasm filters via the nginx-wasm-module (ngx_http_wasm_module). Wasm filters run in a separate linear memory sandbox — a bug in a Wasm filter cannot access NGINX worker memory outside the Wasm module’s allocated region. This isolation is structurally stronger than NJS.
The question for platform teams deploying NJS for custom logic is: when does the NJS threat model become unacceptable, and when should Wasm filter isolation be used instead?
Target systems: NGINX deployments using ngx_http_js_module (NJS) for custom logic; teams evaluating NJS vs. Wasm for custom NGINX extensions; security engineers auditing NGINX deployments with custom scripting.
Threat Model
Adversary 1 — NJS prototype pollution via crafted request. An attacker sends a crafted request containing JSON that exploits prototype pollution in NJS’s object handling or in application JavaScript running in NJS. The pollution modifies JavaScript prototype chain objects, affecting routing decisions for subsequent requests from other clients.
Adversary 2 — SSRF via NJS ngx.fetch() with untrusted input. An NJS script uses request parameters to construct a URL for ngx.fetch() without validating the URL. An attacker sends a request with a parameter set to an internal metadata service URL (e.g., 169.254.169.254), causing NGINX to make an internal request on the attacker’s behalf.
Adversary 3 — NJS infinite loop causing worker DoS. An NJS script processes request headers with a regex or loop that can be made to run indefinitely with a crafted input. An attacker discovers the pattern and sends requests that trigger the pathological case, blocking a NGINX worker thread. With enough requests, all workers are blocked.
Adversary 4 — Wasm filter memory confusion. A Wasm filter with a bug in its linear memory management produces a buffer overflow within the Wasm linear memory. Because Wasm linear memory is isolated from NGINX worker memory, the corruption is contained within the Wasm module’s sandbox and cannot affect NGINX core state (though it may cause the Wasm module to malfunction or crash).
Configuration / Implementation
Step 1 — Audit existing NJS usage for security issues
# Find all NJS script files referenced in NGINX config
grep -r "js_import\|js_content\|js_access\|js_set\|js_body_filter\|js_header_filter" \
/etc/nginx/ 2>/dev/null
# Find all NJS files
find /etc/nginx -name "*.js" 2>/dev/null
# Check for risky NJS patterns
echo "=== Checking for eval usage ==="
grep -r "eval(" /etc/nginx/*.js /etc/nginx/conf.d/*.js 2>/dev/null
echo "=== Checking for ngx.fetch with variable URLs ==="
grep -rn "ngx\.fetch" /etc/nginx/ 2>/dev/null | grep -v '["'"'"'`]http'
# Lines matching this grep use a variable in ngx.fetch URL — potential SSRF
echo "=== Checking for r.requestBody usage with external fetch ==="
grep -rn "r\.requestBody\|r\.args\." /etc/nginx/*.js 2>/dev/null
# Review: is this data used in ngx.fetch or other sensitive operations?
Step 2 — Harden NJS scripts against injection
// /etc/nginx/js/auth-handler.js
// Example: JWT validation in NJS — hardened against injection
import jwt from 'crypto';
// SAFE: validate all external input before use
function validateBearerToken(r) {
const authHeader = r.headersIn['Authorization'];
// Validate header format before processing
if (!authHeader || !authHeader.startsWith('Bearer ')) {
r.return(401, JSON.stringify({error: 'Missing or invalid Authorization header'}));
return;
}
const token = authHeader.substring(7);
// Validate token format — only allow base64url characters and dots
// Prevents injection via malformed tokens
if (!/^[A-Za-z0-9\-_]+\.[A-Za-z0-9\-_]+\.[A-Za-z0-9\-_]+$/.test(token)) {
r.return(401, JSON.stringify({error: 'Invalid token format'}));
return;
}
// Decode and validate claims
try {
const parts = token.split('.');
const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString());
// Validate expiry
if (payload.exp && payload.exp < Math.floor(Date.now() / 1000)) {
r.return(401, JSON.stringify({error: 'Token expired'}));
return;
}
// Validate issuer against allowlist (not via regex on untrusted input)
const ALLOWED_ISSUERS = ['https://auth.example.com', 'https://sso.example.com'];
if (!ALLOWED_ISSUERS.includes(payload.iss)) {
r.return(401, JSON.stringify({error: 'Invalid issuer'}));
return;
}
// Safe to proceed — add validated claims as headers
r.headersOut['X-User-ID'] = String(payload.sub || '').replace(/[^a-z0-9\-_]/gi, '');
r.headersOut['X-User-Role'] = String(payload.role || '').replace(/[^a-z0-9\-_]/gi, '');
} catch (e) {
r.return(401, JSON.stringify({error: 'Token validation failed'}));
return;
}
r.internalRedirect('@authenticated');
}
// SAFE: ngx.fetch with URL validation
async function proxyWithAuthCheck(r) {
const targetPath = r.args['target'];
// VALIDATE: only allow paths from a known set, not arbitrary URLs
const ALLOWED_PATHS = ['/api/v1/users', '/api/v1/products', '/health'];
if (!ALLOWED_PATHS.includes(targetPath)) {
r.return(400, JSON.stringify({error: 'Invalid target path'}));
return;
}
// Construct URL from validated components — never from raw user input
const BACKEND_BASE = 'http://127.0.0.1:8080';
const url = BACKEND_BASE + targetPath;
try {
const response = await ngx.fetch(url, {
method: 'GET',
headers: {'X-Internal': 'nginx-proxy'}
});
r.return(response.status, await response.text());
} catch (e) {
r.return(502, JSON.stringify({error: 'Backend unavailable'}));
}
}
export default {validateBearerToken, proxyWithAuthCheck};
Step 3 — Apply NJS resource limits via NGINX configuration
# /etc/nginx/nginx.conf
http {
# NJS timeout — prevent runaway scripts from blocking workers indefinitely
# NJS does not have a built-in timeout; use this NGINX directive
# (Available in NGINX Plus; for OSS, use proxy_read_timeout as an indirect control)
js_import /etc/nginx/js/auth-handler.js;
server {
listen 443 ssl;
server_name api.example.com;
# Apply NJS auth check
js_access auth-handler.validateBearerToken;
location @authenticated {
proxy_pass http://upstream;
}
# Limit request body size before NJS processes it
# NJS that processes the request body is affected by large body attacks
client_max_body_size 1m;
# NJS scripts that make upstream calls respect proxy timeouts
# Set conservative timeouts to limit SSRF and runaway fetch impact
proxy_connect_timeout 5s;
proxy_read_timeout 10s;
proxy_send_timeout 5s;
}
}
Step 4 — When to use Wasm instead of NJS
Decision framework for NJS vs. Wasm:
Use NJS when:
✓ Logic is simple (header manipulation, JWT validation, routing)
✓ No untrusted code (internal team writes and reviews all scripts)
✓ No dynamic code execution (no eval, no Function() constructor)
✓ ngx.fetch() is not used, or only with hardcoded URL prefixes
✓ The script processes only validated, typed inputs
Use Wasm when:
✓ Script processes complex, attacker-controlled input (e.g., parsing rich documents)
✓ Third-party or vendor-supplied logic is being loaded
✓ The logic is complex enough that memory safety bugs are plausible
✓ Isolation from the NGINX worker memory is a hard requirement
✓ Multiple independent scripts must not share state or affect each other
Step 5 — Wasm filter isolation implementation
// src/lib.rs — Example NGINX Wasm filter (using proxy-wasm SDK)
// Wasm filter that validates JWTs in isolated memory sandbox
use proxy_wasm::traits::*;
use proxy_wasm::types::*;
struct JwtAuthFilter;
impl HttpContext for JwtAuthFilter {
fn on_http_request_headers(&mut self, _num_headers: usize, _end_of_stream: bool) -> Action {
let auth_header = self.get_http_request_header("Authorization")
.unwrap_or_default();
if !auth_header.starts_with("Bearer ") {
self.send_http_response(401, vec![], Some(b"Unauthorized"));
return Action::Pause;
}
let token = &auth_header[7..];
// Validate token format — only base64url characters
if !token.chars().all(|c| c.is_alphanumeric() || c == '-' || c == '_' || c == '.') {
self.send_http_response(401, vec![], Some(b"Invalid token format"));
return Action::Pause;
}
// Set validated header for upstream
self.set_http_request_header("X-Auth-Validated", Some("true"));
Action::Continue
}
}
impl Context for JwtAuthFilter {}
proxy_wasm::main! {{
proxy_wasm::set_log_level(LogLevel::Trace);
proxy_wasm::set_http_context(|_, _| -> Box<dyn HttpContext> {
Box::new(JwtAuthFilter)
});
}}
# Load Wasm filter in NGINX configuration
# Requires ngx_http_wasm_module
http {
wasm {
module jwt_auth /etc/nginx/wasm/jwt-auth.wasm;
}
server {
listen 443 ssl;
location /api/ {
# Wasm filter runs in isolated sandbox — bug in filter
# cannot access NGINX worker memory
proxy_wasm jwt_auth;
proxy_pass http://upstream;
}
}
}
Step 6 — Monitor NJS for anomalous behaviour
# Add NJS error logging to detect script failures
error_log /var/log/nginx/error.log warn;
# In NJS scripts — structured logging for anomaly detection
function logSecurityEvent(r, eventType, detail) {
ngx.log(ngx.WARN, JSON.stringify({
event: eventType,
client_ip: r.remoteAddress,
uri: r.uri,
detail: detail,
timestamp: new Date().toISOString()
}));
}
// Use in scripts:
// logSecurityEvent(r, 'auth_failure', 'invalid token format');
// logSecurityEvent(r, 'ssrf_attempt', 'blocked URL: ' + url);
Expected Behaviour
| Scenario | NJS (default) | NJS (hardened) | Wasm filter |
|---|---|---|---|
| Prototype pollution via crafted input | Affects all subsequent requests in worker | Input validation rejects crafted input early | Isolated Wasm memory; no JS prototype to pollute |
| ngx.fetch() with attacker-controlled URL | SSRF to internal services | URL validated against allowlist before fetch | Wasm filter uses proxy-wasm dispatch_http_call with host allowlist |
| Bug in script — memory corruption | Corrupts NGINX worker memory | Code review reduces bug probability; no sandbox | Corruption contained within Wasm linear memory |
| Runaway script blocks worker | Worker stuck; other requests queued | Timeout + simple script design limits blocking | Wasm epoch interruption can terminate runaway filter |
| Third-party script loaded | Full NGINX worker access | Code review required; no isolation | Sandbox boundary limits third-party blast radius |
Trade-offs
| Aspect | Benefit | Cost | Mitigation |
|---|---|---|---|
| NJS simplicity | Easy to write and debug; native NGINX integration | No memory isolation; prototype pollution possible | Restrict to simple, well-reviewed scripts; avoid processing complex untrusted input |
| Wasm filter isolation | Memory sandbox; contains bugs; supports third-party code | Higher complexity; requires Rust/C compilation; proxy-wasm API is more limited than NJS API | Use Wasm for high-risk logic; NJS for simple routing; evaluate per-use-case |
| NJS input validation | Reduces injection surface | Does not prevent all NJS security issues | Combine with code review and principle of least complexity in scripts |
| Disabling NJS entirely | Eliminates NJS attack surface | Loses custom logic capability | Consider if the logic can be moved to the upstream application instead |
Failure Modes
| Failure | Symptom | Detection | Recovery |
|---|---|---|---|
| NJS script syntax error on deploy | NGINX fails to start; nginx -t fails |
nginx -t in CI catches this |
Fix syntax error; review NJS unit tests |
| ngx.fetch() backend unreachable | NJS request hangs until timeout | NGINX error log shows fetch timeout; elevated request latency | Set proxy_read_timeout to bound the wait; handle fetch errors in NJS try/catch |
| Wasm module ABI mismatch | NGINX fails to load Wasm module; error in logs | Load error at startup | Rebuild Wasm module targeting the proxy-wasm ABI version matching your NGINX Wasm module |
| NJS prototype pollution in shared state | Routing decisions change for unrelated requests | Anomalous routing logs; requests going to wrong upstreams | Isolate NJS execution by avoiding shared mutable global state in scripts |
Related Articles
- NGINX Worker Privilege Hardening — OS-level controls that limit what an NGINX worker can do, relevant when NJS runs inside the worker
- Wasm Multi-Tenancy — isolation boundaries for multi-tenant Wasm deployments, applicable to multi-tenant Wasm filter deployments
- Wasm Shared-Everything Threads Security — shared state implications in Wasm filters when multiple threads are involved
- NGINX AI Inference Proxy Hardening — hardening NGINX used as an inference proxy, where NJS may be used for authentication
- NGINX Config Security CI Pipeline — CI scanning for NGINX configuration including NJS script loading