Commit Graph
36 Commits
Author SHA1 Message Date
finnandClaude Opus 5.5 5eea5da396 Hash passwords with bcrypt instead of a single SHA-512 round
New passwords are stored as {BLF-CRYPT} with cost 12, which Dovecot 2.4
verifies. Existing {SSHA512} hashes are still checked and replaced at the
next login. Unknown users are checked against a dummy hash, so the
response time does not reveal whether an email exists.

bcrypt only uses 72 bytes and bcrypt 5 raises for longer passwords, so
validate_password limits passwords to 72 bytes when creating a user,
changing a password and in the CLI.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 19:04:04 +00:00
finnandClaude Opus 5.5 b1c412f3d7 Show the not found page for unknown URLs
A catch-all rule raises NotFound, so unknown URLs pass the middlewares
and get the regular not found page instead of Flask's default 404.
Anonymous visitors are redirected to the login like for any other URL,
so they cannot tell which URLs exist.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 18:37:12 +00:00
finnandClaude Opus 5.5 105fd42d22 Log every request once and keep the language per request
Request logging:
- One line per request from Flask hooks instead of the LoggingMiddleware.
  It now also covers static files, unknown URLs, 500 errors and the
  responses of the CSRF and auth middlewares. In debug mode, where Flask
  skips after_request on exceptions, the status is logged as 500.
- All fields are logged as key=value.
- The session is logged as a short SHA-256 prefix instead of the raw id.
- In debug mode, the request lines of the Werkzeug dev server are
  filtered out, since the app logs requests itself.

Translations:
- TranslationHandler keeps the language in flask.g instead of a class
  attribute, so concurrent requests cannot overwrite each other's
  language.
- Translating or rendering a template without a language raises
  LanguageNotSet. Requests that do not pass the I18NMiddleware are
  logged with lang=None.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 18:37:12 +00:00
finnandClaude Opus 5.5 b86506eb0e Make the login case-insensitive and keep non-admins off the user list
The login compared the email case-sensitively, although emails are stored
lowercased, so "Fritz@finn.st" could not log in. After changing the
password without ?return=detail, non-admins were redirected to the user
list, which is a 404 for them; they now go back to their detail page.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 18:03:07 +00:00
finnandClaude Opus 5.5 fda35307c6 Document the reverse proxy requirement for X-Real-IP
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 18:02:36 +00:00
finnandClaude Opus 5.5 74a7eaf677 Write the project name as "Max" in the README
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 17:53:01 +00:00
finnandClaude Opus 5.5 7ba0253837 Set the charset and the current language in the HTML head
<meta charset="utf-8"> keeps the encoding when a page is saved locally.
<html lang> now uses the language of the request instead of a fixed
"en", which matters for screen readers, hyphenation and the browser's
translation prompt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 17:53:01 +00:00
finnandClaude Opus 5.5 78702f133c Remove unused Font Awesome files and legacy font formats
max/static/css/ was never included, the pages only use
max/static/fontawesome.min.css. The @font-face rules now only reference
woff2, which all current browsers support, so the eot, svg, ttf and woff
files are removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 17:53:01 +00:00
finnandClaude Opus 5.5 ca4cb0b25f Fix spelling in the translations and their English source strings
- "Go Back" in not_found.html did not match the "Go back" key and was
  never translated. "Domain must be given" was missing in de.json.
- English: successful, deactivate, a user, log in, URL, Re-enter
  password. The keys in de.json are renamed accordingly.
- German: "Wird" instead of "Word", commas before "da", "E-Mail" and
  "E-Mail-Adresse" instead of "Email", "angegeben werden" instead of
  "gegeben sein", "ungültig" instead of "valide", "selbst" instead of
  "selber", missing periods.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 17:47:38 +00:00
finnandClaude Opus 5.5 f46a346078 Show the version in a footer for logged-in users
The version is read once at startup from the VERSION file, which is now
also copied into the Docker image. The footer is right-aligned below the
content block and only shown to logged-in users, so anonymous visitors
do not learn the exact version.

The startup log now has "Max starting." at the beginning of main() and
"Max initialized, version X." at the end, so a failed startup is visible
by the missing second line.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 17:43:02 +00:00
finnandClaude Opus 5.5 2b1160862a Fix several small security and robustness issues
Login:
- One message for unknown email, wrong password and deactivated
  account. Before, "This account is not enabled!" only appeared after
  the right password and confirmed valid credentials.
