Skip to content

3.2.2.1

Latest

Choose a tag to compare

@nilsteampassnet nilsteampassnet released this 03 Sep 06:32
b446b5d

What's Changed

This is the first maintenance release on the 3.2.2 line. It closes seven security advisories, four of them in the new LAPR module, three stored-XSS sinks in the item interface, repairs two data-integrity bugs that could make custom fields and personal items permanently unreadable, and fixes a Docker auto-upgrade path that left containerised databases silently half-migrated. It also lands the new file integrity and permission diagnostics in System Health, folder search, and periodic LAPR endpoint monitoring.

Upgrading is required for every installation, and is urgent for anyone running the LAPR module or the REST API delta feed.

This release changes the database schema. UPGRADE_MIN_DATE is raised, so every installation goes through the upgrade wizard once. The migration adds items.revision_changed_at, backfills it where the journal still holds the matching row, and seeds two new LAPR settings.

🔒 Security fixes

  • LAPR endpoints are now scoped to the caller (GHSA-mq4w-p2gw-pqp4) - LAPR scoped managed items by folder but never scoped endpoints. Any operator holding can_manage_lapr could attach an account to any enrolled endpoint and rotate it: the worker reads the endpoint SSH credential as TP_USER, which bypasses folder ACLs by design, so it changed the password of the Linux account named in the attacker's own item and wrote the new password back into that item. Enrollment required read access to the credential item; nothing re-established that link afterwards. New laprUserCanUseEndpoint() and laprGetUserAccessibleEndpointIds() resolve the endpoint's SSH credential item and apply the same folder read check, failing closed. The check is applied to every endpoint-driven handler, the three list handlers are scoped (they disclosed hostname, port and SSH username of every endpoint), and the polling handlers are bound to the task author (task ids are a sequential AUTO_INCREMENT) so discover_status would otherwise have returned the local account list of a remote host to anyone enumerating ids.

  • The SSH host key is verified before the credential is transmitted (GHSA-h25r-8cc9-2cg8) - LAPRSshService::connect() called login(), which transmits the credential, before the TOFU fingerprint was ever computed; the only comparison lived in the caller, after connect() had returned, and the discovery trait performed no comparison at all. A man-in-the-middle therefore received the plaintext SSH password of a privileged account (root, or sudo-for-chpasswd) and the mismatch was detected too late to matter. phpseclib validates the host key signature inside getServerPublicHostKey() without authenticating, so connect() now takes an optional expected fingerprint and disconnects on mismatch without ever calling login(). Already-enrolled endpoints keep their stored fingerprint — no re-TOFU is needed.

  • delete_account and update_account_policy now check folder scope (GHSA-v7hq-28rv-qh3p) - these two were the only mutating LAPR account handlers not given the session object, so neither checked folder scope while their four siblings did. The load-bearing case is update_account_policy: rotation policies are global and any operator can create one, so an attacker could point a privileged account at a minimum-length, single-character-class policy, and since laprComputeNextRotation() clamps a past due date to now, the next scheduler tick pushed that weak password to the remote Linux account. That is an integrity attack on live credential material, not a disruption. delete_account additionally let anyone silently unmanage any account and cancel its queued rotations.

  • The API delta feed no longer leaks instance-wide item activity (GHSA-9823-p8pg-rfm4) - GET /api/v1/item/changes falls back to a folder id of 0 when the caller has no accessible folder. That sentinel is inert against items.id_tree, since no folder carries id 0, but items_revisions.previous_folder_id is NOT NULL DEFAULT 0 and is only written on a move — so every creation, update, deletion and restore in the instance matched previous_folder_id IN (0). An API caller with zero folder rights learned the ids, change ordering and activity rate of the whole instance. No secret material was exposed: payloads are built by getItems(), which scopes correctly. The branch now requires previous_folder_id > 0, and a caller with neither an accessible folder nor a restricted item short-circuits to an empty delta.

  • The item history detail is encoded at the sink (GHSA-mwxw-gg4p-8xpc) - load_item_history composed an HTML string with the previous field value interpolated into a <span>, and loadItemHistory() concatenated it into the markup handed to .html(). A payload planted in a label, login, url, email, tag or custom field became live DOM for every user opening the item after the field was edited, because the client purifier hands values back as plain unescaped text with the entities already decoded. Seven server-side branches were also emitting raw attacker markup and are now escaped, defence in depth, which additionally restores a legitimate previous value containing <...> that the purifier used to swallow.

  • The item login is rendered with .text() (GHSA-47xg-w656-j4v4) - the item detail card wrote the login with .html(), while the email and url right below it already used .text() and the label used htmlEncode(). A login carrying markup became live DOM for every user with read access opening the item. The node is also the clipboard target, so this additionally keeps the copied value equal to the stored login.

  • Two entity-decoding helpers no longer build live DOM (GHSA-5vf9-rxqv-g8wp) - the originally reported sink no longer exists, but the reason it could ship does: the encoding sentinel never watched url, and its regex only matched a value concatenated into a markup string, so it was blind to a value handed straight to a method that parses HTML. Widening it surfaced two real cases, items.js.php and users.js.php decoded entities with $('<div>').html(value).text(), which builds a live detached node where an <img> fires its handlers before .text() drops the markup. Both now use htmlDecode(), which parses in an inert DOMParser document.

  • The command palette applies the item-level ACL - Ctrl+K scoped its item query to the accessible folders and stopped there. An item restricted to named users or to roles lives in a folder the caller can otherwise see, so the palette returned its label, login and folder path to a user who cannot open it. The same predicate the two other search entry points already used is now built in palette.queries.php, along with the two guards its query was missing (items.deleted_at, and the perso/author test for a personal item in a folder whose flag was never written). Everything is enforced in SQL, so it applies before the LIMIT 30. No secret is involved, the palette reads the cache, never items.pw.

