Nullock is, by design, a tool that handles untrusted bytes — captured HTTP traffic from the wire, responses from arbitrary upstreams, user-supplied JS extensions, regex rules, project files. This document covers what the codebase defends against, the audit findings that were fixed, and the known gaps.
| Actor | What they can do | What we defend against |
|---|---|---|
| Local user (running Nullock on their machine) | Trusted. Owns the project, can install extensions, can run any code. | Not the adversary. |
| Malicious upstream HTTP server | Sends arbitrary response bytes to a request the user proxied through us. | Buffer overflows, CRLF injection into captured cookies that we re-inject, regex catastrophic backtrack via response body, OOM via oversize body / chunked. |
| Malicious local web page (loaded in the user's browser) | Can fetch() 127.0.0.1 URLs from JavaScript, embed <img src> to GET endpoints, submit forms cross-origin. |
CSRF against /api/* writes, exfiltrating captured session/cred state. |
| Network attacker between us and a real origin | Replaces an upstream server's bytes in transit (e.g. by spoofing DNS or MITMing a Cloudflare-fronted host). | Treated identically to "malicious upstream" — we don't currently verify upstream TLS chains, so this is not fully mitigated (see "Known gaps"). |
| Malicious extension author | Drops .js files into the extensions dir. |
Limited — JS extensions are explicitly trusted by virtue of being installed; we validate the wire-bytes they produce but not their intent. |
These were caught and fixed during the multi-subsystem audit logged in commits 1732247 and d56174b.
- Origin check on every state-mutating endpoint. Same-origin (
http://127.0.0.1:<port>) OR an explicitX-Nullock-UI: 1custom header. EmptyOriginis no longer treated as trusted — that previously letfile://-loaded HTML and Electron-style wrappers slip through. - Method enforcement: read endpoints accept
GET; everything else returns405unless POST'd. Previously the path-based dispatcher accepted any method, so a<img src="http://127.0.0.1:17777/api/history/5/probe">could fire the active probe cross-origin. - Method allowlist at the request parser drops unknown verbs at the door.
- Active probe scope check — refuses to fire payloads (
',;id;#,../../etc/passwd, CRLF) at hosts that aren't in the project's scope. Previously a malicious local page could pivot Nullock into attacking arbitrary hosts the user had once browsed. - DNS rebinding defence — the control server validates the
Hostrequest header against the allowed set (127.0.0.1[:port],localhost[:port],[::1][:port]). A drive-by page that resolves an attacker-controlled hostname first to a public IP (to get the script loaded) and then to127.0.0.1(to talk to Nullock) gets rejected with421 Misdirected Hostbecause the browser still sendsHost: rebind.attacker.example.
- Explicit peer-cert verification on the MITM upstream socket and on the Repeater/Scanner/Replay socket.
setPeerVerifyMode(VerifyPeer)+setPeerVerifyName(host)set explicitly at the call site; ansslErrorshandler captures and surfaces the underlying reason without ever callingignoreSslErrors(). A network attacker between Nullock and a real origin can no longer present a forged cert and have us forward its decrypted bytes as "TLS" to the browser — the upstream handshake collapses and the tunnel dies.
- Cross-project session clear — switching projects fires
historyShouldClear, which now also wipesSessionManager. Cookies captured againsttarget-A.examplewhile pentesting Engagement A are no longer replayed into Engagement B's requests when the user switches.
- CL+TE smuggling defence. The proxy's request and response parsers refuse any message that carries both
Content-LengthandTransfer-Encodingheaders, or that carries duplicateContent-Lengthvalues with conflicting numbers, or aContent-Lengthwhose value isn't a single non-negative integer. A hostile upstream that frames a response two ways at once would otherwise let us pick one length and the browser pick the other, turning one captured response into two on the keep-alive socket.
- Slowloris on control server. Header read enforces a hard 10s wall-clock deadline per connection (separate from the per-
waitForReadyReadbudget) so a client dribbling one byte every 4.9s can't pin the main thread forever. Body read enforces a 30s deadline on the same basis. /api/searchReDoS. Patterns are capped at 4 KB. Patterns whose shape matches a nested-unbounded-quantifier heuristic ((...*)*,(...+)+,({n,})+, etc.) are refused before they hit the matcher. Each scanned body is truncated to 1 MB and the loop visits at most 500 rows. Qt's PCRE backend doesn't expose a match-timeout so this is best-effort, but it converts the textbook bombs into a 400.- nmap XML import: XXE / billion-laughs.
<!DOCTYPEand<!ENTITYin the body are refused up-front. Element nesting is capped at 64. QXmlStreamReader's default behaviour of ignoring external entities is the primary defence; these guards make sure a future Qt change (or a parser swap) can't silently re-open the hole. - WebSocket reassembly buffer. Per-stream
m_bufis capped at 2× the max frame payload (32 MiB). On overflow the parser drops its state and stops emitting frame events for that stream; the raw relay still forwards bytes so the user's app keeps working. Without this cap, 100 hostile streams declaring 16 MiB frames and dribbling bytes would pin 1.6 GiB.
- Toggle-race fix.
addPendingOnMainre-checksm_enabledunder the mutex; if the user (or a project switch) disabled intercept during the race window betweenpend()'s atomic check and the queued slot dispatch, the request is released as an immediate forward rather than parked inm_queueforever. Without this, the worker thread that calledpend()would block onp->doneindefinitely and the captured request bytes (including auth headers) would sit resident until process death.
- Repeater, Intruder, intercept queue clear on project switch. R2's
historyShouldClearwiring now also firesRepeater::clearAll,Intruder::clearAll, andintercept.forwardAll()/setEnabled(false). A request loaded into Repeater (with Engagement A's Authorization header) no longer survives a project switch to Engagement B. - Project store I/O race.
m_historyis now guarded bym_historyMutexacrossopen(),close(), andappendEntry(). A worker thread mid-write while the main thread closed the file would previously have written into a closedQFilewhose underlying FD may have been recycled by the OS to another open file in this process (CA private key, theme JSON). - Imported M&R rule quarantine. When loading a project's rules from disk, any rule whose host pattern is a catch-all (
*,.*, empty) AND whosefind/replacetouches a credential-shaped header name (Cookie, Authorization, Bearer, X-API-Key, etc.) loads withenabled = falseand a[QUARANTINED on load]tag in its comment. Defends against the project-file-from-a-DM exfil pattern, where a shared project shim drops a "duplicate Cookie into a new header" rule that any in-scope target then echoes back to the attacker.
- QtConcurrent task drain. Main returns via
QThreadPool::globalInstance()->waitForDone(5000)so in-flight port scan / probe / replay workers (whose lambdas capture raw pointers into the App-scope Wiring struct) get up to 5 seconds to finish before the stack unwinds out from under them.
- Owner-only ACL on
ca.key. After generating (or on every startup, for pre-existing keys) the CA private key file's DACL is rewritten to a single ACE granting only the current userGENERIC_ALL, with inheritance disabled. On POSIX this ischmod 0600. Anyone with the CA private key can forge certs for any host the user trusts — Nullock's installed CA is treated as a root by the user's browser, so a leaked key trivially produces TLS-green spoofs ofbank.comand the like.
- Centralized sensitive-header policy (
Authorization,Proxy-Authorization,Cookie,Set-Cookie,X-API-Key,X-Auth-Token,X-CSRF-Token,X-XSRF-Token,X-Session-Id,X-Amz-Security-Token,X-Goog-IAM-Authorization-Token) is applied uniformly to:- HAR export (default on;
redact:falsein the POST body to opt out) - Postman collection export (default on;
?raw=1query to opt out) - "Copy as curl / wget / httpie / powershell / fetch" renderers in the UI Lets a tester share a HAR with a triager or paste a "copy as curl" into a bug report without also sharing their session.
- HAR export (default on;
- NDJSON query-string suppression — the
--ndjsonevent stream strips?token=…frompathandurlfields by default. A tester piping--ndjsoninto a log file (or sharing a screenshot of their terminal) no longer leaks bearer tokens out of band. Pass--ndjson-include-queryto opt in to the raw query string.
- Project names validated against
[A-Za-z0-9_\- .]{1,64}with Windows reserved-name (CON,NUL,COM1-9,LPT1-9,AUX) blocklist, no leading/trailing dot or space, no NUL byte, no control chars, no... - Theme names same validation.
saveTheme("../../../poison", ...)would have written outside the themes dir; now refused. - HAR import path refuses UNC paths, requires the target to be a regular readable file, caps at 256 MB.
- Static-file
safeJoinstrips leading separators and refuses..substrings.
- Session manager strips
\r,\n,\0, and C0 control bytes from capturedSet-Cookienames and values before storing — preventing a hostile upstream from embedding header splits that we'd replay on subsequent outgoing requests for that host. - JS extension boundary validates every returned method / path / header / status / reason-phrase. Header names must be RFC 7230 tokens; values reject
\r,\n,\0, other C0; method must beA-Z+; status clamped100..599; path rejects whitespace and CR/LF. Invalid values fall back to the pre-mutation original. - crt.sh recon validates the domain to
[A-Za-z0-9.\-]{1,253}before composing the request line.
- Control server
/api/*body size capped at 64 MB;Content-Lengthvalidated (no negative, no overflow) with413returned on excess. - HttpClient response body capped at 128 MB across
readUntilClose,readExact, and chunked decode; negative or oversize chunk sizes refused. - Session manager cookies per host capped at 256 (LRU drop-oldest).
- Recon wordlist capped at 2000 entries per request to bound concurrent UDP DNS sockets.
- crt.sh response parse capped at 32 MB.
- Port scanner
parallelclamped[1, 256],timeoutMsclamped[50, 30000],throttleMsclamped[0, 60000], total tasks (hosts × ports) capped at 100,000.
- Match & Replace patterns now pre-compiled once at
setRules()time. Patterns over 4 KB are refused. Pre-compiling means a CPU-pathological pattern only burns CPU during rule-edit, not on every captured request. Qt's PCRE backend doesn't expose a match-timeout, so a deliberately catastrophic pattern ((a+)+$) is still a CPU DoS during traffic; we mitigate via the pattern-size cap and treat this as "you own the rules you write."
- Hostname validation before invoking openssl: refuses leading
-(option-parsing trap), leading_, control bytes, anything outside[A-Za-z0-9._-]{1,253}, leading/trailing dot,... - Arguments to
opensslgo throughQProcess::setArguments(no shell interpolation). - Subject and SAN ext file content are confined to validated hostnames.
These were surfaced by the audit but not addressed in this pass. Listed so the next person reading the code knows what's open and where to start.
QThread::createinproxy_server::onNewConnectionlifetime on shutdown.~ProxyServerdoesn't track its own worker threads (only the QtConcurrent ones are now drained via the global pool in main). Practical impact remains "crash on shutdown only," but a future fix should track each connection's QThread and join in the destructor.
- Per-handler thread pool on control server. Slowloris is now bounded by the wall-clock deadlines added in this pass, but
handle()is still synchronous-on-main. A single slow request still blocks others up to its deadline. Move handlers onto a thread pool to fully decouple.
If you're a security researcher and you've found a vulnerability that isn't already on the "known gaps" list above:
- Preferred: open a private security advisory on GitHub (Security → Advisories → "Report a vulnerability"). This routes to maintainers without going public.
- Backup: email the maintainer (see GitHub profile) with a subject starting
[SECURITY]. - Public Discord / Twitter / Mastodon: please don't. Public disclosure before a fix puts every Nullock user at risk.
- Affected version (run
nullock --versionor check About → Version in the UI) - Operating system
- Repro steps -- shortest possible
- Impact -- what does an attacker get? Local code execution? Data read? Account takeover?
- A proof-of-concept request / script if available
- Whether you intend to publish a writeup, and if so, a target date
We aim to:
| Step | Target |
|---|---|
| First human response | within 72 hours |
| Severity acknowledgement | within 7 days |
| Patch released (Critical) | within 14 days |
| Patch released (High) | within 30 days |
| Patch released (Medium/Low) | within 90 days |
| Public advisory published | coordinated with reporter, typically 30-90 days after patch |
We're a small team. If we miss an SLA target, we'll tell you why and ask for an extension. We won't ignore you.
We don't run a paid bug bounty yet. Once we have hosted services (paid tier), we plan to set one up via HackerOne. For now, our thanks + a CVE credit + a Hall of Fame entry in SECURITY.md are what we can offer.
In scope for reporting:
- Anything in the FOSS desktop app at
github.com/Bikebrainz/Nullock - Built-in extensions shipped in
extensions/ - The browser extension at
browser-ext/ - The marketplace catalog
- Anything in the lab apps under
labs/(as bonus -- those are intentionally vulnerable)
Out of scope:
- Theoretical attacks against the threat model (we already accept malicious upstream traffic by design; please find real bugs in the parsers / scope guards, not "if you bypass scope you can probe an out-of-scope host")
- Reports against
gratonic/nullockupstream (different repo, different maintainers) - DoS by sustained CPU usage (we don't claim to be DoS-resistant against arbitrary user-configured regex / wordlists)
None yet. This space will list resolved advisories with CVE IDs and credits as they accrue.
This section of the README is about what we plan to harden next; the "Known gaps" list above is the current ledger.
For the things on the "known gaps" list, PRs welcome — but please touch one item per PR and include a regression test or repro script.