> ## Documentation Index
> Fetch the complete documentation index at: https://docs.dnzwithazee.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Leaked password checks

> Mark logins whose password appears in known data breaches, so your application can ask for a new one. Compared on the node, with the password never logged, stored or sent anywhere.

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](https://docs.dnzwithazee.com/proxy/dnz-proxy.md) 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:

```sh theme={null}
dnz proxy routes edit edge shop.example.com --leaked-passwords --leaked-path /login
dnz proxy routes edit edge shop.example.com --leaked-field user.password
dnz proxy routes edit edge shop.example.com --no-leaked-passwords
```

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](https://docs.dnzwithazee.com/proxy/waf-page.md)'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](https://docs.dnzwithazee.com/dashboard/prometheus.md).

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](https://docs.dnzwithazee.com/proxy/dnz-proxy.md) 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.

> [!WARNING]
>
> 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](https://arxiv.org/abs/2201.01174) 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.

| Source | Terms |
| - | - |
| [NCSC's list](https://www.ncsc.gov.uk/news/most-hacked-passwords-revealed-as-uk-cyber-survey-exposes-gaps-in-online-security), this [copy from SecLists](https://github.com/danielmiessler/SecLists/blob/master/Passwords/Common-Credentials/100k-most-used-passwords-NCSC.txt) | Contains public sector information licensed under the [Open Government Licence v3.0](https://www.nationalarchives.gov.uk/doc/open-government-licence/version/3/). SecLists is under the MIT license. Have I Been Pwned states no licensing or attribution requirements on Pwned Passwords. |

### 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.

```sh theme={null}
dnz proxy waf leaked-passwords --file company-list.txt   # --format plain|sha1 when the first line misleads
dnz proxy waf leaked-passwords --clear-file              # back to the default list alone
```

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:

| You do this | The filter becomes |
| - | - |
| Add a file | The default list (downloaded again) and this file. A file you gave before is gone, so give one file holding everything you want. |
| Add a file, and the default list can't be downloaded | **This file alone.** Your file is in use, and the page, CLI and API say *Your own list only: the default list couldn't be downloaded*, with the error. |
| Press **Download** while your own list is in the filter | Nothing. It is refused, as the default list would take your file's place. Upload the file again instead. |
| Remove your own list | The default list alone (downloaded again). If it can't be downloaded your file still goes: nothing is held and the error shows, the leader tries again every hour while a route has the check on, and **Download** tries now. Nodes go on with the filter they hold until there is a new one. |

> [!NOTE]
>
> 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](https://github.com/HaveIBeenPwned/PwnedPasswordsDownloader) 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:

```sh theme={null}
haveibeenpwned-downloader pwnedpasswords   # writes pwnedpasswords.txt, HASH:count per line
awk -F: '$2 >= 1000' pwnedpasswords.txt > common.txt
dnz proxy waf leaked-passwords --file common.txt
```

## 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.

```sh theme={null}
dnz proxy waf leaked-passwords         # the same
dnz proxy waf events --action leaked   # logins it marked
```

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](https://docs.dnzwithazee.com/proxy/benchmarks.md#what-each-feature-costs).


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.