- Email and password must be non-empty strings. A missing password
  field became the string "None" and logged in a user with that
  password.

Forms:
- "Create user" no longer renders the password back into the HTML after
  a validation error.
- Password fields for a new password use autocomplete="new-password",
  so a password manager does not fill in the admin's own password. The
  email fields on the create pages use autocomplete="off".
- Email addresses are limited to 64 characters in the local part and
  254 in total (RFC 5321). Longer ones caused a database error and
  HTTP 500.
- alerts.html no longer translates flash messages a second time. Running
  str.format() again on an already formatted message was fragile and
  logged "Unknown translation" for every German message. All flashes
  now translate when flashing, "Invalid data" included.

Debug mode:
- The Docker image sets MAX_PRODUCTION=1, and the app refuses to start
  in debug mode then. FLASK_DEBUG=1 in production would otherwise enable
  the fixed secret key, cookies without Secure, no HSTS and query
  arguments (including password hashes) in the log.
- make run-dev and run-dev-as-prod bind to 127.0.0.1.

Other:
- A denied access is logged with the logger at debug level instead of
  print. PermissionDenied loses its unused message, it is deliberately
  answered with a 404.
- Fix the typo in /user/<id>/ser-toggle-can-use-different-alias-domain.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 20:04:22 +00:00
finnandClaude Opus 5.5 58242117f6 Ask for the current password when changing the own password
A hijacked session was enough to change the own password, lock out the
owner and get permanent access, including the mailbox via Dovecot.

Changing the own password now requires the current password, for users
and admins alike. Admins changing the password of another user are not
asked, since an admin session can take over accounts in other ways
anyway. The new password fields are marked as new-password for password
managers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 19:38:14 +00:00
finnandClaude Opus 5.5 6e6ae3ccb7 Add the Python extension and interpreter path to the devcontainer
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 19:24:37 +00:00
finnandClaude Opus 5.5 5b87d9ff03 Fix HTTP 500 on the create pages with ?return=detail
UserCreate and AliasCreate used BackToUsersMixin and BackToAliasesMixin,
which access self.user or self.alias for ?return=detail. Neither exists
on the create pages, so the request failed with an AttributeError.

Both views now define their own back link with the target they already
used without the parameter: the user list and the user's detail page.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 19:24:37 +00:00
finnandClaude Opus 5.5 91eaef7f93 Pass only the return parameter to form actions
Five templates built their form action with url_for(..., **request.args),
so every query parameter ended up in the action. This included Flask's
special parameters (?_external=1 gave an absolute http:// action in
production) and the route parameters (?user_id=... or ?alias_id=...
caused a TypeError and HTTP 500).

The only parameter needed is return=detail, which sends the user back to
the detail page after the POST. ReturnToMixin reads it, passes on only
the known value and puts it into the template context as return_to.
BackToUsersMixin and BackToAliasesMixin build on it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 19:24:37 +00:00
finnandClaude Opus 5.5 53081dd553 Pin all dependencies with hashes and add a CVE audit
Only the direct dependencies were pinned. Werkzeug, Jinja2, MarkupSafe,
itsdangerous and the other transitive packages were resolved at build
time, so builds of the same commit could differ, and pip did not verify
the downloaded files. There was also no vulnerability scan.

- requirements.in / requirements-dev.in list the direct dependencies.
  requirements.txt / requirements-dev.txt are lockfiles generated by
  pip-compile with exact versions and hashes for every package. The dev
  lockfile is constrained by the production one.
- Makefile targets: lock-deps, update-deps, sync-deps and audit
  (pip-audit against requirements.txt).
- The locked production versions match the previously installed ones.
  The first audit found no known vulnerabilities.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 18:30:08 +00:00
finnandClaude Opus 5.5 2fb357ae77 Set security headers on all responses
The app set no security headers at all, and the reverse proxy sets none
either.

