Skip to main content
A login whose password appears in known data breaches reaches your application with the header DNZ-Leaked-Password: 1, so it can ask for a new password. The password is compared on the node, so no login waits on a network or an outside service. It is off by default, and it is a DNZ Proxy feature: Traefik and Caddy can’t read a request’s body without a plugin. They pass logins on unchecked, and the route dialog and the proxy’s warnings say so.

Turn it on

1

Open the route

In the route dialog, under Stop attacks, turn on Leaked password checks.
2

Name the login path

The paths, and below them, where logins are posted, such as /login.
3

Name the password's field

A form’s name (password, user[password]), or a JSON key (password, or user.password for one inside an object). password unless you say otherwise.
4

Read the header in your application

When DNZ-Leaked-Password: 1 arrives on a login that succeeds, ask the user for a new password.
Or from the command line:
Only a POST to such a path, with a form or JSON body, is read. Every other request on the route costs one check of a setting the node already holds.

What a match does

  • The request goes on to your application with DNZ-Leaked-Password: 1. The body reaches it byte for byte, whatever the check finds.
  • An event: filter the WAF page’s events by Leaked password. It shows the address, path and time, never the password.
  • A counter: Leaked passwords at the top of the WAF page, and in Prometheus.
By default it only marks: refusing a login is your application’s decision. To block or challenge such a login at the proxy, add a firewall rule with the condition Leaked password (in JSON: { "field": "leakedPassword", "op": "exists" }). A visit a rule acted on shows under that rule’s name in the events, and still counts. Saving such a rule is refused while a node too old to read it serves a proxy that would run it, naming the node.
Your application should trust the header only from the proxy. On a route with the check on, DNZ Proxy removes the header from everything a visitor sends (also dnz_leaked_password and the like, which some applications read as the same variable), on every path, so a visitor can neither forge nor hide it. Make sure nothing else can reach your application directly and set it, as with X-Forwarded-For.

The password stays in the visit

The password is read from the body, hashed with SHA-1 and looked up, and the node forgets it. It is never logged, stored, put in a note or event, or sent anywhere. Reports from the node carry only counts. Your application gets the body as usual.

What isn’t checked

The login goes on unchanged, without the header, when:
  • the body is over 16 KiB, compressed, or sent with Expect: 100-continue;
  • it takes over a second to arrive (the check has waited that long, and what has come goes on followed by the rest, as it comes);
  • it can’t be parsed, doesn’t hold the field, or the field isn’t a string;
  • it isn’t a form or JSON (a multipart/form-data login);
  • the node has no filter yet, or runs a version too old to check. The WAF page and the route dialog name such nodes.
These count as passed on unchecked. Chunked and HTTP/2 bodies are read like any other.

The filter

The node holds a compact, read-only binary fuse filter with 16-bit fingerprints: about 2.3 bytes per password, three memory reads per lookup, and one wrong match in 65,536 lookups. No password or hash can be read back out of it. It comes from DNZ: nodes never reach the internet for it, and only the nodes of a proxy with a route that has the check on get it. They keep a copy on disk for when DNZ is away. Turn off the last such route and each of them frees its memory and removes the file.

The default list

The 100,000 passwords seen most often in Have I Been Pwned’s Pwned Passwords, as the UK’s National Cyber Security Centre published them. It catches what attackers try first, not every leaked password. The leader downloads the one file, at a fixed version and checked against its hash, when the first route turns the check on, or when you press Download, and again each time you add or remove your own list. Only the filter is kept (about 230 KiB on each node). No password list is in DNZ or its releases.

Your own list

A file with one password or one SHA-1 hash per line (HASH:count too) is added to the default list: both are checked, in one filter. The page, the CLI and the API list what the filter holds, each list with its passwords and the total, where a password in both counts once. The two together may hold up to 20 million different passwords, which take about 45 MiB on each node with the check on, and building that takes DNZ about 440 MiB for as long as it lasts. Over the limit the file is refused with the numbers, and the filter in use stays.
DNZ keeps only the filter, never your file or a hash from it, and a filter can’t be added to or taken apart. So every change builds a new filter from what is at hand:
Without internet access, or with GitHub out of reach, a file is the whole filter. To get both once DNZ can reach it, upload the same file again: DNZ doesn’t have it any more, so it can’t do that by itself. On a server that never reaches the internet, add the default list’s lines to your own file.
For more than the default list, run Have I Been Pwned’s downloader yourself once (over a million requests, which is why DNZ doesn’t do it from every install) and keep the lines with the highest counts:

See what it does

Proxy → WAF → Leaked passwords shows what the filter holds, its size, last update and terms, the routes with the check on and what each node serving them checked.
Assistants can read it with the MCP tool proxy_waf_leaked_passwords; supplying a list is left to people.

What it costs

Off, a request on a route costs one check of a setting it already holds. On, a request elsewhere on the route costs that and removing the header; a login waits for its body, then one SHA-1 and three reads. The node’s memory grows by the filter’s size. Measured on one CPU: a login costs about 5.5 µs more CPU (7–10% fewer logins a second at the most a node handles); other requests on the route, nothing measurable. With the default filter a node holds about 1 MiB more; with the largest (20 million passwords) about 43 MiB more, and a login about 12 µs more. Details in Proxy benchmarks.