Skip to content

Hardening

Postulo is built to be as secure as it knows how to be; this page is what the operator adds around it. None of it is required to run, all of it is worth an hour.

The reverse proxy

Terminate TLS in front of Postulo and forward X-Forwarded-Proto; Postulo redirects to HTTPS, sets HSTS for a year, and marks its cookies Secure.

X-Forwarded-Proto is an ordinary request header, so anything that can reach Postulo directly can send it and claim a connection it does not have. Postulo therefore believes it only from an address you have named as your proxy's, and until you name one, only from this host. A request from anywhere else has that header — and X-Forwarded-For, X-Forwarded-Host and the rest — removed before anything reads it.

So name it, in POSTULO_TRUSTED_PROXIES, as the narrowest range that works:

  • A proxy on the host, reaching the container's published port. Inside the container it arrives from the Docker network's gateway, 172.18.0.1 or thereabouts, so name that network: POSTULO_TRUSTED_PROXIES=172.18.0.0/16.
  • A proxy container in the same Compose project. It arrives from that network too; the same line, or the container's address if it is fixed.
  • A proxy on its own machine. POSTULO_TRUSTED_PROXIES=203.0.113.7/32.
  • No proxy at all. POSTULO_TRUSTED_PROXIES= trusts nothing, which is right, and you should also turn POSTULO_SSL_REDIRECT off and read the note beside POSTULO_SECURE_COOKIES — an instance on plain HTTP has other decisions to make.

Server settings → Overview shows the address the current request came from and whether Postulo trusted it, so you never have to guess: open it through the proxy, read the address, name it. An instance behind TLS whose proxy is not trusted redirects itself in a loop, because every request looks like plain HTTP; that is the symptom to recognise.

Every private network used to be trusted by default, which fitted the usual arrangement and also trusted every other host on the LAN, every container on the same bridge and, under rootless Docker or Podman, every client on the internet, since all of them arrive from a private gateway address. Any of those could set X-Forwarded-For and choose the address the sign-in limits counted them under. Naming the proxy is one line, and it is the line that closes that.

Believing X-Forwarded-For from a proxy has a second effect worth knowing about: without it, every request appears to come from the proxy, so the sign-in rate limits count the whole instance as one visitor and a persistent stranger could use up everybody's allowance. With it, they count each person separately, as intended.

Add at the proxy what an application cannot do for itself:

  • Rate limits on /accounts/login/, /accounts/signup/, /accounts/password/ and /api/v1/ — Postulo limits sign-in attempts itself, but a proxy limit is cheaper and earlier.
  • A body size limit a little above the largest upload you expect (uploads are capped at 20 MB, avatars at 5 MB, Europass files at 5 MB, spreadsheets at 2 MB, a candidate file at 1 MB).
  • Never serve data/media yourself. Uploaded CVs are delivered only through a view that has checked who is asking. If you use X-Accel-Redirect or X-Sendfile, mark the location internal and add the file headers there, because nginx does not carry them over from Postulo's response:
location /protected-media/ {
    internal;
    alias /srv/postulo/data/media/;
    add_header Content-Security-Policy "default-src 'none'; sandbox" always;
    add_header X-Content-Type-Options nosniff always;
}

The Apache equivalent, with XSendFile on and XSendFilePath /srv/postulo/data/media:

<Directory /srv/postulo/data/media>
    Header always set Content-Security-Policy "default-src 'none'; sandbox"
    Header always set X-Content-Type-Options nosniff
</Directory>

