NGINX NJS Security Hardening vs. Wasm Filter Isolation

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