@niko122/middleware
v0.2.2
Published
Express-style middleware that obfuscates .html, .css, and .js responses on the fly with layered AES-GCM, PBKDF2, anti-debug traps, and a devtools kill switch.
Maintainers
Readme
@niko122/middleware
Express-style middleware that obfuscates .html, .css, and .js responses on the fly. Every eligible response is rewritten into an encrypted bootstrap that decrypts and reassembles itself in the browser at runtime, with anti-debug traps, a devtools kill switch, env guards, and integrity checks layered on top.
It is security through obfuscation, not a replacement for real server-side secrets. The goal is to make casual viewing of source, copy-pasting of scripts, and reverse-engineering meaningfully harder, not to resist a determined, patient attacker with a disassembler.
Install
npm install @niko122/middlewareNode 16 or newer. Express is an optional peer dep (the middleware is Connect/Express-shaped; you only need Express if you want to use it in Express).
Quick start
const express = require('express');
const path = require('path');
const { middleware } = require('@niko122/middleware');
const app = express();
app.use(middleware({
antiDebug: true,
devtoolsTrap: true,
integrityChecks: true,
pbkdf2Iterations: 250000,
closeUrl: 'https://example.com/blocked',
debugRewriteHtml: '<h1>Access denied.</h1>',
integrityRewriteHtml: '<h1> Access denied. </h1>',
junkRatio: 2.0,
}));
app.use(express.static(path.join(__dirname, 'public')));
app.listen(3000);Every text/html, application/javascript (or .js / .mjs by extension), and text/css (or .css by extension) response now ships as an obfuscated payload. JSON, images, fonts, downloads, and responses larger than maxBytes pass through untouched.
Loading CSS
CSS mode emits JavaScript that injects a <style> tag at runtime, because CSS itself has no way to run a decrypter. That means you load stylesheets as scripts:
<!-- instead of: <link rel="stylesheet" href="/styles.css"> -->
<script src="/styles.css"></script>The middleware automatically flips the outbound Content-Type from text/css to application/javascript so the browser treats the response as a script.
CLI
There is also a one-shot CLI for offline obfuscation:
npx web-middleware input.html -o output.html
npx web-middleware input.js -o output.js
npx web-middleware input.css -o output.jsExtension decides the mode (.html, .htm, .js, .mjs, .css).
Features
- Three-layer cipher pipeline. Source goes through a keyed XOR + ROT + XOR stream step, then AES-256-GCM in the middle, then AES-256-GCM on the outside. The outer key is derived with PBKDF2-SHA256 (default 250,000 iterations) from a password that is itself masked against the runtime integrity hash.
- Split and scattered keys. Each symmetric key is chopped into numbered chunks stored in
Object.create(null)dictionaries and only reassembled in the derived sort order at decrypt time. Ciphertext is fragmented into[index, bytes]pairs that are shuffled and interleaved with decoy and junk statements, so a static scan of the payload finds no contiguous key or cipher blob. - Decoy key triples. Several fake
(key, salt, iterations)tuples sit alongside the real one. Only one index is ever used at runtime, but an attacker who wants to brute-force the password has to try every tuple. - Integrity binding. When on (default for HTML), the outer password is XOR-masked with a SHA-256 of a sentinel-guarded junk region in the emitted bootstrap. Any byte changed between the sentinels breaks key derivation, which breaks AES-GCM authentication, which breaks decryption. Tampering is caught before any plaintext is exposed. AES-GCM auth tags still protect ciphertext integrity when the sentinel-mask check is off (the default for external
.js/.css, wheredocument.currentScript.textContentis empty). - Anti-debug loop. A rotating
new Function('debugger')is built with a randomized//# sourceURL=every call, so Chrome's "Never pause here" can only mute one invocation before the next fresh file shows up. Timing-delta checks (debug-pause slowdowns) and window-size deltas feed into strike counters that have to trip repeatedly before action. - Multi-signal devtools detection. Weizman-style
Object.defineProperty(div, 'id', { get: ... })getter trap. Counter-basedfuncTrap.toString,dateTrap.toString, andregTrap.toStringoverrides that fire the moment the inspector renders a preview.console.tablevsconsole.logtiming delta (CDP serialization is about 10x slower when devtools is attached).console.timeStampCDP side-channel (attached devtools emits an inspector event per call). These survive "Deactivate breakpoints" and "Never pause here". - Env guard. Blocks execution under Node, headless runners, and contexts missing
window,document,navigator,crypto.subtle,TextEncoder, orTextDecoder. Makes server-side prerendering and dumb scrapers fail loudly before decrypt. - Opaque predicates. Always-false guards emitted inline at the top of the bootstrap. Trivially true at runtime, non-trivial for static analysis tools that do not constant-fold.
- Junk interleaving. Generated random functions, loops, and predicates are spliced between every real statement at a tunable
junkRatioso the decompiled flow is buried under noise. - Kill switch. When the anti-debug or devtools trap fires the bootstrap slots
debugRewriteHtmlintodocument.documentElement.innerHTML, callswindow.stop(), nullswindow.opener, and navigates tocloseUrl(orabout:blankif unset). When the integrity / tamper trap fires it slotsintegrityRewriteHtmlinstead and throws. Both fall back to the legacyrewriteHtmlif unset. - Streaming-safe middleware. Wraps
res.writeandres.endso the full body is buffered only when it is going to be rewritten. Responses bigger thanmaxBytes(default 5 MiB), pre-compressed responses (Content-Encoding: gzip,br, etc.),HEAD/OPTIONSrequests, and 204 / 205 / 304 statuses flush straight through.ETagis removed from rewritten responses because the body is no longer byte-stable across requests. - Include / exclude matching. Per-URL allow and deny lists accept regexes, substrings, functions, or arrays of those. Evaluated as include-first, then exclude.
- Source map scrubbing.
/*# sourceMappingURL=... */and//# sourceMappingURL=...lines are stripped before obfuscation so an attacker cannot pivot from the payload to the original source.
How it obfuscates
Every outbound HTML, JS, or CSS response goes through the same pipeline. What changes between the three modes is the final bootstrap wrapper (how the decrypted plaintext gets back into the page).
- Strip source maps.
sourceMappingURLcomments are removed from the source. - Layer 1, XOR + ROT + XOR. A pair of random keys and a byte-shift are generated. The plaintext is XORed with key K1, shifted, then XORed with key K2. This is a cheap stream step whose only job is to scramble the bytes before they hit AES, so the inner ciphertext cannot be pattern-matched to the plaintext structure.
- Layer 2, AES-256-GCM (middle). Encrypt layer-1 output with a random AES-256 key and IV, prepending the IV and appending the auth tag.
- Layer 3, AES-256-GCM (outer). A fresh 32-byte password is generated. PBKDF2-SHA256 with a random salt and 250,000 iterations (configurable) derives the actual AES key. Layer-2 output is encrypted under that key with a random IV, again prepending IV and appending auth tag.
- Mask the outer password against runtime integrity. A block of junk statements is wrapped between two random sentinels and emitted into the bootstrap. The outer password is XORed with SHA-256 of that sentinel-guarded region. At runtime the bootstrap finds the region via
indexOf(startSentinel)ondocument.currentScript.textContent, hashes it, and unmasks. Any tampering between the sentinels changes the hash, which yields the wrong password, which fails the AES-GCM auth tag. Off by default for external.js/.cssbecause an external script'sdocument.currentScript.textContentis empty. - Split keys into scattered chunks. Each of the three key blobs (K1, K2, middle-AES key) is cut into N chunks (default 8). Each chunk is stored as a numeric-keyed entry in an
Object.create(null)dictionary. At decrypt time the bootstrap doesObject.keys(...).sort()to rebuild the key. - Fragment the ciphertext. Layer-3 output is chopped into randomly-sized slices (default between 24 and 48 bytes each). Each slice becomes a
[originalIndex, bytes]pair. The pairs are shuffled. The decrypter sorts them back byoriginalIndexbefore concatenating. - Emit decoy key triples. Several fake
(maskedKey, salt, iterations)entries are generated and put in an array alongside the real one. A singlerealIdxpoints at the one that actually works. Only the real index is used at runtime, so decoys cost the runtime nothing; a brute-forcer has to try every triple. - Interleave junk. Every real declaration is surrounded by generated junk functions, loops, conditionals, and opaque predicates at the configured
junkRatio. The result is a pile of statements in which the real logic is a minority. - Emit env guard. Synchronous guard at the top of the IIFE. Throws and wipes the page if
process.versions.node,global.process, or missing browser globals indicate a non-browser runtime. - Emit opaque predicate. Always-false expression (
!((a*b) % a === 0)) wrapped around anonTamperbranch. Trivially dead code at runtime, looks alive to static analyzers. - Emit sentinel-guarded junk region. Between two random
/*START*/and/*END*/markers used by the integrity hash. - Emit anti-debug and devtools traps. A multi-signal setInterval loop (see Features). Every signal has a grace period and a two-strike threshold so spurious browser work does not false-positive.
- Emit decrypt pipeline.
UNMASK,DERIVE(PBKDF2),AES(AES-GCM viacrypto.subtle.decrypt),DECRYPT_ALL(puts all three layers together), andWIPE(zeroes out every typed array once decryption is done). - Emit bootstrap. An async IIFE that
awaitsDECRYPT_ALL(), callsWIPE(), runs the mode-specific output step, then nulls the plaintext variable. Any exception falls into the catch, which triggersonTamper(wipedocumentElement.innerHTML, callwindow.stop(), rethrow). - Mode-specific output.
- HTML: parse the plaintext with
DOMParser, copy head children viaimportNode, setbody.innerHTML, then clone every<script>into a fresh element withdocument.createElement('script')so the browser actually executes them. Post-load-safe, unlikedocument.write(), which Chrome silently drops from async callbacks after the parser has finished. - JS: indirect eval via
(0, eval)(plain)so the decrypted code runs in global scope, not inside the bootstrap's closure. - CSS:
document.createElement('style'), settextContent, append todocument.head. The middleware rewrites theContent-Typeheader on the way out so the response is served asapplication/javascript.
- HTML: parse the plaintext with
- Minify and wrap. The whole script is collapsed onto a single line (comments stripped, whitespace squashed around operators and punctuation) and shipped inside the delivery wrapper: an inline
<script>in a stub HTML document for HTML mode, or the raw minified script body for JS / CSS mode.
The effect is that the response body an attacker downloads contains no plaintext, no contiguous keys, no contiguous ciphertext, no readable structure, and triggers its self-destruct sequence the moment anyone opens DevTools or runs it outside a real browser.
Configuration
Full option list lives in index.d.ts (TypeScript types) and is also accessible at runtime via normalizeOptions. Highlights:
| Option | Default | Meaning |
|---|---|---|
| antiDebug | true | Install the anti-debug loop. |
| devtoolsTrap | true | Install the devtools detection signals. |
| integrityChecks | true for HTML, false for external JS/CSS | Mask the outer password against a runtime SHA-256 of the sentinel-guarded region. |
| pbkdf2Iterations | 250000 | PBKDF2-SHA256 iteration count for outer-key derivation. |
| closeUrl | '' | URL the close sequence navigates to when a trap fires. |
| debugRewriteHtml | '' | HTML slotted into documentElement.innerHTML when the anti-debug / devtools trap fires. Falls back to rewriteHtml. |
| integrityRewriteHtml | '' | HTML slotted into documentElement.innerHTML when the integrity / tamper trap fires. Falls back to rewriteHtml. |
| rewriteHtml | '' | Legacy single-knob fallback used by both traps when the split options are unset. |
| junkRatio | 2.0 | Roughly this many junk lines per real line. |
| stripSourceMaps | true | Strip sourceMappingURL comments from the source. |
| keyFragments | 8 | Number of chunks each symmetric key is split into. |
| cipherFragments | [24, 48] | Min/max byte size of ciphertext fragments. |
| decoys | 2 | Number of fake (key, salt, iter) triples alongside the real one. |
| nameStyle | 'hex' | Identifier style for generated locals. |
| maxBytes | 5 * 1024 * 1024 | Skip rewriting for responses larger than this. |
| include | null | Only obfuscate URLs matching this predicate / regex / substring / array. |
| exclude | null | Skip URLs matching this predicate / regex / substring / array. |
| onError | null | Hook called when obfuscation throws. The raw body is served instead. |
| testMode | false | Disable every trap. Round-trip harness only. Do not use in production. |
Escape hatches
- Disable everything for a debugging session.
middleware({ ... })readstestMode: trueto skip traps. The example and stress server expose this via anINSPECT=1env flag (INSPECT=1 node server.js). - Skip specific routes. Pass
exclude: ['/healthz', '/metrics', /\.svg$/i]and those URLs bypass the rewriter entirely. - Large responses. Anything bigger than
maxBytesis streamed straight through. Tune up if you have legitimately large assets you want protected.
Programmatic API
const {
middleware,
obfuscateHtml,
obfuscateJs,
obfuscateCss,
normalizeOptions,
} = require('@niko122/middleware');
const encrypted = obfuscateHtml(htmlString, { antiDebug: true });All four accept the same option shape. middleware(options) returns a (req, res, next) => void handler usable with Express 4 / 5, Connect, Polka, and anything else that speaks Node's HTTP shape.
Caveats
- Obfuscation is not encryption in the cryptographic sense. The decryption key ships with the payload. The point is to raise reverse-engineering cost, not to prevent it outright.
- Anti-debug tripping on a legitimate user is painful. In production, consider making
closeUrlan informative page rather than a hard redirect. - CSS served as JavaScript means it cannot be loaded via
<link rel="stylesheet">. Keep that in mind when migrating an existing site. - PBKDF2 at 250k iterations costs tens to low-hundreds of milliseconds on the user's CPU at page load. Lower if you need faster first paint; higher if you want to punish brute-forcers more.
- Integrity checks only work when the emitted script is readable at runtime, which means inline
<script>contexts. External.jsand.csshave emptydocument.currentScript.textContentand must fall back to AES-GCM auth tags for tamper detection. The middleware handles this automatically.
License
MIT. See LICENSE.
