The validation added with the admin user checks reached further than intended.
The seed runs on every container start, not only on the first, and it threw on
three conditions that are normal on a second start.
The worst was the check for an existing user: after the first start the
default admin exists by definition, so every restart after that ended in a
container that would not come up. The insert is wrapped in a try and falls
back to an update precisely because it is meant to run again, and that path
was never reached.
The other two were narrower but the same shape. `validator.isEmail` rejects
`admin@localhost`, which is a common value in self-hosted setups, so an
instance that had been running for a year would stop coming up after the
upgrade.
None of it throws now. Bad input is reported on stderr and skipped, the way
an invalid username already was, and a username that belongs to someone else
is left alone rather than taken. `db:create-admin-user` keeps the strict
validation, which is where a person is there to read it and type again.
Add negative label filtering for cards
Two conflicts, both from work that landed while this branch was open.
In the endless list query, master had added an ORDER BY so the cursor and the
limit agree. The exclusion clause belongs in the WHERE part, so it is placed
before it rather than after; the other way round the statement does not parse.
The label item had been restructured here for the tri-state filter and had
gained a tooltip on master. The restructured version is kept and the tooltip
put back on top of it, which also brings back the translation hook this branch
had dropped.
Reported against 2.2.1 by someone reading the source. Every finding held.
**Sign-in had no ceiling.** Failures were logged with the caller's address
and nothing more. Two counters now — one per address, one per account —
because the two attacks look different: one source working through many
accounts is caught by the first, many sources working on one account by the
second, and behind a proxy only the second still means anything.
The count is kept in the process that serves the request. PLANKA needs no
Redis and the stock deployment is one container; run several and each keeps
its own count, which multiplies the ceiling by their number. That trade is
written where the limits are configured.
**The second factor could be guessed at leisure.** Six digits, and a
pending token that stayed valid for its full ten minutes however many codes
were wrong. Wrong codes are now counted on the session row — in the
database, so the count survives a restart and holds across every process —
and when the budget is spent the session is destroyed. After that even the
right code is refused and the login starts over from the password.
**Avatars, background images and favicons** checked the token's signature
and nothing else, so a revoked session, a deactivated account or a changed
password all kept working there for as long as the signature lasted, which
is a year by default. The five checks the API makes now live in one helper
that both use, rather than the shortened copy that had drifted from it.
**A link attachment's favicon** was fetched from wherever the URL pointed.
Storing a link is harmless — it is a string the user typed — but fetching
its icon is a request the server makes to an address the user chose, and
whether an icon came back reported on what is reachable from inside the
network. Server-side fetches now refuse private, loopback and link-local
addresses, `169.254.169.254` among them. The attachment is still created:
linking to an internal wiki is a legitimate thing to do, and it was the
server's own request that had to stop.
**The signing key.** Our own compose file ships `notsecretkey`, and it is
printed in the documentation — so on any instance that copied it, anyone can
sign a token for any account. PLANKA now says so on every start, and keeps
saying it, along with a key that is missing or shorter than 32 characters.
The placeholder carries the warning inline, where it is copied from.
**The backup script** wrote password hashes, live sessions, TOTP secrets and
SMTP credentials to an unencrypted archive. `BACKUP_PASSPHRASE` now encrypts
it, and without one the script says what it just put on disk. It also says
what it is — an example for the stock compose stack, not a backup concept —
and names the window between the database dump and the file copy, which no
ordering closes.
Instances that accumulated project managers, favorites, board memberships
or board subscriptions pointing at a project or board that no longer exists
had no way to find or clear them.
The script reports by default and only deletes with `--apply`, which first
asks whether a backup exists and refuses outright without a terminal to ask
on. It removes nothing but reference rows: structural orphans such as a
board without a project carry user content, and a membership whose
denormalized project is stale still grants real access to an existing
board, so both are reported and left alone.
The filtered branch of `getByEndlessListId` applied `LIMIT` without an
`ORDER BY`, so Postgres was free to hand back any matching rows. With a
search over 64 cards the first page returned the oldest ones and a full
cursor walk reached 60 of them across 149 rows — cards both skipped and
repeated. The query now orders the way the cursor reads it.
The cursor itself is validated as ISO 8601, which admits forms Postgres
rejects as a timestamp (`2026`, `2026-W35-3`, a comma as the decimal
separator), each of them a 500 from the adapter. It is normalized before
the query, and stays a string because the equality half of the cursor
reads a `Date` as an empty constraint.
`getManagerProjectsModelArray` mapped every project manager to its project
without checking that the project is in the store, so one row pointing at a
deleted project produced a null the caller then read `id` off — taking down
the whole home view. `getMembershipProjectsModelArray` had the same hole one
level up, destructuring `board.project` before testing `board`.
The projects endpoint fed exactly that state: it derived the id list for the
included records from the manager rows, so a `project_manager` row whose
project no longer exists shipped a manager for a project missing from `items`.
Board memberships had the same gap. Both id lists now come from the records
that actually exist.
The local file manager joined attacker-controlled path segments into the
uploads storage path without ensuring the result stayed under the uploads
root. Any authenticated user could read arbitrary files readable by the
Planka process (e.g. /etc/passwd, /app/.env) via `../` sequences on the
/user-avatars/*, /background-images/* and /favicons/* routes (CWE-22).
Resolve the path and enforce it stays within uploadsBasePath, centrally in
buildPath so every local file operation is covered. Additionally resolve
symlinks in read() and re-check containment, so a symlink inside the root
cannot be used to escape it.
Reported by Alpesh (github.com/Alpastx).
Users can pick an inactivity timeout in their preferences. A warning
appears 30 seconds before, and activity or a logout is synchronised
across open tabs.
Adds TOTP setup with QR code, login challenge, recovery codes and
trusted devices that let a browser skip the second factor for 30
days. Admins can reset another user's second factor by confirming
with their own password.
Bump Node to 24 in Dockerfile and Dockerfile.dev.
Bump sharp to 0.35, nodemailer to 9 and concurrently to 10.
Add overrides for body-parser, qs, multiparty, uuid, diff,
serialize-javascript, linkify-it and svgo to pull in security fixes
that upstream pins block.
Rename the sails patch to match 1.5.18.