mirror of
https://github.com/karatektus/pl_mail.git
synced 2026-10-25 01:54:10 +00:00
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>
265 lines
16 KiB
YAML
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
|