- Content-Security-Policy: default-src 'none' with 'self' only for
  styles, images and fonts (MAX has no JavaScript), form-action 'self'
  (also blocks the http:// form action downgrade), frame-ancestors
  'none' and base-uri 'none'.
- X-Frame-Options: DENY, X-Content-Type-Options: nosniff,
  Referrer-Policy: same-origin.
- Strict-Transport-Security with max-age of 7 days, not in debug mode.

The headers are set in after_request, so they also apply to static
files, 404 and error responses.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 18:17:19 +00:00
finnandClaude Opus 5.5 931243dd60 Harden the container: non-root, no build tools, read-only, stdout logging
The app ran as root, and the production image contained gcc, vim, wget
and gnupg. Code execution in the container would have allowed changing
the app code and fetching or compiling further tools.

- Multi-stage build: the wheels (psycopg2) are built in a build stage.
  The runtime image only contains libpq5, postgresql-client and
  wait-for-it (724 MB -> 298 MB).
- The app runs as the system user max (uid/gid 10001). The code is
  owned by root and precompiled, the user has no writable directory.
- Log to stdout only; docker collects it. The log file under /app/log
  was lost on every container recreation anyway.
- docker-compose: read-only filesystem (tmpfs for /tmp), no
  capabilities, no-new-privileges and log rotation (5 x 10 MB).
- Add a .dockerignore and set MAX_ORIGIN in docker-compose.yml.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 18:09:30 +00:00
finnandClaude Opus 5.5 7ac4e6c3a7 Handle sessions on password change correctly
remove_user_sessions() got the user id as except_id, so the exception
never matched. On an own password change the result was correct only
by accident. When an admin changed another user's password, the admin
session got a new id, but the old entry stayed valid in the store.

- New Session.regenerate(): keeps the data under a new id and removes
  the old store entry.
- change_password() handles the sessions: on an own password change it
  regenerates the current session and ends all other sessions of the
  user (except_id is now the session id). When an admin changes another
  user's password, the admin session stays untouched and all sessions
  of that user end.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 17:48:03 +00:00
finnandClaude Opus 5.5 9aed6b5227 Set SameSite=Lax and use the __Host- prefix for the session cookie
The own SessionInterface ignored SESSION_COOKIE_SAMESITE, so the cookie
was sent with every cross-site request in Firefox and Safari (CSRF,
clickjacking in iframes, keeping the idle timeout alive). The cookie
name "session" could also be set by other subdomains or via plain HTTP,
the precondition for session fixation.

- SameSite=Lax. No GET changes state, so links from mails keep working.
- In production the cookie is named __Host-session, which browsers only
  accept with Secure, Path=/ and without Domain. The debug server keeps
  "session", since it runs without Secure.
- set_cookie and delete_cookie use the same attributes, since browsers
  ignore the deletion of a __Host- cookie without Secure.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 17:40:54 +00:00
finnandClaude Opus 5.5 8e32727fcd Limit sessions per user instead of a global LRU cap
The session store held at most 100 sessions and evicted the least
recently used one. Anyone with an account could log in 101 times and
thereby log out every other user, including the admins.

- Each user has at most 10 sessions. A new login above the limit
  removes the user's own least recently used session (by _last_seen).
- There is no global limit anymore: the store only accepts sessions
  with a user_id (raises SessionWithoutUser otherwise), so it is
  bounded by 10 * number of users. Anonymous users still create no
  store entries.
- save_session clears an invalid session before re-raising, since
  Flask saves the session again for the error response.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 17:27:44 +00:00
finnandClaude Opus 5.5 80757fa1d8 Fix HTTP 500 on missing email or domain when creating users and aliases
If the email or domain field was missing or invalid, the error was
flashed, but the address was still built from the unset attributes,
which raised an AttributeError. Only build and validate the address
if both are set.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 15:25:38 +00:00
finnandClaude Opus 5.5 4a03b5c1a1 Reserve privileged alias addresses for admins
Any user could create aliases like postmaster@, webmaster@ or abuse@ on
their domain. CAs use these addresses to validate domains, so a family
account (or a compromised one) could obtain TLS certificates or catch
abuse and system mails.

- Only admins may create aliases with a reserved local part (admin,
  administrator, webmaster, hostmaster, postmaster, abuse, security,
  noc, root, mailer-daemon). The acting user counts, so admins can
  still assign such aliases to other users.
- Escape the domains in the email regex, so "finn.st" no longer
  matches "finnXst".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 15:23:12 +00:00
finnandClaude Opus 5.5 fc24ba068d Prevent session fixation on login
login() kept the existing session id and CSRF token. Anyone who knew
the id before (e.g. via cookie injection) was logged in as the victim
afterwards and knew the CSRF token, too.

- login() clears the session after a successful check. Since the fix of
  Session.clear() this deletes the old store entry, drops the CSRF
  token, resets the timestamps and assigns a new id.
- open_session no longer adopts signed ids without a store entry, so an
  id chosen by the client never becomes a session id.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 11:51:35 +00:00
finnandClaude Opus 5.5 cc3d064e64 Expire sessions after 30 min idle and 12 h absolute
Sessions were only bounded by a process restart or the LRU eviction, so
a stolen or fixed session id could stay valid for months. A cookie TTL
would not help, since an attacker with a copy of the id ignores it.

The store entry now carries _created_at and _last_seen (monotonic
time). They are kept out of the session dict as attributes of the
Session object, so is_empty() and the app code are unaffected.
open_session drops expired entries and returns a fresh, empty session.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 11:46:44 +00:00
finnandClaude Opus 5.5 ca023e705c Invalidate sessions server-side on logout and deactivation
Session.clear() set the id to None before clearing, so on_update
assigned a new id and save_session deleted the store entry of that new
id. The entry of the old id with the user_id stayed valid: a copied
cookie survived the logout, and a session "logged out" by the auth
middleware for a disabled user was valid again after re-enabling.

- Session.clear() now removes the store entry of the current id.
- Deactivating a user removes all of their sessions immediately, like
  deleting a user does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 11:03:03 +00:00
finnandClaude Opus 5.5 dc85c578a2 Protect against login CSRF without cookies for anonymous users
Anonymous users must not get cookies or session store entries, so a
CSRF token for /login is not possible. Instead, every unsafe request is
checked statelessly via Sec-Fetch-Site, falling back to Origin. Requests
without both headers are not from a browser and are accepted. The
expected origin comes from the new MAX_ORIGIN setting, which is required
outside of debug mode.

Further CSRF changes:
- Do not delete the CSRF token on a failed check. It added no security,
  but invalidated forms in open tabs and could be triggered cross-site.
- Treat HEAD and OPTIONS as safe methods.

Keep the login page free of sessions:
- Show login messages via ?msg=login_required|logout (fixed keys only)
  and the login error via the template context instead of flash().
- Only send the session deletion cookie if the client sent a cookie.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 11:03:03 +00:00
finnandClaude Opus 5.5 91e95b1947 Make isort compatible with black
isort's lines_after_imports = 2 conflicted with black, so run-cleanup
reverted black's changes. Use isort's black profile instead and apply the
resulting formatting. Also fix the flake8 errors in check_email_mixin and
drop a leftover debug print.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 09:06:51 +00:00
finnandClaude Opus 5.5 340006ff37 Disable debug default login
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 08:39:28 +00:00
finnandClaude Opus 5.5 86fcdfde27 Devcontainer: enable bash-completion and set UTF-8 locale
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 08:39:28 +00:00
finnandClaude Opus 5.5 e0b5e05cce Use port 8757 for the dev server
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 08:39:28 +00:00
finnandClaude Opus 5.5 9f25457b24 Remove old docker-compose based dev setup
The devcontainer replaces Dockerfile.dev, dc.dev.yml and the related
start/entrypoint scripts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 08:39:28 +00:00
finnandClaude Opus 5.5 9895017385 Restructure scripts into dev/ and prod/ for the devcontainer
- scripts/prod: entrypoint, db-setup and schema.sql shipped in the image
- scripts/dev: seed/clear db, psql and cleanup, run natively in the
  devcontainer instead of via docker-compose
- move setup.cfg to the repo root so linters pick it up
- Makefile targets run directly in the devcontainer

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 08:39:28 +00:00
finnandClaude Opus 5.5 7c2b9fb3f4 Refactor logging and drop deprecated FLASK_ENV
Move logging setup into max/log.py. The file handler is only used outside
debug mode and its directory is configurable via LOG_DIR. FLASK_ENV is
deprecated in Flask; debug mode is now controlled via FLASK_DEBUG.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 08:21:07 +00:00
finn 65dddea3bc initial switch to a devcontainer 2026-10-21 06:25:18 +00:00
finn 2bd853e9b5 modernizing the dependencies 2026-10-19 18:53:59 +02:00