mirror of
https://github.com/karatektus/pl_mail.git
synced 2026-10-25 01:54:10 +00:00
main
10
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
b453b79eb4 |
Single sign-on only: hide the password form and refuse password sign-ins (#210)
* Single sign-on only: hide the password form and refuse password sign-ins Adds Admin -> SSO 'Single sign-on only' (and OIDC_DISABLE_PASSWORD_LOGIN). The login page drops the password form and LoginFormAuthenticator refuses a password sign-in before looking the user up. It only applies while a whole provider is in force, the saving administrator must already be linked to the provider, and app:sso:allow-password-login brings the form back from the console. Config restore leaves it off. Off by default; one boolean column on oidc_config. Closes #169 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> * Single sign-on only: require a link to the provider being saved The admin form let the box be ticked by an administrator linked to any provider, including the one the page held before. Switching providers and ticking it in one save made a provider they had never used the only way in. It now compares the stored issuer with the one on the page, without a trailing slash, as OidcDiscovery does. Tests for what had none: the console command (clears the setting, keeps the provider), a restore over a row that had it on, the link to another provider, and a control showing the same password signs in with the setting off. The "right credentials" test now posts the right password. Docs: there is no iOS app. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Docs: put the iOS app back in the single sign-on page The previous commit dropped it on the wrong assumption that there is no iOS app. There is one. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com> Co-authored-by: Paul Lützner <karatektus@gmail.com> |
||
|
|
0591d9a854 |
Settings → Mail → Images: always show remote images (#124)
One per-user switch, off until somebody turns it on. With it on, MessageRenderer treats every sender as if it were on the image allowlist, so no message asks before loading its pictures. They still come through the image proxy, so a sender learns that a message was opened but not the reader's address. Mail in Spam stays blocked, as it does for trusted senders. Kept apart from the per-sender allowlist: no "Stop showing" line for a sender nobody trusted, and switching it off leaves those entries as they were. Stored in the user's settings bag and carried by a config backup. No migration. Closes #124. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
09c503c61c |
Sign in through an OpenID Connect provider (Authentik, Keycloak…) (#106)
plMail could only sign people in with a local password. On an install that already runs Authentik or Keycloak, that is one more account and password per person, outside the provider's access policy. A second authenticator on the `main` firewall, beside the password form. Both end in the same token for the same User, so the user checker, the login throttle and TOTP two-factor apply unchanged. Authorization code flow with PKCE, state and nonce on league/oauth2-client's GenericProvider; endpoints come from the provider's discovery document. - Set up under a new Admin → SSO tab; the OIDC_* variables are the fallback for an install configured from its compose file, and a saved provider wins whole. This lets an administrator who points it at a provider of their own sign in as any account without a second factor; the security model says so. - The ID token's signature is not verified: it only ever comes from the token endpoint over TLS (OIDC Core § 3.1.3.7 step 6). So the issuer and endpoints must be https and no request to the provider follows a redirect. Accepting an ID token from anywhere else needs JWKS verification first. - An account is linked by (issuer, sub). The address is used for the first link only, and only when the provider reports it verified. - Auto-creation is off by default and creates an ordinary account with no usable password. - app:user:oidc-unlink forgets a link from the console, for somebody deleted and re-added at the provider. Migration: two nullable columns and a unique index on `user`, and a one-row table `oidc_config`. Nothing backfilled; reversible. Not run: Playwright on this branch. No automated test makes a round trip against a real provider; that was checked by hand on a local test stack. Closes #91 Co-Authored-By: Paul Lützner <mail@pluetzner.de> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
4d705a56da |
Keep remember-me tokens on the server, so signing out holds (#104)
* Keep remember-me tokens on the server, so signing out holds A remember-me cookie that carries only its own signature stays valid until it expires, and signing out only asks the browser to delete it. A request already on its way with it - the page's own background fetches - reached the server after the sign-out and signed the person straight back in. The tokens are kept on the server now (Symfony's DoctrineTokenProvider, table rememberme_token), one per device. Signing out deletes this device's token, so a late cookie opens nothing; other devices stay signed in. Symfony's CacheTokenVerifier keeps parallel requests that rotate the token from looking like theft. Existing signed cookies are no longer read: people who relied on "Keep me signed in" sign in once more. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Say what remember-me is now, wherever it was described Remember-me moved from a signed cookie to tokens kept on the server, and five places still said "signature-based, not stored": the handbook's security page, the config-backup page's account of why APP_SECRET stays behind, and three comments beside the code that depends on it. English and German both. RememberMeAcrossRestoreTest proved its point against SignatureHasher, which the firewall no longer uses. It now asserts what the property rests on today: the firewall holds the persistent handler, and a backup taken while a token row exists carries neither its series nor its value. One test added for the claim the docs repeat: a changed password ends the cookie, because the token carries a digest of the hash it was issued under. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Paul Lützner <mail@pluetzner.de> |
||
|
|
8f7d2cb073 |
Work through the first-impression feedback in #37
Eleven of the twelve items. The twelfth, a deleted label coming back after the next sync on IMAP, is left for an issue of its own: there a label is a folder, and what deleting one should do to the mail in it needs deciding first. A selection survives the pager. It was nothing but the ticked checkboxes, so "next page" replaced the rows and the selection with them. The rows that are not on screen are now remembered in assets/mail_selection.js, per list, and the toolbar, the label menu, the move menu and a drag all read the selection from there. The checkbox stays the truth for anything visible. The whole view can be selected from the select menu. It was only offered by a banner that appears once a page is fully ticked. And a whole-view selection no longer survives a row being unticked: the action still ran on everything. A sync no longer logs a stored message as one it could not build. MessageSyncer reads the UIDs a folder holds once, before it fetches, and a move made meanwhile files rows under UIDs that list does not have. The INSERT was refused on (mailbox, uid), rightly, and each message was then held back as a failure. The list is read again when a batch is refused. A pairing code and the JMAP address under App passwords carry the configured public address, and the browser's only when none is set. This reverses the earlier choice in DevicePairingController, which handed a phone paired at home a LAN address. Hiding quoted text gives the height back. scrollHeight cannot report less than the frame it is measured in, so the collapse reports the root element's box instead and holds other reports until the parent has resized. Mail in Sent shows its images without asking. Read off the Sent label and not the From line, which a forged mail fills in as it likes. Spam still wins. Smaller: placeholders are lighter than typed text, the actions on a connected integration are buttons on one baseline, Save signature and four composer buttons no longer hover to white on white, the Spam badge loses the accent, and German says "vor 12s". Refs #37 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
ebeaf8cffa |
fix(security): add a per-username login limit
Symfony's default login throttle counts per username+IP and per IP, so a guesser on many addresses got five tries per address at the same account, while the comment in security.yaml claimed it was per username. A custom LoginRateLimiter keeps both and adds a username-only limit (20 per 15 minutes, set above what a mistyping person reaches since it can also be spent by someone else). Comment and handbook corrected. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
dee4257d3f |
fix(security): check every hop of user-supplied URLs against private networks
The Nextcloud, Paperless and Immich drivers followed up to twenty redirects unexamined, the URL validator let any hostname through without resolving it (container names, nip.io, 127.1, 2130706433, mapped IPv6), and push endpoints were not checked at all. Requests to user-supplied URLs (the three drivers, CalDAV, ICS feeds, Web Push) now go through UserUrlHttpClient, which wraps NoPrivateNetworkHttpClient and re-checks every hop. The validator resolves the host. LAN integrations keep working through the existing INTEGRATIONS_ALLOWED_HOSTS and admin-pinned server addresses; push endpoints get their own PUSH_ALLOWED_HOSTS for the bundled ntfy, and never redirect. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
376cf4ae72 |
Add Paperless-ngx as an attachment source and a save destination
Connect it with an address and an API token and it appears beside the other services; nothing else in the UI had to change, because the picker, the destination chooser and the connect dialog are all driven by the provider taxonomy and the driver interfaces. TAGS ARE THE CONTAINER LAYER. A document archive has no directory tree — Paperless replaced folders with tags and expects the rest to be found by searching — so the picker's "descend into a container, pick a file" has nothing to descend into. The root lists every tag as a folder with the newest documents beneath it, and opening a tag lists that tag's documents. Correspondents and document types would serve equally well and are deliberately not offered: three sideways slices of one archive is a navigation problem, and search already crosses all three. Search is Paperless's own full-text index, so it reads the OCR layer as well as the metadata and a scanned invoice is findable by a line item nobody typed. That is the feature: there is no longer a reason to open Paperless, find a document and download it before writing the mail. Upload is asynchronous — post_document answers with a task id, not a document id — so upload() returns it prefixed. Ids are opaque outside the driver, which is what makes the prefix safe, and download() refuses a task id with a sentence a person can act on rather than letting it 404 later. ShareLink is not declared. Minting a Paperless share link is a permissioned feature with its own expiry semantics, not something to do silently. The taxonomy test that assumed only photo services lack share links had a false premise; it now states the real rule rather than being relaxed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
c8dffdf0a1 |
fix(mail): a message is read on our terms, not the sender's
Opening a message told the sender it had been opened. Eight images loaded from images.ctfassets.net the moment a newsletter was rendered — one of them 526x5, which is what a tracking pixel wears when it is pretending to be a spacer — and each one carried the reader's IP address, their user agent, the time, and, from a per-recipient URL, which recipient it was. Read receipts, working perfectly, for anyone who cared to send one. Remote images are blocked now, and the block is not the interesting half. The interesting half is that saying yes does not undo it: an opted-in image loads through /mail/image-proxy, which fetches server-side and streams the bytes back, so the sender learns that somebody's plMail asked and nothing else. There is also "always for this sender", per user, in a table with a unique constraint — because trusting a sender is a button in a message, the same message can be open in two tabs, and an allowlist that a double click can duplicate is an allowlist that a race can lose an entry from. The proxy is a service that connects wherever a stranger's mail tells it to, from inside the container network, so its rules are stated in order and tested one by one: https only; port 443 only, which removes port-scanning rather than blacklisting the interesting ports; every address the host resolves to must be public, and the connection is then PINNED to a checked address through the client's `resolve` option, because checking and letting the client resolve again is the whole rebinding hole; redirects followed by hand, three at most, each hop re-running all of that, since max_redirects would follow a public host to 169.254.169.254 past every check; an image content type; 8 MB. Every refusal answers with the same transparent GIF, because telling the caller "refused" rather than "timed out" against an internal address hands back exactly the port-scan result the rules exist to deny. The signature on the URL is NOT the SSRF defence and says so in its own docblock — anyone with an account can mail themselves a link and be handed a valid signature for it. Deliberately not IntegrationUrlValidator, which has an allow-list and an admin-pinned base URL because a user is naming their own Nextcloud on a LAN. Importing its escape hatches here would mean an admin who allowed a LAN host for Immich had also allowed every sender's mail to reach it. The docs there claimed pinning the resolved IP "needs something Symfony's client does not expose"; that is not true, `resolve` does exactly that, and the correction is in this commit next to the code that disproves it. The body no longer renders in the app's own DOM. The sanitizer held — nothing got through it — but "no gaps, forever, against every sender" is a claim that has to keep being true, and the price of it being wrong once was a same-origin script with the session cookie. It renders in an iframe srcdoc whose value is what is absent from its sandbox: no allow-same-origin, so an opaque origin with no cookie, no storage and no reach into this document; no allow-forms, so a phishing form cannot even be submitted; no top-navigation, no modals, no downloads. allow-scripts IS present, and that is a deviation from the brief worth stating plainly: height is a fact only the framed document can measure, a frame without allow-same-origin cannot be measured from outside, and the alternatives were a fixed height or a guess. It is paid for by the frame's own CSP restricting script-src to a per-render nonce, which our thirty lines of height-and-hover reporting carry and no markup arriving in an email can. That CSP also names our origin in img-src explicitly — not 'self', which in an opaque origin matches nothing — so the blocker and the browser both have to fail before a pixel fires. Every warning is drawn OUTSIDE the frame. A banner inside the sandbox is a banner the mail can paint for itself, and a "Show images" button a message can forge is worse than no button. The hovered-link preview follows the same line: detected in the frame, where the links are, drawn by the parent, so a message cannot paint a status bar claiming a different destination. A Hetzner-branded invoice phish sat in Spam and rendered at full fidelity with nothing said about it. Messages in Spam now carry a banner, and the sender-name check is two rules that fit in a sentence each, because a warning nobody can check is a warning people learn to click through. Rule 1: the display name spells out a domain that is not the sender's. Rule 2 runs only when the display name carries a legal form as a whole word — GmbH, Inc, Ltd — which is what separates an organisation from a person; then, if none of its non-generic words of four letters or more occurs in the sender's registrable domain, the names disagree. "Hetzner Online GmbH" <support@ownkhalsick.com> fires; the same name at hetzner.com does not; "Deutsche Bahn AG" at bahn.de does not; "Jane Cooper" is never judged at all. DKIM passing for the sender's own domain suppresses both, since a domain that signed the message may write what it likes. A lookalike domain containing the brand is a known miss and has a test saying so — catching it means edit distances against a brand list, and every false positive that bought would be spent on ordinary mail. A trusted sender still gets no images inside Spam. The allowlist records a belief about a sender; a message in Spam is the provider disagreeing about whether this really is that sender, and the cautious reading of a disagreement is the right one. Blocking runs at RENDER time, not at ingest, and both reasons matter. Every message already stored was sanitized before this existed, so an ingest-time block would have protected only mail that had not arrived yet. And the answer depends on who is asking, because the allowlist is per reader — a stored form cannot carry an answer that varies by reader. Two layout fixes ride along, from the same report. Attachments rendered after the full HTML body, some 750px below the fold on a long message, and an attachment nobody scrolls to is an attachment nobody was sent; the chips move under the header, the same partial, relocated rather than duplicated. And the message header is sticky within the reading pane, so the answer to "who is this from" is not a scroll away from the sentence that raised the question. Verified in a browser, because every claim here is a claim about what the browser does. tests/e2e/rendering-security.spec.ts asserts on route interception — no connection to the sender's host, before or after opting in — and its matcher is a hostname predicate rather than the obvious glob, which would have matched the proxy's own URL because the sender's URL lives inside its query string, and reported a leak at the exact moment the feature worked. Two traps found on the way, both now in the handbook. Dom\HTMLDocument rejects LIBXML_NOWARNING with a ValueError rather than ignoring it, and the blocker fails closed, so the symptom was every message rendering as plain text: perfectly private and completely unreadable. It also needs the UTF-8 override, because MailBodySanitizer drops <head> and the body has no meta charset left. |
||
|
|
4d103cfbdf |
Write the handbook, and make it fail the build when it goes stale
README says what plMail is and gets you running; CONTRIBUTING says how to
change it. Nothing covered the part in between — using it, operating it,
and registering the Google and Microsoft applications it talks to. That
is docs/: 34 pages across features, installation, providers and
internals, mirrored to the GitHub wiki.
The wiki is a mirror and never a source. bin/mirror-wiki.php flattens the
tree into the wiki's flat namespace, rewrites every relative link,
generates the sidebar from the index so the two cannot disagree, and
stamps each page with a footer saying where it came from. A browser edit
survives until the next push to main. That cost is deliberate:
documentation not versioned with the code drifts from it, and the drift
is invisible until somebody follows an instruction that stopped being
true.
Which is the whole problem, so the checkable half is checked.
DocumentationCoverageTest fails the build when a command is missing from
the reference or documented and gone, when a variable in .env is absent
from the configuration page, when a relative link dangles, when a page is
unreachable from the index or promised and missing, or when a page has no
"Things that bite" section. It cannot check whether a paragraph is TRUE,
and says so — this is the floor, so review attention goes to the part a
machine cannot have an opinion about. CONTRIBUTING has claimed since it
was written that the command table "is part of the definition of done";
nothing enforced it until now, and it was wrong in three places.
Writing it found things. They are fixed here rather than documented
around, because a handbook describing a bug accurately is still a
handbook nobody can follow.
compose.yaml mounted no volume for var/attachments, var/raw
or var/uploads. Workers write those during sync
and the web container serves them back, so each
kept its own copy in its own layer — downloads
404, and everything died on a recreate. The
override and the TrueNAS file both mount them;
the file the README tells an operator to run did
not. The same omission cost real data once
before. See the CHANGELOG for moving what is
currently stranded.
app:calendar:materialise new, nightly. Occurrences are drawn when an
event is saved and nothing moved that window
again, so a weekly standup ran out of dates in
two years and its reminders stopped with them —
silently. The query it runs had existed since
the materialiser did, documented as exactly this
sweep, and had never been called.
Microsoft wizard asked for three of the nine permissions plMail
requests, and a token already issued is not
upgraded when the rest are added later.
GmailWatchService told you to grant Pub/Sub Publisher to
gmail-api@system.gserviceaccount.com. The
account is gmail-api-push@; a topic granted
exactly what the docblock asked for never
delivered a notification.
IcsExporter said it wrote no VALARM. It has written them
since alerts landed.
CLIENT_DEVELOPMENT.md said there was no JMAP calendar API and that
SearchSnippet/get was absent. Both shipped.
CONTRIBUTING said only app:calendar:push registers a push
channel; subscribing has done it since the
subscribe flow learned to.
Three docblocks named getters removed long ago, or described a
method two below the one they sat on.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|