- Shell 100%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
| build_nginx_map.sh | ||
| create_all_jsps.sh | ||
| create_logged_jsps.sh | ||
| README.md | ||
Zimbra web firewall - JSP Whitelist Toolkit
Scripts to build an nginx whitelist of legitimate Zimbra JSP files, for a
multi-server Zimbra infrastructure (nginx/zimbra-proxy on one host,
mailstore/jetty on one or more other hosts). Blocks direct requests to any
.jsp path that isn't part of the genuine Zimbra install — hardening
against exploitation of undocumented/debug JSPs and webshell drops.
Key lessons baked into this toolkit
Two things turned out to matter more than expected while building this, and both are already handled by the scripts below:
1. Filesystem path is NOT the same as URL path.
A webapp's directory name on disk isn't necessarily its URL prefix. In a
typical Zimbra install, the main zimbra (webmail) webapp is deployed at
the URL root, even though its files live under
.../webapps/zimbra/public/ — so login.jsp is actually requested as
/public/login.jsp, not /zimbra/public/login.jsp. The zimbraAdmin
webapp, by contrast, commonly does keep a /zimbraAdmin prefix.
service has no public subfolder at all. create_all_jsps.sh lets you
specify the real URL path for each directory explicitly, rather than
assuming it matches the filesystem layout.
2. Log traffic can't confirm legitimate JSP usage.
Zimbra's web client reaches most public/*.jsp files via internal
server-side forwards, not distinct browser-initiated HTTP requests — the
access log only ever sees the outer URL a user navigated to, never a
separate request for login.jsp itself. Meanwhile, the only traffic
that does show up as raw .jsp requests in the log is almost entirely
automated scanner/webshell probe noise. So "used in traffic" and
"legitimate" don't correlate here. build_nginx_map.sh's intended mode is
--disk-only: whitelist everything genuinely shipped by Zimbra, and use
log data only as a separate monitoring signal (phantom_hits.txt), never
as a filter.
Scripts
| Script | Runs on | Produces |
|---|---|---|
create_all_jsps.sh |
Each mailstore (jetty) server | all_jsps.txt |
create_logged_jsps.sh |
The nginx / zimbra-proxy reverse-proxy server | logged_jsps.txt |
build_nginx_map.sh |
Wherever you assemble the two files | zimbra_jsp_whitelist.map.conf + review files |
All three are self-contained bash scripts with usage documentation in their header comments, and sensible Zimbra defaults if you don't pass any arguments.
Workflow
1. On each mailstore server — enumerate the real JSPs, with real URL paths
scp create_all_jsps.sh mailstore1:~/
ssh mailstore1
./create_all_jsps.sh
By default, scans and maps:
| Filesystem directory | URL path |
|---|---|
/opt/zimbra/jetty/webapps/zimbra/public |
/public |
/opt/zimbra/jetty/webapps/zimbraAdmin/public |
/zimbraAdmin/public |
/opt/zimbra/jetty/webapps/service |
/service |
Verify these defaults against your actual install before trusting them — routing can vary:
grep -B2 -A5 "zimbraAdmin\|location /service" \
/opt/zimbra/conf/nginx/nginx.conf.web.https.default
or check a real request in your browser's network tab / access log.
Override with dir:url_path pairs (comma-separated) if your routing
differs, or to add more webapps:
./create_all_jsps.sh "/opt/zimbra/jetty/webapps/zimbra/public:/public,/opt/zimbra/jetty/webapps/zimbraAdmin/public:/zimbraAdmin/public,/opt/zimbra/jetty/webapps/service:/service,/opt/zimbra/jetty/webapps/zimlet:/zimlet"
Multiple mailstores: run this on every one (patch levels can differ slightly), then merge:
scp mailstore1:~/all_jsps.txt all_jsps.mailstore1.txt
scp mailstore2:~/all_jsps.txt all_jsps.mailstore2.txt
cat all_jsps.mailstore*.txt | sort -u > all_jsps.txt
2. On the reverse-proxy server — capture what's being requested
scp create_logged_jsps.sh proxy-server:~/
ssh proxy-server
./create_logged_jsps.sh
Defaults to scanning /opt/zimbra/log/nginx.access.log* (plain and .gz
rotated logs both handled). Pass a different glob as the first argument if
your logs live elsewhere:
./create_logged_jsps.sh "/var/log/nginx/*access*.log*"
Extraction is format-agnostic (handles differing log_format layouts,
method casing, and quoting) and normalizes away any scheme://host
prefix your log format might include, so multiple legitimate domains
pointing at the same Zimbra install — and attacker payloads sent as
absolute-form request-targets — all collapse to a comparable local path.
Copy the result to wherever you'll build the map:
scp proxy-server:~/logged_jsps.txt ~/jsp_audit/
This file is not required for building the map (see mode below) — but it's still useful as an independent scanner-activity monitor.
3. Build the whitelist map
Put all_jsps.txt (merged, if multiple mailstores) and, optionally,
logged_jsps.txt in the same folder as build_nginx_map.sh, then:
./build_nginx_map.sh --disk-only all_jsps.txt logged_jsps.txt
(logged_jsps.txt is optional in --disk-only mode — omit it, or point
at a nonexistent path, and you'll just skip the phantom_hits.txt report.)
Always use --disk-only for Zimbra — see "Key lessons" above. An
--intersect mode (whitelist = on disk and logged) exists for
reference/other setups, but will produce a near-empty, broken whitelist
for a stock Zimbra install.
Produces zimbra_jsp_whitelist.map.conf, containing three cooperating
nginx map blocks, not one:
map $uri $zimbra_jsp_allowed { ... } # the actual whitelist
map $uri $zimbra_jsp_allowed_is_jsp { ... } # is this request a .jsp request at all?
map $zimbra_jsp_allowed_is_jsp$zimbra_jsp_allowed $zimbra_jsp_allowed_block { ... }
$zimbra_jsp_allowed_block is 1 only when a request both targets a
.jsp path and isn't whitelisted — every other request (any non-.jsp
path, or a whitelisted .jsp path) evaluates to 0. This design exists
specifically so the check can be deployed as a plain if in server{}
(see step 4) without needing its own location{} block. See the file's
own header comments for why that distinction matters.
Also produces, alongside the map:
accounted_for.txt— the exact list that went into the map.phantom_hits.txt(iflogged_jsps.txtwas supplied) — logged.jsprequests that don't correspond to any real file. Worth a periodic skim for scan/attack activity. Does not affect the map.
4. Deploy to zimbra-proxy
Do NOT wrap this in a location ~ \.jsp$ { ... } block. nginx checks
regex locations before plain prefix locations, so a bare
location ~ \.jsp$ would take priority over zimbra-proxy's existing
prefix locations (/zimbraAdmin, /service, /Microsoft-Server-ActiveSync,
etc.), silently hijacking all .jsp traffic away from its correct
per-context routing (different upstreams, headers, and settings) —
breaking things unrelated to JSPs entirely.
Instead, use a plain if inside server{}, before any location{} —
this runs during nginx's rewrite phase, ahead of location dispatch
entirely, so it can never shadow or be shadowed by existing routing.
Reassuringly, Zimbra's own stock templates already use this exact pattern
for a different purpose (blocking specific bad user agents via
$user_agent_block) — so this isn't a novel technique, it mirrors what
ships by default.
Copy the map file:
sudo cp zimbra_jsp_whitelist.map.conf /opt/zimbra/conf/nginx/includes/
sudo chown zimbra:zimbra /opt/zimbra/conf/nginx/includes/zimbra_jsp_whitelist.map.conf
Find every relevant template. Zimbra generates the HTTPS proxy config from more than one template file — check what exists on your version:
ls -la /opt/zimbra/conf/nginx/templates/ | grep -i 'web\.https'
Typically:
nginx.conf.web.https.default.template— the default/catch-all block(s). This file commonly contains twoserver{}blocks: one conditional (only emitted ifzimbraReverseProxyStrictServerNameis enabled) and one unconditional. Check both.nginx.conf.web.https.template— generated once per configured domain (multi-domain / SNI virtual hosting viazmprov). Look for!{explode domain(vhn)}at the top of the file as the tell.nginx.conf.web.https.mode-*.template— small HTTP↔HTTPS redirect fragments; these don'tproxy_passto the mailstore, so leave them alone.
In each relevant template, add the map include near the top of the
file (alongside Zimbra's own existing map $http_user_agent $user_agent_block { ... }, if present — each generated file gets its own
independent map declarations, so this is safe to repeat per-file, exactly
like Zimbra's own map already does):
include "/opt/zimbra/conf/nginx/includes/zimbra_jsp_whitelist.map.conf";
Then, inside every server{} block in that file, add the if right
next to Zimbra's own equivalent check — typically right after the
include ... mode-${web.mailmode}; line and before the first location:
include ${core.includes}/${core.cprefix}.web.https.mode-${web.mailmode};
if ($zimbra_jsp_allowed_block) {
return 403;
}
${web.login.upstream.disable} location = ${web.login.upstream.url}/
Important — do this in EVERY template that generates a
server{}block for this proxy, not just the one you happen to test against first. In practice this means both of:
nginx.conf.web.https.default.templatenginx.conf.web.https.templateIt's easy to edit one, test successfully against whichever domain/IP that one happens to serve, and conclude you're done — while a different domain or IP alias on the same box is actually being served by the other template's generated block, which never got the edit. This is exactly what happened during development of this toolkit: the fix tested fine on one IP alias, but a second alias for the same
server_namekept returning404instead of403, because it was served fromnginx.conf.web.https(the per-domain template) while onlynginx.conf.web.https.defaulthad been edited.
Regenerate and validate before restarting anything:
su - zimbra
zmproxyconfgen
grep -c "zimbra_jsp_whitelist" /opt/zimbra/conf/nginx/nginx.conf.web.https.default
grep -c "zimbra_jsp_allowed_block" /opt/zimbra/conf/nginx/nginx.conf.web.https.default
grep -c "zimbra_jsp_whitelist" /opt/zimbra/conf/nginx/includes/nginx.conf.web.https
grep -c "zimbra_jsp_allowed_block" /opt/zimbra/conf/nginx/includes/nginx.conf.web.https
/opt/zimbra/common/sbin/nginx -t -c /opt/zimbra/conf/nginx/nginx.conf
If nginx -t reports a duplicate map variable, one of your templates'
generated output is landing in a shared http{} context with another —
remove the duplicate include (keep the if in both) and regenerate
again.
If you have multiple domains configured, nginx.conf.web.https.template
is expanded once per domain into multiple server{} blocks within
that same generated file — confirm the if-count matches your domain
count, not just that it's present at all:
grep -c "zimbra_jsp_allowed_block" /opt/zimbra/conf/nginx/includes/nginx.conf.web.https
zmprov gad | wc -l
Restart only after the syntax test passes:
zmproxyctl restart
Verify — including that non-.jsp routing still works. Test against
every distinct domain name and every IP alias the server answers
on, not just one — a single successful test does not confirm the other
generated server blocks are also protected:
curl -sk -o /dev/null -w "%{http_code}\n" https://your-host/public/login.jsp # expect 200/302
curl -sk -o /dev/null -w "%{http_code}\n" https://your-host/some/backdoor.jsp # expect 403
curl -sk -o /dev/null -w "%{http_code}\n" https://your-host/service/soap -X POST # expect normal SOAP behavior, not 403
Repeat the "expect 403" check above against each domain name and each IP alias:
curl -sk -o /dev/null -w "%{http_code}\n" https://<domain-or-ip>/zimbraAdmin/some-backdoor.jsp
A 404 instead of 403 on any of them means that specific domain/IP's
server{} block is missing the if — go back and check which generated
file/template is actually serving it (grep for the domain name or IP
across /opt/zimbra/conf/nginx/nginx.conf.web.https* and
/opt/zimbra/conf/nginx/includes/nginx.conf.web.https* to find out),
then edit that template too.
Then actually log into webmail and the admin console through a browser. Since normal usage doesn't reliably show up in access logs for this app, a manual functional check matters more here than for a typical whitelist — don't rely on curl alone before calling it done.
Ongoing maintenance
Re-run after any Zimbra patch/upgrade (mailstore JSPs, and occasionally URL routing, can change):
# on each mailstore
./create_all_jsps.sh
# merge if multiple mailstores, then wherever you build the map:
./build_nginx_map.sh --disk-only all_jsps.txt
# redeploy
sudo cp zimbra_jsp_whitelist.map.conf /opt/zimbra/conf/nginx/includes/
zmproxyconfgen && /opt/zimbra/common/sbin/nginx -t -c /opt/zimbra/conf/nginx/nginx.conf && zmproxyctl restart
Re-run create_logged_jsps.sh + a fresh phantom_hits.txt periodically as
an independent monitoring check, on whatever cadence suits you — it's
decoupled from whitelist maintenance and doesn't gate a redeploy.
Notes / caveats
- Keep your template customizations tracked somewhere outside
/opt/zimbra, since a Zimbra upgrade can overwrite stock templates and silently drop them. - Review
accounted_for.txtby hand before trusting it — these scripts are mechanical; they enumerate and combine, they don't judge which JSPs are safe to expose. If you know a specific file shouldn't be reachable even though it ships with Zimbra, remove that line fromzimbra_jsp_whitelist.map.conf(or fromall_jsps.txtbefore rebuilding) and redeploy. phantom_hits.txtis a good candidate to feed into whatever log monitoring / alerting you already run, as an early signal of scanning activity against this host.- If you add more mailstores or more webapp directories later, re-check
the URL-path mapping for each new one individually — don't assume it
follows the same pattern as
zimbra/zimbraAdmin/service. - The if/include must be added to every nginx template that generates
a
server{}block for this proxy (see step 4) — missing even one is a silent, partial failure: some domains/IPs get protected and others don't, and a quick test against just one of them will look like success. Always test against every domain name and IP alias the server answers on before considering the deployment complete.