Postulo hands a file to the proxy only if it is an inert type — a PDF, a Word or OpenDocument file, a picture that is not markup, an archive. An SVG, HTML, XML, a script, and any type it does not recognise are streamed by Django, which sets the policy and nosniff on them itself, so the types the policy exists for do not depend on this block. - Two pictures are in the database, not under data/media. A profile picture, the copy of a Gravatar and a company logo are rows that the database deletes with their owner, and a web worker answers each one: a proxy is never handed them. What each adds to the database is bounded by what it is allowed to weigh once stored: at most 1 MiB for a raster picture (re-encoded as PNG within that budget) and 512 KiB for a vector logo. A person with a picture and a Gravatar copy adds at most 2 MiB, and each company at most 1 MiB. The lists never read the bytes, only the page that draws one asks for it, and the browser keeps the answer for a day. - The security headers Postulo sends (a strict content security policy, nosniff, same-origin referrer, DENY framing) can stay as they are — and check that your proxy passes them through unchanged, because some replace them. Traefik, on the maintainer's own test instance, rewrites X-Frame-Options: DENY to SAMEORIGIN and pins HSTS to a year whatever the application asks for. The framing case is not a hole, since frame-ancestors 'none' in the policy covers every browser that matters, but a header the application sets and nobody receives is worth knowing about, and no test of the source can find it. curl -sSI https://your-instance/ and compare with what the container sends. One page widens the policy, and only for images: the companies' map allows the tile server chosen under Server settings → Plugins → Maps, OpenStreetMap's by default, and its tiles alone send the instance's origin as the Referer (see Configuration). Do not loosen the policy to add analytics — there is no place for a third-party script in a page that shows somebody's employment history. The pages are served under that policy in a real browser by the test suite, which fails if any of them provokes a single violation, so it should stay quiet in yours too. If you point a reporting endpoint at it and see something, it is worth telling us about.

