No description
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
2026-08-30 14:42:36 +02:00
build_nginx_map.sh Téléverser les fichiers vers "/" 2026-08-30 14:30:15 +02:00
create_all_jsps.sh Téléverser les fichiers vers "/" 2026-08-30 14:30:15 +02:00
create_logged_jsps.sh Téléverser les fichiers vers "/" 2026-08-30 14:30:15 +02:00
README.md Actualiser README.md 2026-08-30 14:42:36 +02:00

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 (if logged_jsps.txt was supplied) — logged .jsp requests 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 two server{} blocks: one conditional (only emitted if zimbraReverseProxyStrictServerName is enabled) and one unconditional. Check both.
  • nginx.conf.web.https.template — generated once per configured domain (multi-domain / SNI virtual hosting via zmprov). 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't proxy_pass to 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.template
  • nginx.conf.web.https.template

It'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_name kept returning 404 instead of 403, because it was served from nginx.conf.web.https (the per-domain template) while only nginx.conf.web.https.default had 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.txt by 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 from zimbra_jsp_whitelist.map.conf (or from all_jsps.txt before rebuilding) and redeploy.
  • phantom_hits.txt is 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.