Files
pl_mail/config/packages/security.yaml
T
7fb2c8f38c Inline images in a message load through a signed link (#218)
The message body renders in a sandbox that sends no session cookie, so every cid: image was a 302 to the login page. They now load through /mail/inline/{id}, signed with an expiry of one to two days, for the message's own image parts only; parts labelled octet-stream are served when their bytes are an image. Closes #213.

Co-authored-by: Philippe Delodder <philippe@delodder.be>
Co-authored-by: MontyWei <62734446+montywei@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 14:03:04 +02:00

265 lines
16 KiB
YAML

security:
# https://symfony.com/doc/current/security.html#registering-the-user-hashing-passwords
password_hashers:
Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto'
# https://symfony.com/doc/current/security.html#loading-the-user-the-user-provider
providers:
app_user_provider:
id: App\Security\UserProvider
role_hierarchy:
ROLE_ADMIN: ROLE_USER
firewalls:
dev:
pattern: ^/(_(profiler|wdt)|css|images|js)/
security: false
jmap:
pattern: ^/(jmap|\.well-known/jmap)
stateless: true
provider: app_user_provider
# Suspended and removed accounts are refused here too, and that is
# the half that matters most: an app password outlives the browser
# session it was minted from, so a check only on `main` would shut
# somebody out of the web UI and leave every mail client they had
# already connected syncing. See App\Security\UserChecker.
user_checker: App\Security\UserChecker
# Two credential types share this firewall: long-lived app
# passwords (third-party clients and the Android app, which pairs
# by QR into one; Bearer or Basic) and short-lived JWTs, which
# nothing issues yet. ApiTokenAuthenticator::supports() claims only
# Basic and prefixed Bearer, so JWT gets the rest.
custom_authenticators:
- App\Security\ApiTokenAuthenticator
entry_point: App\Security\ApiTokenAuthenticator
jwt: ~
caldav:
pattern: ^/(caldav|\.well-known/caldav)
stateless: true
provider: app_user_provider
# The same two reasons the jmap firewall gives, and the same
# authenticator object: a CalDAV client authenticates with an app
# password over Basic, which is exactly what ApiTokenAuthenticator
# already claims. Nothing new is minted for calendars — a password
# that syncs mail syncs the calendar beside it, and revoking it
# stops both.
user_checker: App\Security\UserChecker
custom_authenticators:
- App\Security\ApiTokenAuthenticator
entry_point: App\Security\ApiTokenAuthenticator
# The logo pictures (the icon tiles, the tab icon, the top bar's logo):
# outside security altogether, like `dev`'s assets, rather than a
# PUBLIC_ACCESS rule under `main`. Each depends on its URL and nothing
# else, so it is cached publicly for a year — and under `main` that
# promise could not be kept. The first listener to ask who the user is
# (AppearanceCookieSubscriber asks on every response) reads the session,
# and a response on a request that read the session is rewritten by
# Symfony to `private, max-age=0`: forty-two tiles on the appearance
# pane re-fetched on every visit, each one a session read and a user
# reload for a picture that is the same for everybody. With no firewall
# there is no token to load and no session to touch — which is also why
# the login page's tab icon needs no rule of its own. See
# App\Controller\BrandingController.
branding:
pattern: ^/branding/
security: false
main:
lazy: true
provider: app_user_provider
# The password form, and single sign-on through an OpenID Connect
# provider when one is set up under Admin → SSO or in the OIDC_*
# variables — see App\Security\OidcAuthenticator, which declines
# every request while neither is. Both end in the same token for the same User,
# so the user checker, the login throttle and two-factor below
# apply to either way in.
custom_authenticators:
- App\Security\LoginFormAuthenticator
- App\Security\OidcAuthenticator
# Two authenticators means Symfony has to be told which one sends
# a signed-out visitor somewhere. The login page, which offers both.
entry_point: App\Security\LoginFormAuthenticator
# Refuses an account an administrator has suspended or removed, and
# does it after the password has been verified rather than before —
# the class says why at length. A session that was already open is
# the other half, and is ended by UserProvider::refreshUser().
user_checker: App\Security\UserChecker
# A password form on an instance meant to be reachable from the
# internet, with no limit on how fast it could be guessed. Three
# limits, in App\Security\LoginRateLimiter (numbers in
# rate_limiter.yaml): per username+IP, the tight one; per IP, the
# backstop for one client spraying a password across many accounts,
# looser so a household behind one NAT address cannot lock itself
# out; and per username alone, so guesses spread across many
# addresses still hit a ceiling. Symfony's default has only the
# first two, which is per username+IP and not per username as this
# comment used to claim.
#
# Note this does NOT cover the 2FA code form — that is a separate
# firewall listener with its own throttle, see below.
login_throttling:
limiter: App\Security\LoginRateLimiter
# Opt-in via the "keep me logged in" checkbox on the login form;
# a single sign-on sign-in has no form and asks for it itself, in
# App\Security\OidcAuthenticator.
# Signature-based tokens: no storage, and changing the password
# invalidates every cookie that was issued for it.
remember_me:
lifetime: 5184000 # 60 days
secure: auto
samesite: lax
# Kept on the server, one row per device (`rememberme_token`),
# rather than a cookie that only carries its own signature.
#
# A signed cookie is valid until it expires, whatever happened
# since, and signing out only asks the browser to delete it. Any
# request already on its way with it — the page's own background
# fetches, which are many — reached the server after the sign-out
# and signed the person straight back in. With the token stored,
# signing out deletes this device's row, and a cookie that names
# it opens nothing. Other devices stay signed in. Parallel
# requests that rotate the token are reconciled by Symfony's
# CacheTokenVerifier, so they are not mistaken for theft.
token_provider:
doctrine: true
# Password first, then TOTP if the account has it switched on. The
# RememberMeToken is listed in scheb_2fa.yaml's security_tokens, so
# a remember-me cookie is challenged too — see the note there.
two_factor:
auth_form_path: app_2fa_login
check_path: app_2fa_login_check
# The bundle's own handler remembers every GET it turns away as
# "where you were going", including the ones the code form's own
# layout makes. See the class for what that did.
authentication_required_handler: App\Security\TwoFactor\TwoFactorRequired
# Both are scheb's defaults, spelled out because
# templates/security/2fa_login.html.twig hard-codes the matching
# field ids for its label/autofocus wiring. Changing one here
# without the other silently stops the form submitting anything.
auth_code_parameter_name: _auth_code
trusted_parameter_name: _trusted
logout:
path: app_logout
# Without a token a plain GET logged you out, so any page on the
# internet could do it with an <img src>. The id is `logout`,
# stateless in csrf.yaml, so what refuses the forged request is
# the same-origin check. Every sign-out control is a POST form
# carrying csrf_token('logout').
enable_csrf: true
# Easy way to control access for large sections of your site
# Note: Only the *first* access control that matches will be used
access_control:
# Docker healthchecks and uptime monitors hold no session. It answers
# only what such a caller could infer by trying the app anyway — see
# App\Controller\HealthController on why it stays deliberately vague.
- { path: ^/healthz$, roles: PUBLIC_ACCESS }
# CSP violation reports. The browser sends these itself, about a
# resource its own policy refused to load, so there is nothing of ours
# in the request: no token, no header we chose, and on the login page no
# session either. It cannot be authenticated and still be received. The
# `client_error_report` limiter is the bound; see
# App\Controller\Monitoring\ClientErrorController for what it keeps.
- { path: ^/csp-report$, roles: PUBLIC_ACCESS }
- { path: ^/gmail/push, roles: PUBLIC_ACCESS }
# Covers /webhook/graph/notify and /webhook/graph/lifecycle for mail,
# and /webhook/graph/calendar for a mirrored calendar's events. All
# three prove themselves with the clientState minted per subscription.
- { path: ^/webhook/graph, roles: PUBLIC_ACCESS }
# Google Calendar watch channels. A plain webhook, not the Pub/Sub path
# /gmail/push is on, so it needs a rule of its own — the caller is
# Google itself and holds no session. Authenticity is the channel token
# in X-Goog-Channel-Token; see App\Controller\Webhook\GoogleCalendarPushController.
- { path: ^/webhook/google/calendar$, roles: PUBLIC_ACCESS }
# RFC 6764's signpost, and public where its JMAP neighbour below is
# not. The difference is what each returns: /.well-known/jmap answers
# with the session object, which is the user's own data, while this
# answers with a 301 to a constant path and nothing else. A client asks
# it before it has anything to authenticate with — that is the whole
# point of "just type your server address" — and several read a 401
# here as "no CalDAV on this host" rather than as a challenge.
- { path: ^/\.well-known/caldav$, roles: PUBLIC_ACCESS }
- { path: ^/\.well-known/jmap, roles: IS_AUTHENTICATED }
- { path: ^/jmap, roles: IS_AUTHENTICATED }
- { path: ^/admin, roles: ROLE_ADMIN }
- { path: ^/login, roles: PUBLIC_ACCESS }
# Starting a demo, and only on an install that is one: the route 404s
# itself when APP_DEMO_MODE is off, so this rule opens nothing on a
# normal deployment. Anchored to the exact path — /demo/receive
# deliberately falls through to ROLE_USER below, because delivering
# mail into a mailbox requires being in one.
- { path: ^/demo$, roles: PUBLIC_ACCESS }
# The legal notice, and it must be anonymous by necessity rather than
# convenience: § 5 TMG wants it "unmittelbar erreichbar", and a visitor
# who has not signed in is exactly who it is for. The route 404s itself
# when demo mode is off, so this opens nothing on a normal install.
- { path: ^/impressum$, roles: PUBLIC_ACCESS }
# The privacy notice, anonymous for the same reason and with more force:
# the reader has no account here and is deciding whether to get one.
- { path: ^/datenschutz$, roles: PUBLIC_ACCESS }
# Device pairing, by necessity: a device that could authenticate here
# would not need to pair. The gate is the code itself — 32 bytes of
# CSPRNG, valid two minutes, burned on first use — see
# App\Service\User\DevicePairingService. Note this is /device/pair,
# not /settings/pair: issuing a code still requires a session.
- { path: ^/device/pair$, roles: PUBLIC_ACCESS }
# A shared calendar link. The recipient has no account here by
# definition — a link they had to sign in to read would be a link
# nobody could use — so the token in the path is the whole gate: 32
# bytes of CSPRNG, stored only as a SHA-256 so a database dump is not a
# set of working URLs, revocable from Settings → Sharing. What each link
# may reveal is decided per link and enforced in
# App\Service\Calendar\Sharing\ShareLinkReader, which returns redacted
# DTOs rather than events — a template here cannot leak a title because
# it never receives one. Read only: both routes are GETs.
- { path: ^/share/, roles: PUBLIC_ACCESS }
# A public booking page, and the one unauthenticated POST on this
# install that both creates rows and sends mail. Same token, same
# reasoning; what proves the caller is holding the published URL. The
# POST is additionally bounded by the `booking_attempt` rate limiter,
# because a token is what the abuser is holding too — see
# App\Controller\Sharing\BookingController, which also says why there is
# no CSRF token on a page that deliberately starts no session.
- { path: ^/book/, roles: PUBLIC_ACCESS }
# The code form sits between the two factors: the password is in, the
# second factor is not, and ROLE_USER has not been granted yet. It must
# be reachable in exactly that state and in no other, which is what
# this role means. Covers /2fa and /2fa_check — see routes/scheb_2fa.yaml.
- { path: ^/2fa, roles: IS_AUTHENTICATED_2FA_IN_PROGRESS }
# Creates the first administrator, so it cannot require one. Safe only
# because App\Service\Setup\InstallGuard 404s it the moment any user
# exists — see that class before touching this line.
- { path: ^/install, roles: PUBLIC_ACCESS }
# The remote-image proxy. Must be anonymous by necessity, not by
# oversight: the mail body renders in a sandbox with no allow-same-origin
# (opaque origin, for XSS containment), and such a frame sends no session
# cookie with its <img> loads — behind ROLE_USER every proxied image came
# back as the login page and rendered broken. The gate is the HMAC
# signature the route checks itself: hash_hmac over the URL with
# %kernel.secret%, no user in the payload, so only URLs our own pipeline
# signed are fetchable (a capability token, cf. GitHub Camo). Every SSRF
# check still runs per fetch in App\Service\Mail\ImageProxyFetcher. See
# App\Controller\Mail\ImageProxyController for the full argument.
- { path: ^/mail/image-proxy$, roles: PUBLIC_ACCESS }
# An inline (cid:) image of a message, anonymous for the same reason as
# the proxy above: the sandboxed body sends no session cookie, so behind
# ROLE_USER every inline image was a 302 to /login. The gate is the HMAC
# over the part id that the route checks itself, and it serves nothing
# but raster images. See App\Controller\Mail\InlineAttachmentController.
- { path: ^/mail/inline/\d+$, roles: PUBLIC_ACCESS }
- { path: ^/, roles: ROLE_USER }
when@test:
security:
password_hashers:
# By default, password hashers are resource intensive and take time. This is
# important to generate secure password hashes. In tests however, secure hashes
# are not important, waste resources and increase test times. The following
# reduces the work factor to the lowest possible values.
Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface:
algorithm: auto
cost: 4 # Lowest possible value for bcrypt
time_cost: 3 # Lowest possible value for argon
memory_cost: 10 # Lowest possible value for argon