Secrets

  • POSTULO_SECRET_KEY: long, random, and never reused from another instance. Postulo refuses to start on one that is short, repetitive or an obvious placeholder — Django's own security.W009 thresholds, moved from a warning to a refusal, because this key does more here than sign sessions.
  • POSTULO_FIELD_KEY: the key that encrypts connection secrets (tokens for notifiers and stores). Keep it outside the database backup — a backup with both is a backup with the secrets in clear. Set it even if you never rotate anything: without it those secrets are encrypted under POSTULO_SECRET_KEY, which means you cannot ever change your signing key without losing them. Setting it now is what makes that possible later, and it is checked for strength the same way.
  • API tokens are shown once and stored hashed; a lost one is replaced, not recovered.
  • On the PostgreSQL compose profile the database container is handed the whole .env, both keys above included, though it only reads POSTGRES_PASSWORD (#791). Someone who takes over that container can therefore read the key that decrypts your connection secrets as well as the rows, which is the one place "the key is held outside the database" is not true. The SQLite default does not have it. To close it, give db only its password with an environment: entry and run every compose command with --env-file .env.

Backups

A backup holds everything. Encrypt it at rest (age, gpg, or an encrypted volume), keep it somewhere the web server cannot reach, and test a restore once. See Backups and your data.

The pages captures keep

Where keeping is switched on, a capture can keep the page it was read from, and that page was written by a stranger. What stops it being their code running as whoever opens it:

  • The source is never served as a page. It is kept gzipped under a name ending .txt.gz, shown inside Postulo as escaped text, and downloaded as text/plain, as an attachment, with X-Content-Type-Options: nosniff and a sandboxing content security policy. No address answers it as HTML, in a frame or out of one.
  • A rendering is a picture or a PDF, checked. One of PNG, JPEG, WebP or PDF, with its first bytes checked against the kind it was sent as, and answered under that kind only.
  • Drawing one here runs nothing. WeasyPrint draws it, in a process of its own with a time limit and a memory ceiling, through a fetcher that opens no address at all — not the network, not a file, not even a data: image the page carries. A browser is never used for this: it has a script engine and a network stack, and can only be asked to stand them down.
  • Sizes are bounded before anything is read: each file by its own cap, each account by POSTULO_CAPTURE_ACCOUNT_MAX_BYTES, and unconfirmed captures' pages by POSTULO_CAPTURE_PAGE_KEEP_DAYS. A token holding captures can send a rendering and cannot read one back.

They live in captures/<account>/ under POSTULO_MEDIA_ROOT, which is one more reason never to point your web server at that directory. They are in every backup and in each person's export.

Monitoring

/metrics serves Prometheus metrics: how many applications, listings and companies exist, how many document copies are waiting or have failed, whether the database answers and whether the migrations are applied. It is off, and while it is off that address is a 404 rather than a 403, so nothing tells a stranger the endpoint exists.

POSTULO_METRICS_ENABLED=true
POSTULO_METRICS_TOKEN=        # optional; see below

Nothing in it names anybody. Counts only: how many applications exist, never whose; how many copies are waiting, never for what. No label carries a person, a company, an application or a URL, and a test asserts it — a metric with somebody's identifier in a label is a record of what they are doing, exported somewhere else, and calling it monitoring does not change that.

That is why the token is optional here and mandatory for the log endpoint. Left empty, /metrics is readable by whoever can reach the instance, which is a sensible thing on a private network and a decision you should make deliberately; Server settings → Logs says which of the two is in force.

There are no request-rate or latency metrics, on purpose. The image runs three workers, and a counter living in one of them sees a third of the traffic — a graph quietly showing a third of the traffic is worse than no graph. Your reverse proxy already has those numbers, sees every request, and is the right place for them.

The log endpoint

/logs serves the same records as Server settings → Logs, one JSON object per line, for a collector such as Grafana Alloy or Vector running elsewhere on your network. It is off, and while it is off that address is a 404 rather than a 403, so nothing tells a stranger the endpoint exists.

If you turn it on, set a token:

POSTULO_LOGS_ENDPOINT_ENABLED=true
POSTULO_LOGS_TOKEN=$(python -c 'import secrets; print(secrets.token_urlsafe(32))')

With the endpoint on and no token, Postulo refuses to serve anything and records why. That is deliberate: an unauthenticated log endpoint is a data leak with a URL, and a forgotten variable should fail loudly rather than publish quietly.

Be clear about what this is. Metrics can be made to carry nothing about anybody. A log entry cannot: explaining that a delivery failed means naming the connection, and often the company and the application it was for. Turning this on is personal data leaving the instance over HTTP, to be treated the way you would treat a backup. If you can already read the container's stdout, do that instead — it is the same records and nothing new is exposed.

One thing the log does not hold is where Postulo's own requests went. The HTTP client would record every address it asks, at INFO: the posting somebody captured, a path on their own server, a webhook receiver whose address is the only secret it has. Postulo keeps it from doing so at every POSTULO_LOG_LEVEL. A log kept by a version before this still holds those lines until they rotate out; delete postulo.log* in POSTULO_LOG_DIR if staff or a collector should not read them.

The cache, and why it is not optional

Postulo counts failed sign-ins, password resets, captures, API calls and scrapes of /logs and /metrics in its cache, and turns somebody away once a count is too high. The default cache is a table in Postulo's own database, which matters for one reason: every worker reads and writes the same table, so "ten failed attempts a minute" is ten across the instance rather than ten per worker, and the count is still there after a restart.

The table is made by a migration; there is nothing to run.

If you have Redis or Memcached, POSTULO_CACHE_URL points at it and everything above still holds:

POSTULO_CACHE_URL=redis://localhost:6379/1

What you should not do is point it at a per-process cache — locmemcache:// — or at dummycache://. Both make Postulo look like it is enforcing a limit while enforcing it once per worker, or not at all.

What the counts cover. allauth's, on sign-in, sign-up and password reset, which have always been there. Failed sign-ins are counted per address they come from and per account: five in five minutes, whether the account was named by its username, by any of its addresses, in capitals or not, and by whichever name the instance was reached under. And Postulo's own, on the three things an account could otherwise do without limit: capture, the API, and the two token-guarded endpoints. Capture is the one that matters, because it lets anybody with an account choose an address your server then fetches — the other outbound connections (link checks, logo addresses, Find logo, webhook receivers, mail servers) are guarded the same way, see Where the server is allowed to dial — capture by POSTULO_CAPTURE_RATE, link checks and logo fetches by POSTULO_FETCH_RATE — and see Configuration for the numbers and how to raise them. Nothing in that group is reachable without an account, so none of it was ever a stranger's to abuse; it was a ceiling on the people you have already let in.

Single sign-on, and what it asks you to trust

With an OpenID Connect provider configured, somebody arriving through it is signed in as the Postulo account holding the address the provider gives, and the two are connected from then on. That is the convenience worth having, and it is worth knowing exactly what it rests on.

Postulo only ever matches an address the provider marked verified. An unverified claim links to nothing at all. So the security of the arrangement comes down to one question about your provider, not about Postulo:

Does this provider only call an address verified when the person actually proved they hold it?

For a Keycloak, Authentik, Pocket ID, Zitadel or Kanidm you run yourself, the answer is almost certainly yes, and there is nothing to do. For an endpoint you do not control, check before you rely on it: a provider that lets somebody type any address into their profile and calls it verified turns "sign in with single sign-on" into "sign in as anyone whose address you know".

If you cannot answer it, leave the door closed. Since 0.6.0 it is closed unless you open it:

POSTULO_OIDC_LINK_BY_EMAIL=false

Setting it to true opens it, and an instance with a provider that sets neither is warned at start-up (postulo.W002) that the default changed.

Then single sign-on signs in only accounts that have already connected the provider deliberately, from Settings → Connections. A person signs in with a password once and links it themselves.

Two related things, so the picture is complete:

  • POSTULO_OIDC_AUTO_SIGNUP is a different door, and it is shut by default: it governs whether the provider may create an account. Linking to one that already exists is what the setting above governs.
  • If a local account's own address was never verified, signing into it through the provider wipes that account's password. That is deliberate and it protects you: somebody who registered with your address before you did, and knows the password they chose, is locked out at the moment you first sign in. A verified local address is left alone.

When a second factor is asked for

Postulo asks for a code from an authenticator app whenever the account has one set up. Two sign-ins are treated differently, and it is worth knowing which and why.

A passkey never gets the prompt, and there is no setting for that. It is already two factors — the device somebody has, released by a fingerprint, a face or a PIN — so asking for a code afterwards is a second lock on a door that already has one. That friction is what makes people turn two-factor authentication off altogether, which leaves them worse off than before.

Single sign-on gets the prompt unless you say otherwise. Your identity provider has just done the checking Postulo is about to repeat, and on a company or university provider it very often did it with something stronger than six digits. But Postulo cannot see that: how the provider authenticated somebody is not in what it sends back. So it is your call, because you are the only one who knows what your provider actually enforces:

POSTULO_OIDC_IS_SECOND_FACTOR=true

or the switch under Server settings → Sign-in. Turn it on when you run the provider and know it requires a second factor of its own. Leave it off otherwise: with it on, anybody who gets through your provider is through Postulo, and the code that would have caught them is not asked for.

Two things it never does. It does not apply to a password sign-in: somebody with an authenticator app who signs in with a password is asked for the code, always. And it removes nobody's authenticator app — it changes when a code is asked for, not whether the account has one. Each person can see which of their own ways in are complete under Settings → Account.

Django's admin

It is off. POSTULO_ADMIN_URL is empty by default and nothing is mounted, so there is no /admin/ on a Postulo instance unless you put one there. That is deliberate: Server settings covers people, sign-in policy, plugins, email, logs and defaults, which leaves the admin as a developer's convenience — and on a self-hosted box, mostly a second and less careful way into the same data, at the first path anybody would try.

Where it is mounted its pages are Django's own, and they say their language Django's way: pt-br where every page of Postulo's says pt-BR. It is the same language tag.

It was not always off. Before 0.3.0 it defaulted to admin/, which published Django's own username-and-password form on every instance whose operator had not read one line of the settings file.

If you want it, set a path of your own:

POSTULO_ADMIN_URL=back-office-7f3a/

It holds accounts, invitations and the metadata of API tokens, and nothing that belongs to a person. Appointing an administrator from Server settings → People does not make them a superuser, so a co-administrator finds the admin empty; the flag you set on your own account (or that createsuperuser set) is the only key, and it opens every account, passwords and flags included. Before 0.5.0 every appointed administrator got it, and every member's applications and documents were in the admin too, with edits that skipped the event log.

An administrator is trusted with the instance whatever the admin shows, because they can install plugins and a plugin is code with the whole database. Appoint only people you would trust with it.

Two things to know when you do. The admin has no login of its own: anybody who is not signed in is sent to Postulo's sign-in and comes back afterwards, so an administrator is asked for the code, has to have a confirmed address, and is held to the same limits on failed attempts as everybody else. Until 0.5.0 it had Django's own form, which took a password and nothing more, whatever second factor the account had. And a path is not a secret: treat it as one fewer thing being guessed at, not as protection. Put the admin behind your VPN or an authentication layer at the proxy if you keep it.

Accounts

  • Leave registration closed unless you mean to run a shared instance; invite people.
  • Turn on two-factor authentication for every administrator (Settings → Account).
  • Appoint administrators you would trust with the database: they install plugins.
  • Keep two administrators: the last one cannot be removed, deactivated or deleted, and an instance with one is an instance one lost password away from needing the console.

Plugins and connections

A plugin runs inside Postulo's process with everything Postulo can do, for every person on the instance. Install only plugins you have read or trust. Postulo refuses a package that is not pure Python, that declares no Postulo entry point, or whose dependencies would move one of its own, and it never updates anything by itself — but none of that is a judgement about what the plugin does once it is running. A plugin you are unsure about can be switched off without being removed.

A catalogue must be configured with a public key, and its index must be signed with that key before a single wheel is fetched; every wheel is then checked against the checksum the signed index carries. Add a catalogue only from people whose review you would accept.

That signature covers the plugin's own file and stops there. A plugin's requirements are fetched from PyPI when it is installed, and are whatever is served that day, together with whatever they need in turn — so trusting a plugin means trusting its dependency list, and the confirmation page shows you that list before anything is fetched. Postulo installs only built wheels, so nothing runs a build script on the way in, and records every package that arrived: open a plugin's entry under Server settings → Plugins to see what is actually in your instance.

Keep POSTULO_CONNECTIONS_ALLOW_PRIVATE off unless a plugin genuinely needs to reach a service on your own network — with it on, a public hostname that resolves to a private address is allowed through. It is the switch for what you set up on purpose, and it widens exactly these: plugin connections (a Paperless, a webhook, a calendar), plugin catalogues and the OAuth token requests a connection makes, and mail servers (the instance's own SMTP and a person's own outbox).

It does not touch what is public by definition. These refuse a private address however the setting is left, on the first request and on anything they are redirected to: capturing a posting from a URL and its robots.txt, checking that a portfolio link answers, company logos, Gravatar, the update check and Web Push to the browser's push service. If a logo or a Gravatar is on your intranet, switching this on will not bring it in; upload the picture instead.

Where the check does apply, Postulo connects to an address it has already approved rather than looking the name up again, so a record that answers differently a second later cannot send the request somewhere the check never saw. The site is still asked for by name, and its certificate is still checked against that name, and a redirect to the same site goes on asking for it by that name.

Keeping up

Subscribe to the repository's releases. A vulnerability disclosed in a dependency is handled as SECURITY.md describes: a patch release and a pinned issue. Upgrading is a docker compose pull && up -d for the registry image, or git pull, build and up -d for a source build (see Installing Postulo).

What a stranger is told

The foot of every page says Powered by Postulo to everybody and the version only to people signed in. The exact version is the first thing somebody probing an instance wants, because it says which published advisories apply, and a visitor who has not signed in has no use for it. /healthz answers a stranger with the status and the database only; the version goes to the container's own probe (a local peer) and to a signed-in administrator, so a monitor that needs the version should run beside Postulo or sign in as one. This is a courtesy, not a secret. What protects an instance is running a current release (Keeping up), not hiding which one it is.

The image and Debian's updates

Postulo's image is built on python:3.14-slim-bookworm and runs apt-get upgrade at build time, so a fresh build takes whatever security updates Debian has published. That is worth knowing for two reasons.

Rebuilding is how you get patched. A running container never updates itself; nothing in it reaches out for packages, by design. When Debian publishes a security update for something in the image — including packages Postulo never asked for, which arrive with Python and with Pango — the way to get it is to pull a newly built image, or to rebuild without a cache. Pulling the same tag you already have does nothing if the tag has not been rebuilt since.

A rebuild on a machine that has built before is answered by its cache. Docker keeps every layer it has built and hands it back for as long as the line that made it and the layer under it are the same, and neither changes when Debian publishes an update. So docker compose build, or up -d --build, on a machine that built Postulo last month gives you last month's packages and says nothing about it. Tell the build when you are building:

docker compose -f docker/compose.yml build --pull \
  --build-arg POSTULO_APT_REFRESH="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
docker compose -f docker/compose.yml up -d

The value is part of what Docker compares, so one it has not seen makes the layer that upgrades run again, and everything after it; the stylesheet and the Python environment above it are still taken from the cache. --pull asks for the base image as well. The layer's first line in the build output says which moment it was given, or that it was given none:

Debian's packages as of: 2026-09-29T08:40:12Z
Debian's packages as of: whenever this layer was last built

--no-cache gets you the same packages and rebuilds the rest too. The images the project publishes are built with a new moment on every run, the same one for the image that is scanned and the image that is pushed (#300). Until that was so, the dev images kept the packages of the day the runner first built the layer, and the scan that gates them is what found it.

The base image is not pinned to a digest, deliberately. Pinning would make two builds of the same commit produce the same image, which is a real thing to want. It would also mean Debian's updates arrive only when somebody remembers to bump the digest — and a manual step nobody performs is exactly how a fixable High-severity finding sat in the image unnoticed. Between an image that drifts towards being patched and one that reliably stays unpatched, Postulo takes the first. Each release is published by digest, so the artefact you actually run is still identified exactly.

Most findings in a Debian base image have no fix, and that is normal. Scanning the image will report dozens of HIGH and several CRITICAL entries that Debian has triaged as not warranting an update. They are not ignorable by choice; there is nothing to install. Scan with --ignore-unfixed (Trivy) or --only-fixed (Grype) to see the findings you can actually act on, which is usually none or one.

Where the server is allowed to dial

Postulo makes outbound connections on your behalf, and nearly every one of them goes somewhere somebody typed. Two rules cover all of it. What the core dials, and what turns each off:

What Whose address Turned off or bounded by
A capture, and that site's robots.txt (a redirected one is followed, five hops) The person who captures POSTULO_CAPTURE_RATE; POSTULO_CAPTURE_IGNORE_ROBOTS only changes what is asked
A link check on a CV, a company logo found by its typed address The person's The feature's own switch
Webhook deliveries, Web Push, a plugin's connection, mail servers The administrator's or the person's The connection's own switch
Single sign-on, OAuth token exchange The administrator's Leaving sign-in by password. OpenID Connect discovery, token and userinfo calls are made by allauth's own session, not the guarded client: the address is the operator's environment setting, never a page field (#815)
Gravatar, the update check Postulo's own list Off unless chosen (POSTULO_UPDATE_CHECK)
The GeoNames city table, at the first start of the container, and its postal codes when asked for (#702) A fixed project address POSTULO_SKIP_GEONAMES=1, or supply the table by hand; the postal codes only with POSTULO_GEONAMES_POSTCODES=1
A company's Wikidata item, when somebody presses Fetch from Wikidata on its page (#673) A fixed public address, www.wikidata.org; the item is the one the person recorded The wikidata feature under Server settings → Plugins; POSTULO_WIKIDATA_RATE per account. One item per press, a megabyte at most, three redirects at most and each held to that host, and nothing kept until the person says so
A company's entry in its register, when somebody presses Look up beside its register number (#1021): recherche-entreprises.api.gouv.fr (France), data.brreg.no (Norway), avoindata.prh.fi (Finland), ares.gov.cz (Czechia), api-krs.ms.gov.pl (Poland) Fixed public addresses, each declared by the adapter for its country; the number is the one the person recorded The register-lookup feature under Server settings → Plugins, off until switched on; POSTULO_REGISTER_LOOKUP_RATE per account. Only the number is sent; https only, no redirects, two megabytes at most, and nothing kept until the person ticks it
A company's LEI record, when somebody presses Look up in GLEIF (#1021) A fixed public address, api.gleif.org; the LEI is the one the person recorded The same feature and allowance
A register a plugin adds through postulo.register_adapters The hosts that plugin's adapter declares, and no other The same feature and allowance; the request is Postulo's, through the public-only client, and an adapter cannot take a country Postulo answers for
A publication's record, when somebody presses Look up beside its DOI (#688) A fixed public address, doi.org; the DOI is the one the person typed, in the path alone The publication-lookups feature under Server settings → Plugins; POSTULO_LOOKUP_RATE per account, spent even when the service fails. The public-only client, 2 MiB and ten seconds in all, no retry, nothing kept until the person saves
The registration agency's metadata service that doi.org redirects that request to (#688): Crossref's api.crossref.org, DataCite's data.crosscite.org, mEDRA's Chosen by doi.org, not by anybody on the instance The same switch and allowance; at most three redirects, each https and each approved by the guard like the first, so a redirect to a private address is refused unsent
A person's public works, when they press Suggest from my ORCID record (#688) A fixed public address, pub.orcid.org (ORCID's Anonymous API); the ORCID is the one stored on the person's own Your details, never one in the request The same switch and allowance. 4 MiB and ten seconds; at most 100 works listed and 20 added at a press, and nothing added unticked
Reinstalling plugins after an upgrade (manage.py plugins sync) The plugin record Not installing plugins

Publication lookups send an identifier and nothing else. No email address goes with them, the person's or the instance's, so they use Crossref's and DataCite's public pools, which a person pressing a button stays well inside. ORCID's terms bar using its public API in connection with a revenue-generating product or service: a household or a homelab is outside that, an operator selling hosted accounts may not be. That is yours to judge; the publication-lookups switch turns both buttons off, and off nothing reaches doi.org or ORCID.

The downloads of reference data (GeoNames, ESCO) go through the same guarded client as the rest, which checks every address and every redirect.

A capture URL is refused if it is private or local, always. That address came off a stranger's page, and a self-hosted instance sits on a network with a router, a NAS and whatever else at addresses a stranger would love the server to fetch for them.

Everything else — a plugin connection, a mail server — is refused if it is private, unless you have said otherwise. A Paperless on your own LAN is the entire point of a connection, and a mail relay in the same Compose network is the same case, so one switch governs both:

POSTULO_CONNECTIONS_ALLOW_PRIVATE=true

One decision, made once, by the person who owns the machine. The refusal names this variable when it happens, so nobody has to go looking for it.

POSTULO_EMAIL_HOST is exempt. A host set in the environment is a line in a file only you can edit — and the default it carries is localhost — so checking it would refuse the default configuration of every instance that has never opened the Email page. A host stored from Server settings → Email is checked. The exemption is for the instance's own transport only: a person's Your own email server is refused if it is private unless the switch above is on.

Three things happen on every one of these connections, and the third is the one that is usually missing:

  1. Private and loopback addresses are refused under the policy above. An IPv6 address that carries an IPv4 one — IPv4-mapped, or NAT64 under the well-known prefix 64:ff9b::/96 — is judged by what it carries, so 64:ff9b::a9fe:a9fe, which is the cloud metadata address on a host with NAT64, is refused. Ranges no public site answers from are refused whatever they hold: the rest of the reserved ::/8 (the IPv4-compatible and IPv4-translated forms are in it), a network's own NAT64 prefix 64:ff9b:1::/48, 6to4, Teredo, and the retired site-local range fec0::/10.
  2. Every address the name answers with is checked, not just the first. A name that resolves to one public address and one private one would otherwise be a way straight through. The connection then tries those addresses in order, as a browser would, so one that cannot be reached (a broken IPv6, a round-robin node that is down) does not fail it; only a failure to connect moves on.
  3. The connection goes to the address that was checked. A name with a one-second lifetime can answer publicly for the check and privately a moment later; resolving twice leaves exactly that window. The certificate is still proved against the name you typed, not against the number.

The refusal happens before anything is dialled. Every private address gets the same answer whether something is listening on it or not, because nothing ever finds out — which is what stops the Test button being a port scanner with a form around it.