✨ New features

  • File integrity and permission diagnostics in System Health (PR #5352, @guerricv) - replaces the legacy "unknown files" counter and its web deletion workflow with one read-only diagnostic shared by the admin dashboard, System Health, the background tasks and the CLI. It parses the release app/files_reference.txt manifest, reports missing, modified and unexpected files, and adds file permission diagnostics. A companion CLI script (app/scripts/file_integrity.php) covers SSH-only environments. The deletion workflow is deliberately gone: reporting is safe, bulk deletion from a web page was not.

  • Folder search, filtering and reset controls (PR #5349, @guerricv) - the search page now returns matching folders in a visually distinct section above the item results, each with its icon, title and authorized tree path. Folder titles join the default text-search fields, a folder filter narrows item results to one subtree, and a reset control clears the whole form. All folder scoping is resolved server-side against the caller's authorized tree.

  • Periodic LAPR endpoint checks and per-endpoint rotation pause (PR #5354, @guerricv) - enrolled endpoints previously kept their enrollment-time OS and privilege information forever. A scheduler now re-checks them on a configurable cadence (lapr_endpoint_check_interval_minutes, default 24 h), refreshing OS info, capabilities and status while enforcing the trusted host fingerprint. Transient outages retry on the shorter lapr_retry_delay_minutes so recovery can unblock a pending rotation promptly. An endpoint can also be paused, which suspends its automatic rotations and cancels queued tasks without rewriting account state or dates; a manual break-glass rotation requires a server-validated confirmation and preserves the pause.

  • Repair the encryption keys of your own personal items - a personal object carries keys for its owner and the internal account only. When the internal one is gone, nothing running server-side can open the object, so the repair task and the administrator were both powerless, and the owner, who could still read it, had no way to say so. My Profile now offers Repair my personal items encryption keys: it walks the caller's own personal tree, unwraps each object key with the key they still hold, and writes back the internal reference key, from where the background task can finish the job. It runs synchronously and batched by the client on purpose, the private key exists only for the length of the session.

🛠️ Improvements

  • The background repair task now covers personal objects - the sharekeys repair tool ignored them entirely, so a personal item whose owner lost their sharekey stayed unreadable with no way back. Extending the shared pass would have recreated the SEC-8 leak, so a dedicated owner-only pass resolves the owner from the personal tree, cross-checks it against the item creator, and writes exactly one sharekey. Foreign keys left by a pre-SEC-8 install are removed only once the object is known to be recoverable, never before, because they are the last remaining way to open an object the internal account cannot decrypt.

  • Personal objects are excluded from key redistribution by containment, not by flag - the three places that exclude personal folders resolved the list from the folder's own personal_folder column. A sub-folder created under a personal root keeps that flag at 0 when it was never written (legacy data, copy_folder, import), and items.perso is no fallback either. Consequences: the repair task redistributed the keys of items in such a sub-folder to every eligible user, the TP_USER exclusion list left another user's personal sub-folder in scope, and Security Posture could keep such a folder authorized through a role grant. All three now use getPersonalFolderIdsWithDescendants(), which resolves containment from the nested tree bounds.

  • item/update preserves the API password history (PR #5343, @guerricv) - a password change through the REST API used to succeed silently and leave a hole in the history. The server now decrypts the current value before any mutation, compares with hash_equals() so an unchanged password is a genuine no-op, and stores the previous value in log_items.old_value exactly like the web UI. Behaviour change: when the caller holds no usable sharekey the update is now rejected with 422 and nothing is written. The common cause is transient — the background sharekey fan-out is still running, and the message says to retry.

  • revision_changed_at exposed alongside revision (PR #5343, @guerricv) - a Unix UTC timestamp paired with the item revision, returned everywhere revision is, so an offline client can order changes without inferring a date from the unreliable items.updated_at. It changes only when a functional content revision is allocated, never on a read or a ciphertext-only rewrite. null means no reliable date exists.

  • Every Tools query builds its table name with prefixTable() (issue #5347) - the Tools page hard-coded the default teampass_ prefix in 13 queries. On an installation using another prefix the query hit a missing table and db_error_handler() threw an uncaught exception that aborted the page: Restore keys and Restore missing sharekeys never rendered, and the AJAX handlers behind them plus the personal-items migration stats were broken the same way. A table-prefix-guard CI job now reads the reference table list from the installer so the rule cannot regress.

  • One payload normalization for every main.queries.php handler (PR #5357, @guerricv) - prepareExchangedData(..., 'decode') returns an empty string when no data was posted or when decryption failed, and json_decode() returns null or a scalar for a payload that is not an object. Reading an offset on any of those is a fatal TypeError in PHP 8, and around forty such accesses remained across the six handlers, mailHandler's mail_me case being reachable by any authenticated user with no gate at all. The payload is now normalized once in mainQuery() before dispatch, and the six handler signatures are narrowed to array so the invariant is checked instead of assumed.

  • Published Docker images report the real version - the build argument came from github.ref_name, so the image published as :latest carried TEAMPASS_VERSION=master and org.opencontainers.image.version=master. The version is now read from app/config/include.php, the same source of truth the entrypoint uses. Builds of one commit are also serialized so they no longer race to overwrite the shared sha-<short> tag.

  • Graph Application permissions documented for OAuth2 synchronization - the permissions an Azure Entra app registration actually needs are now spelled out in the documentation.

🐛 Bug fixes

  • An empty custom field is no longer reported as unreadable (issue #5342) - doDataDecryption() returns an empty string both when the decryption fails and when the stored value legitimately decrypts to empty, and every caller read that emptiness as a failure. A readable empty field was shown with the "could not be decrypted" warning and, since the data-loss guard was added, could no longer be cleared either, preserved forever. Rows in that exact state exist in the wild. A new doDataDecryptionWithStatus() reports whether the decryption returned, and is used wherever emptiness becomes a verdict. The API read path had the mirror problem: it decided from the category encrypted_data flag instead of the stored row, so a value encrypted before the flag was turned off was returned as raw ciphertext, which a read-modify-write client then wrote back as plaintext. repair_unencrypted_fields.php is also repaired: both of its key lookups fed a private key still wrapped with its owner password, so it declared recoverable values lost and advised deleting them.

  • The One-Time View page decodes stored HTML entities (issue #5350) - item fields never reach the database as raw markup, and every other consumer decodes them before rendering; the OTV page did not. A description therefore reached the recipient as literal <p>text</p>, and a label or login containing ' or & showed raw entities. The description is now sanitized with HTMLPurifier rather than strip_tags(), strip_tags() keeps every attribute, and the page is served unauthenticated, so decoding before it would have reopened a previously fixed stored-XSS sink.

  • OAuth2 self-registration assigns the fallback role again (issue #5351, reported by @spilipenko-bit) - the fallback assigned the configured role to $userGroups, a variable no longer read by anything, so a self-registered OAuth2 user was created with no role at all. Until 3.1.6.2 that variable doubled as the result; moving role assignment to the users_roles table dropped both the implode and the consumer but left the fallback writing to it. The assignment has been dead since. It now writes to $groupIds, and tests $groupIds so the fallback also covers a user whose directory groups match no TeamPass role, as the setting description promises. The fallback is restricted to OAuth2, LDAP has no equivalent setting and would otherwise have every account inherit the OAuth2 role.

  • The Docker container applies the full upgrade chain - the standalone upgrade_run_X.Y.Z.php scripts never write teampass_version; only the web wizard does. The container auto-upgrade therefore left the recorded version frozen, replayed the same migration on every boot and kept the install directory reachable forever. It also ran a single script, the one matching the image version, so a 3.2.0 database moved to a 3.2.2 image skipped upgrade_run_3.2.1.php, which creates tables that 3.2.2 only alters behind a SHOW INDEX probe, and the schema stayed silently incomplete. Every intermediate step now runs in numeric order, the version reached is recorded after each one, and the chain stops at the first failure (read from the script JSON output, since these scripts exit 0 even on error). Databases older than 3.1.5 are still left to the web wizard.

  • Search no longer hides items in deeply nested personal folders - find.queries.php and palette.queries.php filtered results through a parent-based heuristic that kept only the personal root and its direct children, so any deeper descendant was treated as another user's personal folder. Its items were absent from the quick search and the command palette while remaining visible when browsing and on the Search page. The heuristic was inert in find.queries.php until a DB::query() call became DB::queryFirstRow(); the NOT IN clause has shipped since 3.2.1.4. Both handlers now resolve the scope through searchResolveFolderScope().

  • save_user_location no longer fatals on an invalid request (PR #5357, @guerricv) - a production PHP-FPM log exposed an intermittent TypeError: Cannot access offset of type string on string. Fixed as part of the payload normalization above.

  • The restored-folder query uses valid named arguments (@guerricv) - a malformed call in the recycle-bin restore path.

  • The search placeholder falls back to a current English string - a stale English fallback was blocking new translations of the search placeholder in other languages.

⬆️ Upgrade notes

  • Schema. The upgrade adds items.revision_changed_at (BIGINT UNSIGNED NULL) and backfills it only where the current item revision still has its exact row in the journal, older entries may already have been pruned and their timestamp is genuinely unknown, so it stays NULL rather than being guessed. UPGRADE_MIN_DATE is raised, so every installation runs the wizard once.

  • Periodic LAPR endpoint checks are seeded OFF on upgrade, ON for fresh installs. An upgrade must not start new outbound SSH traffic without an administrator opting in. If you run LAPR and want the new monitoring, turn on Periodically check enrolled servers in the LAPR settings page after upgrading; the check interval defaults to 24 h.

  • PUT /api/v1/item/update can now answer 422 on a password change. The endpoint used to succeed silently when the caller had no usable sharekey, leaving a hole in the password history. It now fails closed. The usual cause is transient — the background sharekey fan-out is still running — so retry; treat it as permanent only if it survives the encryption-keys repair task. Every other field stays updatable in the meantime.

  • The "unknown files" deletion workflow is gone. It is replaced by the read-only file integrity diagnostic in System Health. Reviewing and removing unexpected files is now a deliberate operator action, from the shell or the new app/scripts/file_integrity.php CLI.

  • If you use a non-default table prefix, re-check the Tools page — "Restore keys" and "Restore missing sharekeys" were invisible before this release and are now available.

  • Back up your database before upgrading, as always.

Full Changelog: 3.2.2.0...3.2.2.1

Important

  • Requires at least PHP 8.2

Languages

Please join Teampass v3 translation project on Poeditor and translate it for your language.

Installation

Follow instructions from Documentation.

Upgrade

Follow instructions from Documentation.

Ideas and comments

Are welcome ... please use Discussions.

Download TeamPass