Compare commits

...
10 Commits
Author SHA1 Message Date
github-actions[bot] 63a8657e0a Bump versions for v9.99 2026-07-17 12:10:21 +00:00
Lauri Ojansivu b846e6d565 Updated ChangeLog. 2026-07-17 15:09:11 +03:00
Lauri Ojansivu 580276079e Fix #6476: snap FerretDB wait loop had no timeout, so WeKan hung with no web port.
With database=ferretdb, snap-src/bin/wekan-control waits for FerretDB to accept
connections before starting node - WeKan does not open its HTTP port until the
database answers. That wait loop had no timeout and no diagnostics, so if
FerretDB never started listening (crashed or arch-incompatible per-arch binary,
locked/corrupt SQLite, or the service left disabled by a failed migration
hand-off), WeKan blocked there forever: the service showed active but the port
never opened and the only symptom was a silent 'FerretDB not ready yet' loop.
uusijani's wekan.log stopped right after the startup env dump, confirming
control never reached node main.js.

Mirror the MongoDB branch: after WEKAN_DB_WAIT_TIMEOUT seconds (default 120,
overridable) print an actionable hint once, then keep retrying - pointing to
'snap logs <snap>.ferretdb', naming the SQLite dir to check for locks/corruption,
the arch-mismatch case, the snap start --enable recovery, and the
'snap set <snap> database=mongodb' fallback. Regression test
tests/ferretdbWaitTimeout.test.cjs, including a negative guard that the wait is
not a silent unbounded loop again.

Thanks to uusijani (report) and xet7 (fix).
2026-07-17 15:04:48 +03:00
Lauri Ojansivu 9a387acce0 Translations: auto-heal English-reverted languages on pull; force-push helpers.
tx pull -f fills any string untranslated on Transifex with the English source, so
languages whose translations live only in git silently revert to English on every
pull. Make the pull self-heal, and fix the push helpers so restored translations
actually upload.

- pull-translations.sh: after the pull, for each language file whose changes are
  ENTIRELY reverts, git checkout HEAD -- <file> to restore the committed
  translations and force-push them back (../tx push -t -l <lang> -f). Files that
  also carry a real new/updated translation from Transifex are NOT auto-healed
  (a whole-file checkout would discard it) and are left for manual handling.

- report-english-regressions.mjs: add a machine-readable --files mode and an
  otherChanges classifier, so only pure-revert files are offered for auto-heal;
  the human report tags mixed files.

- push-translation.sh: add -f (without it the Go tx CLI skips a language whenever
  the local file is not newer than the server copy - push.go shouldSkipPush - so
  a freshly reverted file uploads nothing), plus a usage guard.

- push-copy-en-gb-translation.sh (new): copy en.i18n.json to en-GB.i18n.json
  verbatim and force-push it as the en_GB translation, so British English shows
  as translated instead of reverting to the English source on each pull.

Thanks to xet7.
2026-07-17 14:14:31 +03:00
Lauri Ojansivu 7374b7e00d Fix #6467 #6469: LDAP connection leak exhausted the directory server.
Operators reported that after an update WeKan "dies really quickly", restarts
did not help, and it "kills our openldap server with too many open connections";
others saw logins hang for minutes then fail with "Must be logged in". Every
LDAP login attempt (loginHandler.js) and every background sync tick (sync.js)
created a new LDAP() and connect()ed but NEVER called disconnect() on any path,
so each attempt - including every failed login and every one-minute sync tick -
leaked a socket to the LDAP/AD server until it hit its connection limit and
started refusing connections, taking it and all WeKan logins down.

Guarantee release on every exit path: a shared runWithLdapDisconnect() helper
(connectionGuard.js) disconnects in a finally; the login handler runs its whole
flow through it; background sync() disconnects in a finally; importNewUsers()
disconnects only the connection it owns; and LDAP.disconnect() is a safe no-op
when connect() was never reached. Adds behavioural + negative + source-guard
tests (tests/ldapConnectionRelease.test.cjs, wired into test:unit:node).

Also bumps the bundled FerretDB whose SQLite pool no longer caps MaxOpenConns
at 16 (that starved WeKan's cursor load: minutes-long board loads, failed
logins).

Thanks to xet7.
2026-07-17 13:35:42 +03:00
github-actions[bot] e15c920872 Bump versions for v9.98 2026-07-17 03:16:35 +00:00
Lauri Ojansivu d1ac53b367 Updates 2026-07-17 05:03:22 +03:00
Lauri Ojansivu c951b40e5e Changelog: Upcoming security section for EscapeBleed and RandomBleed.
Document the two GitHub CodeQL code scanning fixes already committed
(alerts #423 js/incomplete-sanitization and #422
js/biased-cryptographic-random) in a new Upcoming section above v9.97,
linked to the wekan.fi Hall of Fame entries.

Thanks to GitHub CodeQL and xet7.
2026-07-17 04:57:32 +03:00
Lauri Ojansivu be6ebb2503 Fix CodeQL js/biased-cryptographic-random in schema upgrade randomId.
256 is not a multiple of the 55-character id alphabet, so the plain
byte % 55 mapping skewed generated document ids toward the first
characters. randomId now uses rejection sampling: bytes at or above the
largest multiple of the alphabet size (220) are discarded and resampled,
so every character is equally likely. Ids stay Meteor-style 17-char and
exact-length for custom lengths. Resolves code scanning alert #422.

Tests: tests/schemaUpgradeSteps.test.cjs gains a rejection-sampling
guard (negative case pins that out-of-range bytes are never wrapped).

Thanks to xet7.
2026-07-17 04:52:30 +03:00
Lauri Ojansivu d6ddcffcc2 Fix CodeQL js/incomplete-sanitization in maximizedCardPosition test.
The CSS-declaration-to-RegExp conversion escaped only parentheses;
escape the full regex metacharacter set (including backslash) instead.
Resolves code scanning alert #423. Test-only, fixed input list, but the
complete escape is correct and matches the pattern already used in
tests/archiveLinkGrouping.test.cjs.

Thanks to xet7.
2026-07-17 04:52:30 +03:00
25 changed files with 857 additions and 238 deletions

No files matched your search

+97
View File
@@ -86,6 +86,103 @@ them up next.
same `params.user` feeds both the e-mail notification text, where the full name is intended, and the webhook payload,
where a username is expected; the safe change is to ADD a `username` field to the webhook rather than repurpose `user`).
# v9.99 2026-07-17 WeKan ® release
This release fixes the following BUG:
- **LDAP connection leak: WeKan exhausted the directory server with "too many open connections"**
([#6467](https://github.com/wekan/wekan/issues/6467), [#6469](https://github.com/wekan/wekan/issues/6469)).
Operators reported that after an update WeKan "dies really quickly", that restarting the server did not
help, and that it "kills our openldap server with too many open connections"; others saw logins hang for
minutes and then fail with *"Must be logged in"*. Root cause: every LDAP login attempt
(`packages/wekan-ldap/server/loginHandler.js`) and every background sync run
(`packages/wekan-ldap/server/sync.js`) created a fresh `new LDAP()` and called `connect()`, but the code
**never called `disconnect()` on any path** — success, failure, or fallback. Each login attempt (including
every *failed* one) and each background-sync tick (every minute by default) therefore leaked one socket to
the LDAP/AD server. Over time this grew without bound until the directory server hit its per-client
connection limit and started refusing connections, which took it — and, with it, every WeKan login — down.
- **Fixed** by guaranteeing the connection is always released:
- A small shared helper `packages/wekan-ldap/server/connectionGuard.js` (`runWithLdapDisconnect(ldap, fn)`)
runs the work and `disconnect()`s in a `finally`, on every exit path. The disconnect is best-effort and
never masks the original result or error.
- The LDAP login handler now runs its whole flow through `runWithLdapDisconnect`, so `connect()` is always
paired with a `disconnect()` — on a successful login, a thrown `Meteor.Error`, or a fallback to the
default account system.
- The background `sync()` releases its connection in a `finally`, and `importNewUsers()` disconnects only
the connection it opened itself (never a connection borrowed from `sync()`).
- The admin `ldap_test_connection` method (`packages/wekan-ldap/server/testConnection.js`), which had the
same leak, now disconnects on both the success and failure paths, so repeated "Test Connection" clicks
no longer leak either.
- `LDAP.disconnect()` is now a safe no-op when `connect()` was never reached, so cleanup can never throw.
- **Tests** (`tests/ldapConnectionRelease.test.cjs`, added to `test:unit:node`): behavioural coverage of the
guard (disconnects after success and returns the result; disconnects exactly once; disconnects *and*
re-throws when the work throws — the failed-login path that exhausted OpenLDAP), negative cases (a failing
disconnect never turns a success into a failure nor hides the real error; a missing/`null` ldap is
tolerated), plus source-level guards that the leak-prone call sites actually route through the guard and
that `connect()` lives inside the guarded region.
- Thanks to the reporting operators and **xet7** (fix).
This release also updates the bundled FerretDB (see the fork's own CHANGELOG Upcoming): the SQLite backend
connection pool no longer caps `MaxOpenConns` at 16, which had starved WeKan's cursor-heavy load and made
boards take minutes to load and logins fail with *"Must be logged in"* ([#6467](https://github.com/wekan/wekan/issues/6467),
[#6469](https://github.com/wekan/wekan/issues/6469)).
- **Snap: FerretDB never becoming ready made WeKan hang with no web port and no explanation**
([#6476](https://github.com/wekan/wekan/issues/6476)). With `database=ferretdb`, `snap-src/bin/wekan-control`
waits for FerretDB to accept connections *before* starting WeKan — WeKan does not open its HTTP port until
the database answers. That wait loop had **no timeout and no diagnostics**: if FerretDB never started
listening (a crashed or CPU/architecture-incompatible per-arch binary, a locked or corrupt SQLite database,
or the FerretDB service left disabled by a failed migration hand-off), WeKan blocked there forever. The
service showed as `active`, but the port never opened and the only symptom was a silent
`FerretDB not ready yet, retrying in 5 seconds` repeating — exactly what #6476 reported (Apache proxy could
not connect to port 3333). The reporter's `wekan.log` stopped right after the startup env dump, confirming
control never reached `node main.js`.
- **Fixed** by mirroring the MongoDB branch: after `WEKAN_DB_WAIT_TIMEOUT` seconds (default 120,
overridable) the FerretDB wait loop now prints an actionable hint **once** and keeps retrying — pointing to
`snap logs <snap>.ferretdb` for the real error, naming the SQLite directory to check for locks/corruption,
the arch-mismatch (`exec format error`) case, the `snap start --enable <snap>.ferretdb` recovery, and the
`snap set <snap> database=mongodb` fallback to keep working meanwhile. This turns "port never opens, no
clue why" into a clear message. Regression test: `tests/ferretdbWaitTimeout.test.cjs`, including a negative
guard that the FerretDB wait is not a silent unbounded loop again.
- This is a robustness/diagnostics fix; the underlying reason FerretDB was not accepting connections is
environment-specific and lives in the FerretDB service log.
- Thanks to **uusijani** (report) and **xet7** (fix).
Thanks to above for their contributions.
# v9.98 2026-07-17 WeKan ® release
This release fixes the following SECURITY ISSUES found by GitHub CodeQL code scanning:
- **[EscapeBleed](https://wekan.fi/hall-of-fame/escapebleed/): incomplete string escaping when
building a regular expression** (GitHub CodeQL code scanning alert #423, rule
`js/incomplete-sanitization`, CWE-116 Improper Encoding or Escaping of Output;
`tests/maximizedCardPosition.test.cjs`). Code that turned a CSS declaration into a
`RegExp` escaped only parentheses (`str.replace(/[()]/g, '\\$&')`) instead of the full
regex metacharacter set — an input containing other metacharacters (including a backslash)
would not be escaped correctly, so the generated pattern could match the wrong thing.
- **Fixed** by escaping the complete metacharacter set
(`str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')`), matching the correct pattern already used
elsewhere in the tests. This is test-only code with a fixed, trusted input list, so there
was no injection exposure, but the incomplete escape was genuinely wrong.
- Thanks to **GitHub CodeQL** (code scanning alert #423) and **xet7** (fix).
- **[RandomBleed](https://wekan.fi/hall-of-fame/randombleed/): biased random ids from a
cryptographically secure source** (GitHub CodeQL code scanning alert #422, rule
`js/biased-cryptographic-random`, CWE-1204 / weak randomness; `server/lib/schemaUpgradeSteps.js`).
The startup schema upgrade generates Meteor-style document ids with
`crypto.randomBytes(len)` mapped through `byte % ID_CHARS.length`. Because 256 is not a
multiple of the 55-character alphabet, that modulo skews generated ids toward the first 36
characters of the alphabet (each ~1.4% more likely than the rest) — reducing entropy of the
ids used for the swimlanes/lists/checklist-items the upgrade creates.
- **Fixed** with rejection sampling: bytes at or above the largest multiple of the alphabet
size (220) are discarded and resampled, so every character is exactly equally likely; ids
stay Meteor-style 17 characters (and exact-length for custom lengths). A negative
regression test pins that out-of-range bytes are never wrapped.
- Thanks to **GitHub CodeQL** (code scanning alert #422) and **xet7** (fix).
Thanks to above for their contributions.
# v9.97 2026-07-17 WeKan ® release
This release adds the following new features:
+1 -1
View File
@@ -7,7 +7,7 @@ LABEL org.opencontainers.image.source="https://github.com/wekan/wekan"
# TARGETARCH and TARGETVARIANT are automatically provided by Docker Buildx
ARG TARGETARCH
ARG TARGETVARIANT
ARG VERSION=9.97
ARG VERSION=9.99
ARG DEBIAN_FRONTEND=noninteractive
ENV BUILD_DEPS="apt-utils gnupg wget bzip2 g++ curl libarchive-tools build-essential git ca-certificates python3 unzip"
+1 -1
View File
@@ -1,5 +1,5 @@
appId: wekan-public/apps/77b94f60-dec9-0136-304e-16ff53095928
appVersion: "v9.97.0"
appVersion: "v9.99.0"
files:
userUploads:
- README.md
+2 -2
View File
@@ -10,7 +10,7 @@ This is without container (without Docker or Snap).
Right click and download files 1-4:
1. [wekan-9.97-win64.zip](https://github.com/wekan/wekan/releases/download/v9.97/wekan-9.97-win64.zip) or newest available at https://github.com/wekan/wekan/releases/
1. [wekan-9.99-win64.zip](https://github.com/wekan/wekan/releases/download/v9.99/wekan-9.99-win64.zip) or newest available at https://github.com/wekan/wekan/releases/
2. [node.exe](https://nodejs.org/dist/latest-v24.x/win-x64/node.exe)
@@ -22,7 +22,7 @@ Right click and download files 1-4:
6. Double click `mongodb-windows-x86_64-7.0.34-signed.msi` . In installer, uncheck downloading MongoDB compass.
7. Unzip `wekan-9.97-win64.zip` , inside it is directory `bundle`, to it copy other files:
7. Unzip `wekan-9.99-win64.zip` , inside it is directory `bundle`, to it copy other files:
```
bundle (directory)
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "wekan",
"version": "v9.97.0",
"version": "v9.99.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "wekan",
"version": "v9.97.0",
"version": "v9.99.0",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "wekan",
"version": "v9.97.0",
"version": "v9.99.0",
"description": "Open-Source kanban",
"private": true,
"repository": {
@@ -25,7 +25,7 @@
},
"scripts": {
"test": "meteor test --once --driver-package meteortesting:mocha",
"test:unit:node": "node tests/filenameSanitizer.test.cjs && node tests/cardBoardConsistency.test.cjs && node tests/cardUrl.test.cjs && node tests/defaultSwimlane.test.cjs && node tests/cardMoveModifier.test.cjs && node tests/authenticationMethod.test.cjs && node tests/linkedCardTarget.test.cjs && node tests/staleWhileRevalidate.test.cjs && node tests/restArrayParam.test.cjs && node tests/minicardLabelHeight.test.cjs && node tests/dateUtils.normalizeDigits.test.cjs && node tests/reconcileBoardTeamMembers.test.cjs && node tests/rulesBoardSelector.test.cjs && node tests/ruleActionResolve.test.cjs && node tests/boardBackground.test.cjs && node tests/weekStart.test.cjs && node tests/ldapUsernameSlug.test.cjs && node tests/ldapGroupAllowlist.test.cjs && node tests/htmljsArrayContent.test.cjs && node tests/cardDescriptionDraft.test.cjs && node tests/commentDraft.test.cjs && node tests/attachmentDeleteGuard.test.cjs && node tests/ruleMoveAction.test.cjs && node tests/snapMigrationRecovery.test.cjs && node tests/migrationAttachmentExtraction.test.cjs && node tests/attachmentRepair.test.cjs && node tests/attachmentStoragePath.test.cjs && node tests/schemaUpgradeSteps.test.cjs && node tests/cpuExec.test.cjs && node tests/cpuExecWiring.test.cjs && node tests/ferretdbHasData.test.cjs && node tests/ferretdbPolling.test.cjs && node tests/uiDensity.test.cjs && node tests/editorSubmitKey.test.cjs && node tests/isoWeek.test.cjs && node tests/boardCopyDefaults.test.cjs && node tests/linkedCardTitle.test.cjs && node tests/commentActivity.test.cjs && node tests/swimlaneSort.test.cjs && node tests/subtaskCopy.test.cjs && node tests/subtasksDefaultBoard.test.cjs && node tests/scrollbarCss.test.cjs && node tests/minicardBidiIsolation.test.cjs && node tests/notificationCleanup.test.cjs && node tests/cardSearch.test.cjs && node tests/linkedCardMembers.test.cjs && node tests/verificationEmail.test.cjs && node tests/boardSortReorder.test.cjs && node tests/checklistItemTitles.test.cjs && node tests/swimlaneFilter.test.cjs && node tests/popupOffset.test.cjs && node tests/linkedCardCover.test.cjs && node tests/inviteEmailLanguage.test.cjs && node tests/domainTablePage.test.cjs && node tests/mongoSelectorSafety.test.cjs && node tests/cardsLoading.test.cjs && node tests/base64Chunk.test.cjs && node tests/backupPaths.test.cjs && node tests/backupCron.test.cjs && node tests/avatarUrlSafety.test.cjs && node tests/importedUserReconciliationPlan.test.cjs && node tests/ldapPhoto.test.cjs && node tests/importedBoardMemberPlan.test.cjs && node tests/unit/resetPasswordEmail.test.js",
"test:unit:node": "node tests/filenameSanitizer.test.cjs && node tests/cardBoardConsistency.test.cjs && node tests/cardUrl.test.cjs && node tests/defaultSwimlane.test.cjs && node tests/cardMoveModifier.test.cjs && node tests/authenticationMethod.test.cjs && node tests/linkedCardTarget.test.cjs && node tests/staleWhileRevalidate.test.cjs && node tests/restArrayParam.test.cjs && node tests/minicardLabelHeight.test.cjs && node tests/dateUtils.normalizeDigits.test.cjs && node tests/reconcileBoardTeamMembers.test.cjs && node tests/rulesBoardSelector.test.cjs && node tests/ruleActionResolve.test.cjs && node tests/boardBackground.test.cjs && node tests/weekStart.test.cjs && node tests/ldapUsernameSlug.test.cjs && node tests/ldapGroupAllowlist.test.cjs && node tests/ldapConnectionRelease.test.cjs && node tests/htmljsArrayContent.test.cjs && node tests/cardDescriptionDraft.test.cjs && node tests/commentDraft.test.cjs && node tests/attachmentDeleteGuard.test.cjs && node tests/ruleMoveAction.test.cjs && node tests/snapMigrationRecovery.test.cjs && node tests/migrationAttachmentExtraction.test.cjs && node tests/attachmentRepair.test.cjs && node tests/attachmentStoragePath.test.cjs && node tests/schemaUpgradeSteps.test.cjs && node tests/cpuExec.test.cjs && node tests/cpuExecWiring.test.cjs && node tests/ferretdbHasData.test.cjs && node tests/ferretdbPolling.test.cjs && node tests/ferretdbWaitTimeout.test.cjs && node tests/uiDensity.test.cjs && node tests/editorSubmitKey.test.cjs && node tests/isoWeek.test.cjs && node tests/boardCopyDefaults.test.cjs && node tests/linkedCardTitle.test.cjs && node tests/commentActivity.test.cjs && node tests/swimlaneSort.test.cjs && node tests/subtaskCopy.test.cjs && node tests/subtasksDefaultBoard.test.cjs && node tests/scrollbarCss.test.cjs && node tests/minicardBidiIsolation.test.cjs && node tests/notificationCleanup.test.cjs && node tests/cardSearch.test.cjs && node tests/linkedCardMembers.test.cjs && node tests/verificationEmail.test.cjs && node tests/boardSortReorder.test.cjs && node tests/checklistItemTitles.test.cjs && node tests/swimlaneFilter.test.cjs && node tests/popupOffset.test.cjs && node tests/linkedCardCover.test.cjs && node tests/inviteEmailLanguage.test.cjs && node tests/domainTablePage.test.cjs && node tests/mongoSelectorSafety.test.cjs && node tests/cardsLoading.test.cjs && node tests/base64Chunk.test.cjs && node tests/backupPaths.test.cjs && node tests/backupCron.test.cjs && node tests/avatarUrlSafety.test.cjs && node tests/importedUserReconciliationPlan.test.cjs && node tests/ldapPhoto.test.cjs && node tests/importedBoardMemberPlan.test.cjs && node tests/unit/resetPasswordEmail.test.js",
"test:unit:all": "npm run test:unit:node && node tests/cards.stickers.test.js && node tests/stickers.test.js && node tests/stickers.packs.test.js && node tests/linkCardPopup.test.js && node tests/trelloCreator.import.test.js && node tests/trelloImport.maplink.test.js && node tests/trelloImport.zip.test.js && node tests/wekanCreator.import.test.js && node tests/wekanCreator.inconsistent.test.js && node tests/unit/oauth2-admin-groups.test.js",
"test:watch": "meteor test --driver-package meteortesting:mocha",
"test:e2e": "node tests/e2e/list-regressions.js",
@@ -0,0 +1,37 @@
'use strict';
// #6467 / #6469: guarantee that an LDAP connection is ALWAYS released.
//
// Every LDAP login attempt (loginHandler.js) and every background sync run
// (sync.js) creates a fresh `new LDAP()` and calls `connect()`. The old code
// never called `disconnect()` on any path, so each attempt leaked a socket to
// the directory server. Under normal traffic this grew without bound until the
// LDAP/AD server hit its per-client connection limit and started refusing
// connections ("too many open connections"), which took the directory server
// down and, with it, every WeKan login — matching the reported symptom of
// WeKan "dying really quickly" and killing the OpenLDAP server, and slow /
// failing logins ("Must be logged in").
//
// runWithLdapDisconnect runs `fn` and unbinds the connection in a `finally`, so
// the socket is closed on EVERY exit path: normal return, thrown error, or an
// LDAP-login `Meteor.Error`. The disconnect is best-effort and never masks the
// original result or error: `ldap.disconnect()` already swallows and logs its
// own errors, and this wrapper additionally tolerates a missing/!function
// `disconnect` (e.g. a partially constructed object in a test) so cleanup can
// never turn a successful login into a failure.
async function runWithLdapDisconnect(ldap, fn) {
try {
return await fn();
} finally {
if (ldap && typeof ldap.disconnect === 'function') {
try {
await ldap.disconnect();
} catch (error) {
// ldap.disconnect() logs internally; swallow here so releasing the
// connection can never change the login/sync outcome.
}
}
}
}
module.exports = { runWithLdapDisconnect };
+7
View File
@@ -548,6 +548,13 @@ export default class LDAP {
async disconnect() {
this.connected = false;
this.domainBinded = false;
// #6467/#6469: disconnect() is now called in a finally on every login and
// sync exit path, including paths where connect() was never reached or
// failed early. Guard against a missing client so releasing the connection
// is always a safe no-op and can never throw over the original result.
if (!this.client) {
return;
}
Log.info('Disconecting');
try {
await this.client.unbind();
+170 -161
View File
@@ -1,5 +1,6 @@
import {slug, getLdapUsername, getLdapEmail, getLdapUserUniqueID, syncUserData, addLdapUser, syncUserGroupsToOrgsTeams} from './sync';
import LDAP from './ldap';
import { runWithLdapDisconnect } from './connectionGuard';
import { log_debug, log_info, log_warn, log_error } from './logger';
// Org/team sync is optional enrichment; failed sync must not block login.
@@ -48,220 +49,228 @@ Accounts.registerLoginHandler('ldap', async function(loginRequest) {
const self = this;
const ldap = new LDAP();
let ldapUser;
try {
// #6467/#6469: run the whole login flow through runWithLdapDisconnect so the
// connection opened by ldap.connect() below is ALWAYS released, on every exit
// path (success, fallback, thrown Meteor.Error). Previously disconnect() was
// never called, so every login attempt leaked a connection to the directory
// server until it hit "too many open connections" and fell over.
return await runWithLdapDisconnect(ldap, async () => {
let ldapUser;
await ldap.connect();
try {
if (!!LDAP.settings_get('LDAP_USER_AUTHENTICATION')) {
await ldap.bindUserIfNecessary(loginRequest.username, loginRequest.ldapPass);
ldapUser = (await ldap.searchUsers(loginRequest.username))[0];
} else {
await ldap.connect();
const users = await ldap.searchUsers(loginRequest.username);
if (!!LDAP.settings_get('LDAP_USER_AUTHENTICATION')) {
await ldap.bindUserIfNecessary(loginRequest.username, loginRequest.ldapPass);
ldapUser = (await ldap.searchUsers(loginRequest.username))[0];
} else {
if (users.length !== 1) {
log_info('Search returned', users.length, 'record(s) for', loginRequest.username);
throw new Error('User not Found');
const users = await ldap.searchUsers(loginRequest.username);
if (users.length !== 1) {
log_info('Search returned', users.length, 'record(s) for', loginRequest.username);
throw new Error('User not Found');
}
if (await ldap.isUserInGroup(loginRequest.username, users[0])) {
ldapUser = users[0];
} else {
throw new Error('User not in a valid group');
}
if (await ldap.auth(users[0].dn, loginRequest.ldapPass) !== true) {
ldapUser = null;
log_info('Wrong password for', loginRequest.username)
}
}
if (await ldap.isUserInGroup(loginRequest.username, users[0])) {
ldapUser = users[0];
} else {
throw new Error('User not in a valid group');
}
if (await ldap.auth(users[0].dn, loginRequest.ldapPass) !== true) {
ldapUser = null;
log_info('Wrong password for', loginRequest.username)
}
}
} catch (error) {
log_error(error);
}
if (!ldapUser) {
if (LDAP.settings_get('LDAP_LOGIN_FALLBACK') === true) {
return fallbackDefaultAccountSystem(self, loginRequest.username, loginRequest.ldapPass);
} catch (error) {
log_error(error);
}
throw new Meteor.Error('LDAP-login-error', `LDAP Authentication failed with provided username [${ loginRequest.username }]`);
}
if (!ldapUser) {
if (LDAP.settings_get('LDAP_LOGIN_FALLBACK') === true) {
return fallbackDefaultAccountSystem(self, loginRequest.username, loginRequest.ldapPass);
}
// Look to see if user already exists
throw new Meteor.Error('LDAP-login-error', `LDAP Authentication failed with provided username [${ loginRequest.username }]`);
}
let userQuery;
// Look to see if user already exists
const Unique_Identifier_Field = getLdapUserUniqueID(ldapUser);
let user;
// Attempt to find user by unique identifier
let userQuery;
if (Unique_Identifier_Field) {
userQuery = {
'services.ldap.id': Unique_Identifier_Field.value,
};
const Unique_Identifier_Field = getLdapUserUniqueID(ldapUser);
let user;
// Attempt to find user by unique identifier
log_info('Querying user');
log_debug('userQuery', userQuery);
if (Unique_Identifier_Field) {
userQuery = {
'services.ldap.id': Unique_Identifier_Field.value,
};
user = await Meteor.users.findOneAsync(userQuery);
}
log_info('Querying user');
log_debug('userQuery', userQuery);
// Attempt to find user by username
user = await Meteor.users.findOneAsync(userQuery);
}
let username;
let email;
// Attempt to find user by username
if (LDAP.settings_get('LDAP_USERNAME_FIELD') !== '') {
username = slug(getLdapUsername(ldapUser));
} else {
username = slug(loginRequest.username);
}
let username;
let email;
if(LDAP.settings_get('LDAP_EMAIL_FIELD') !== '') {
email = getLdapEmail(ldapUser);
}
if (LDAP.settings_get('LDAP_USERNAME_FIELD') !== '') {
username = slug(getLdapUsername(ldapUser));
} else {
username = slug(loginRequest.username);
}
if(LDAP.settings_get('LDAP_EMAIL_FIELD') !== '') {
email = getLdapEmail(ldapUser);
}
if (!user) {
if(email && LDAP.settings_get('LDAP_EMAIL_MATCH_REQUIRE') === true) {
if (!user) {
if(email && LDAP.settings_get('LDAP_EMAIL_MATCH_REQUIRE') === true) {
if(LDAP.settings_get('LDAP_EMAIL_MATCH_VERIFIED') === true) {
userQuery = {
'_id' : username,
'emails.0.address' : email,
'emails.0.verified' : true
};
} else {
userQuery = {
'_id' : username,
'emails.0.address' : email
};
}
} else {
userQuery = {
username
};
}
log_debug('userQuery', userQuery);
user = await Meteor.users.findOneAsync(userQuery);
}
// Attempt to find user by e-mail address only
if (!user && email && LDAP.settings_get('LDAP_EMAIL_MATCH_ENABLE') === true) {
log_info('No user exists with username', username, '- attempting to find by e-mail address instead');
if(LDAP.settings_get('LDAP_EMAIL_MATCH_VERIFIED') === true) {
userQuery = {
'_id' : username,
'emails.0.address' : email,
'emails.0.address': email,
'emails.0.verified' : true
};
} else {
userQuery = {
'_id' : username,
'emails.0.address' : email
};
}
} else {
userQuery = {
username
log_debug('userQuery', userQuery);
user = await Meteor.users.findOneAsync(userQuery);
}
// Login user if they exist
if (user) {
if (user.authenticationMethod !== 'ldap' && LDAP.settings_get('LDAP_MERGE_EXISTING_USERS') !== true) {
log_info('User exists without "authenticationMethod : ldap"');
throw new Meteor.Error('LDAP-login-error', `LDAP Authentication succeded, but there's already a matching Wekan account in MongoDB`);
}
log_info('Logging user');
const stampedToken = Accounts._generateStampedLoginToken();
const update_data = {
$push: {
'services.resume.loginTokens': Accounts._hashStampedToken(stampedToken),
},
};
if (LDAP.settings_get('LDAP_SYNC_ADMIN_STATUS') === true) {
log_debug('Updating admin status');
const targetGroups = LDAP.settings_get('LDAP_SYNC_ADMIN_GROUPS').split(',');
const groups = (await ldap.getUserGroups(username, ldapUser)).filter((value) => targetGroups.includes(value));
user.isAdmin = groups.length > 0;
await Meteor.users.updateAsync({_id: user._id}, {$set: {isAdmin: user.isAdmin}});
}
if( LDAP.settings_get('LDAP_SYNC_GROUP_ROLES') === true ) {
log_debug('Updating Groups/Roles');
const groups = await ldap.getUserGroups(username, ldapUser);
if( groups.length > 0 ) {
Roles.setUserRoles(user._id, groups );
log_info(`Updated roles to:${ groups.join(',')}`);
}
}
// #4737: sync LDAP groups as Organizations/Teams at login too (default off).
await syncUserGroupsToOrgsTeamsSafe(ldap, ldapUser, user._id);
await Meteor.users.updateAsync({ _id: user._id }, update_data);
await syncUserData(user, ldapUser);
if (LDAP.settings_get('LDAP_LOGIN_FALLBACK') === true) {
await Accounts.setPasswordAsync(user._id, loginRequest.ldapPass, {logout: false});
}
return {
userId: user._id,
token: stampedToken.token,
};
}
log_debug('userQuery', userQuery);
// Create new user
user = await Meteor.users.findOneAsync(userQuery);
}
log_info('User does not exist, creating', username);
// Attempt to find user by e-mail address only
if (!user && email && LDAP.settings_get('LDAP_EMAIL_MATCH_ENABLE') === true) {
log_info('No user exists with username', username, '- attempting to find by e-mail address instead');
if(LDAP.settings_get('LDAP_EMAIL_MATCH_VERIFIED') === true) {
userQuery = {
'emails.0.address': email,
'emails.0.verified' : true
};
} else {
userQuery = {
'emails.0.address' : email
};
if (LDAP.settings_get('LDAP_USERNAME_FIELD') === '') {
username = undefined;
}
log_debug('userQuery', userQuery);
user = await Meteor.users.findOneAsync(userQuery);
}
// Login user if they exist
if (user) {
if (user.authenticationMethod !== 'ldap' && LDAP.settings_get('LDAP_MERGE_EXISTING_USERS') !== true) {
log_info('User exists without "authenticationMethod : ldap"');
throw new Meteor.Error('LDAP-login-error', `LDAP Authentication succeded, but there's already a matching Wekan account in MongoDB`);
if (LDAP.settings_get('LDAP_LOGIN_FALLBACK') !== true) {
loginRequest.ldapPass = undefined;
}
log_info('Logging user');
const result = await addLdapUser(ldapUser, username, loginRequest.ldapPass);
const stampedToken = Accounts._generateStampedLoginToken();
const update_data = {
$push: {
'services.resume.loginTokens': Accounts._hashStampedToken(stampedToken),
},
};
if (result instanceof Error) {
throw result;
}
if (LDAP.settings_get('LDAP_SYNC_ADMIN_STATUS') === true) {
log_debug('Updating admin status');
const targetGroups = LDAP.settings_get('LDAP_SYNC_ADMIN_GROUPS').split(',');
const groups = (await ldap.getUserGroups(username, ldapUser)).filter((value) => targetGroups.includes(value));
user.isAdmin = groups.length > 0;
await Meteor.users.updateAsync({_id: user._id}, {$set: {isAdmin: user.isAdmin}});
result.isAdmin = groups.length > 0;
await Meteor.users.updateAsync({_id: result.userId}, {$set: {isAdmin: result.isAdmin}});
}
if( LDAP.settings_get('LDAP_SYNC_GROUP_ROLES') === true ) {
log_debug('Updating Groups/Roles');
const groups = await ldap.getUserGroups(username, ldapUser);
if( groups.length > 0 ) {
Roles.setUserRoles(user._id, groups );
log_info(`Updated roles to:${ groups.join(',')}`);
Roles.setUserRoles(result.userId, groups );
log_info(`Set roles to:${ groups.join(',')}`);
}
}
// #4737: sync LDAP groups as Organizations/Teams at login too (default off).
await syncUserGroupsToOrgsTeamsSafe(ldap, ldapUser, user._id);
// #4737: sync LDAP groups as Organizations/Teams for the new user (default off).
await syncUserGroupsToOrgsTeamsSafe(ldap, ldapUser, result.userId);
await Meteor.users.updateAsync({ _id: user._id }, update_data);
await syncUserData(user, ldapUser);
if (LDAP.settings_get('LDAP_LOGIN_FALLBACK') === true) {
await Accounts.setPasswordAsync(user._id, loginRequest.ldapPass, {logout: false});
}
return {
userId: user._id,
token: stampedToken.token,
};
}
// Create new user
log_info('User does not exist, creating', username);
if (LDAP.settings_get('LDAP_USERNAME_FIELD') === '') {
username = undefined;
}
if (LDAP.settings_get('LDAP_LOGIN_FALLBACK') !== true) {
loginRequest.ldapPass = undefined;
}
const result = await addLdapUser(ldapUser, username, loginRequest.ldapPass);
if (result instanceof Error) {
throw result;
}
if (LDAP.settings_get('LDAP_SYNC_ADMIN_STATUS') === true) {
log_debug('Updating admin status');
const targetGroups = LDAP.settings_get('LDAP_SYNC_ADMIN_GROUPS').split(',');
const groups = (await ldap.getUserGroups(username, ldapUser)).filter((value) => targetGroups.includes(value));
result.isAdmin = groups.length > 0;
await Meteor.users.updateAsync({_id: result.userId}, {$set: {isAdmin: result.isAdmin}});
}
if( LDAP.settings_get('LDAP_SYNC_GROUP_ROLES') === true ) {
const groups = await ldap.getUserGroups(username, ldapUser);
if( groups.length > 0 ) {
Roles.setUserRoles(result.userId, groups );
log_info(`Set roles to:${ groups.join(',')}`);
}
}
// #4737: sync LDAP groups as Organizations/Teams for the new user (default off).
await syncUserGroupsToOrgsTeamsSafe(ldap, ldapUser, result.userId);
return result;
return result;
});
});
+54 -34
View File
@@ -4,6 +4,7 @@ import limax from 'limax';
import LDAP from './ldap';
import { slugifyPreservingHyphens } from './usernameSlug';
import { parseGroupAllowlist, filterGroupsByAllowlist } from './groupAllowlist';
import { runWithLdapDisconnect } from './connectionGuard';
import { log_debug, log_info, log_warn, log_error } from './logger';
import { getLdapPhotoBuffer } from './ldapPhoto';
@@ -368,56 +369,68 @@ export async function importNewUsers(ldap) {
return;
}
// #6467/#6469: when called standalone (no shared connection passed in),
// importNewUsers owns the connection it opens and must release it. When the
// background sync() passes in its own `ldap`, ownership stays with sync() (it
// disconnects in its finally), so we must NOT close a borrowed connection.
let ownConnection = false;
if (!ldap) {
ldap = new LDAP();
await ldap.connect();
ownConnection = true;
}
let count = 0;
const ldapUsers = await ldap.searchUsers('*');
try {
let count = 0;
const ldapUsers = await ldap.searchUsers('*');
for (const ldapUser of ldapUsers) {
count++;
for (const ldapUser of ldapUsers) {
count++;
const uniqueId = getLdapUserUniqueID(ldapUser);
// Look to see if user already exists
const userQuery = {
'services.ldap.id': uniqueId.value,
};
log_debug('userQuery', userQuery);
let username;
if (LDAP.settings_get('LDAP_USERNAME_FIELD') !== '') {
username = slug(getLdapUsername(ldapUser));
}
// Add user if it was not added before
let user = await Meteor.users.findOneAsync(userQuery);
if (!user && username && LDAP.settings_get('LDAP_MERGE_EXISTING_USERS') === true) {
const uniqueId = getLdapUserUniqueID(ldapUser);
// Look to see if user already exists
const userQuery = {
username,
'services.ldap.id': uniqueId.value,
};
log_debug('userQuery merge', userQuery);
log_debug('userQuery', userQuery);
user = await Meteor.users.findOneAsync(userQuery);
if (user) {
await syncUserData(user, ldapUser);
let username;
if (LDAP.settings_get('LDAP_USERNAME_FIELD') !== '') {
username = slug(getLdapUsername(ldapUser));
}
// Add user if it was not added before
let user = await Meteor.users.findOneAsync(userQuery);
if (!user && username && LDAP.settings_get('LDAP_MERGE_EXISTING_USERS') === true) {
const userQuery = {
username,
};
log_debug('userQuery merge', userQuery);
user = await Meteor.users.findOneAsync(userQuery);
if (user) {
await syncUserData(user, ldapUser);
}
}
if (!user) {
await addLdapUser(ldapUser, username);
}
if (count % 100 === 0) {
log_info('Import running. Users imported until now:', count);
}
}
if (!user) {
await addLdapUser(ldapUser, username);
}
if (count % 100 === 0) {
log_info('Import running. Users imported until now:', count);
log_info('Import finished. Users imported:', count);
} finally {
if (ownConnection) {
await ldap.disconnect();
}
}
log_info('Import finished. Users imported:', count);
}
// #6461: invoke the app-side LDAP org/team sync as a true server-to-server call.
@@ -568,6 +581,13 @@ async function sync() {
} catch (error) {
log_error(error);
return error;
} finally {
// #6467/#6469: the background sync runs on a cron (every minute by default)
// and each run opened a new LDAP connection; without this finally every run
// leaked a connection to the directory server, so a long-running WeKan
// steadily exhausted the LDAP/AD server's connection limit even with nobody
// logging in. Release it on every exit path.
await ldap.disconnect();
}
return true;
}
+19 -9
View File
@@ -1,4 +1,5 @@
import LDAP from './ldap';
import { runWithLdapDisconnect } from './connectionGuard';
Meteor.methods({
async ldap_test_connection() {
@@ -22,18 +23,27 @@ Meteor.methods({
await ldap.connect();
} catch (error) {
console.log(error);
// #6467/#6469: release anything opened before rethrowing, so a failed
// "Test Connection" does not leak a socket to the directory server.
if (ldap) {
await ldap.disconnect();
}
throw new Meteor.Error(error.message);
}
try {
await ldap.bindIfNecessary();
} catch (error) {
throw new Meteor.Error(error.name || error.message);
}
// #6467/#6469: always disconnect the test connection (success or failure),
// otherwise repeated admin "Test Connection" clicks leak connections too.
return await runWithLdapDisconnect(ldap, async () => {
try {
await ldap.bindIfNecessary();
} catch (error) {
throw new Meteor.Error(error.name || error.message);
}
return {
message: 'Connection_success',
params: [],
};
return {
message: 'Connection_success',
params: [],
};
});
},
});
+3 -3
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="utf8" />
<title>WeKan REST API v9.97</title>
<title>WeKan REST API v9.99</title>
<!-- needed for adaptive design -->
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
@@ -423,7 +423,7 @@ data-styled.g139[id="sc-iIvZUO"]{content:"kAmnlO,"}/*!sc*/
55.627 l 55.6165,55.627 -231.245496,231.24803 c -127.185,127.1864
-231.5279,231.248 -231.873,231.248 -0.3451,0 -104.688,
-104.0616 -231.873,-231.248 z
" fill="currentColor"></path></g></svg></div></div><div class="sc-jtyQFi ldXCvc api-content"><div class="sc-eCQgVK jleRXN"><div class="sc-iCECmn lhRBUS"><div class="sc-hKVpXn kktglN api-info"><h1 class="sc-fuztkK sc-ctiVdb eRMVDe cBgvIi">Wekan REST API<!-- --> <span>(<!-- -->v9.97<!-- -->)</span></h1><p>Download OpenAPI specification<!-- -->:</p><div class="sc-iJSMbW sc-cBEgGa fiNpIH ewCFMV"></div><div data-role="redoc-summary" html="" class="sc-iJSMbW sc-cBEgGa fiNpIH ewCFMV"></div><div data-role="redoc-description" html="&lt;p&gt;The REST API allows you to control and extend Wekan with ease.&lt;/p&gt;
" fill="currentColor"></path></g></svg></div></div><div class="sc-jtyQFi ldXCvc api-content"><div class="sc-eCQgVK jleRXN"><div class="sc-iCECmn lhRBUS"><div class="sc-hKVpXn kktglN api-info"><h1 class="sc-fuztkK sc-ctiVdb eRMVDe cBgvIi">Wekan REST API<!-- --> <span>(<!-- -->v9.99<!-- -->)</span></h1><p>Download OpenAPI specification<!-- -->:</p><div class="sc-iJSMbW sc-cBEgGa fiNpIH ewCFMV"></div><div data-role="redoc-summary" html="" class="sc-iJSMbW sc-cBEgGa fiNpIH ewCFMV"></div><div data-role="redoc-description" html="&lt;p&gt;The REST API allows you to control and extend Wekan with ease.&lt;/p&gt;
&lt;p&gt;If you are an end-user and not a dev or a tester, &lt;a href=&quot;https://github.com/wekan/wekan/issues/new&quot;&gt;create an issue&lt;/a&gt; to request new APIs.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;All API calls in the documentation are made using &lt;code&gt;curl&lt;/code&gt;. However, you are free to use Java / Python / PHP / Golang / Ruby / Swift / Objective-C / Rust / Scala / C# or any other programming languages.&lt;/p&gt;
@@ -2003,7 +2003,7 @@ comment-only / worker members are rejected).</p>
" class="sc-iJSMbW sc-cBEgGa sc-ciCrSJ fiNpIH dNfUH dDDioG"><p>Attachment not found</p>
</div></button></div></div></div><div class="sc-jSppWd sc-gKkgUA fpMlmc bdQQyo"><div class="sc-fXwuWv fYxpnv"><button class="sc-jWMFtl jzaJhV"><span type="delete" class="sc-eEFuoE iINwyG http-verb delete">delete</span><span class="sc-FpjRO dsIRaZ">/api/attachment/delete/{attachmentId}</span><svg class="sc-dYjPD bQKUih" style="margin-right:-25px" version="1.1" viewBox="0 0 24 24" x="0" xmlns="http://www.w3.org/2000/svg" y="0" aria-hidden="true"><polygon points="17.3 8.3 12 13.6 6.7 8.3 5.3 9.7 12 16.4 18.7 9.7 "></polygon></svg></button><div aria-hidden="true" class="sc-fmtEmb tUxhh"><div class="sc-ljIcGq bAnPPh"><div html="" class="sc-iJSMbW sc-cBEgGa fiNpIH bAoMjv"></div><div tabindex="0" role="button"><div class="sc-jlJOIR PEJyb"><span></span>/api/attachment/delete/{attachmentId}</div></div></div></div></div></div></div></div></div><div class="sc-ekVkVN bjzTxL"></div></div></div>
<script>
const __redoc_state = {"menu":{"activeItemIdx":-1},"spec":{"data":{"openapi":"3.0.0","info":{"title":"Wekan REST API","version":"v9.97","description":"The REST API allows you to control and extend Wekan with ease.\n\nIf you are an end-user and not a dev or a tester, [create an issue](https://github.com/wekan/wekan/issues/new) to request new APIs.\n\n> All API calls in the documentation are made using `curl`. However, you are free to use Java / Python / PHP / Golang / Ruby / Swift / Objective-C / Rust / Scala / C# or any other programming languages.\n\n# Production Security Concerns\nWhen calling a production Wekan server, ensure it is running via HTTPS and has a valid SSL Certificate. The login method requires you to post your username and password in plaintext, which is why we highly suggest only calling the REST login api over HTTPS. Also, few things to note:\n\n* Only call via HTTPS\n* Implement a timed authorization token expiration strategy\n* Ensure the calling user only has permissions for what they are calling and no more\n"},"paths":{"/users/login":{"post":{"operationId":"login","summary":"Login with REST API","tags":["Login"],"requestBody":{"content":{"application/json":{"schema":{"type":"object","required":["username","password"],"properties":{"username":{"description":"Your username\n","type":"string"},"password":{"description":"Your password\n","type":"string","format":"password"}}}},"application/x-www-form-urlencoded":{"schema":{"type":"object","required":["username","password"],"properties":{"username":{"description":"Your username\n","type":"string"},"password":{"description":"Your password\n","type":"string","format":"password"}}}}},"description":"Login credentials","required":true},"responses":{"200":{"description":"Successful authentication","content":{"*/*":{"schema":{"type":"object","required":["id","token","tokenExpires"],"properties":{"id":{"type":"string","description":"User ID"},"token":{"type":"string","description":"Authentication token\n"},"tokenExpires":{"type":"string","format":"date-time","description":"Token expiration date\n"}}}}}},"400":{"description":"Error in authentication\n","content":{"*/*":{"schema":{"type":"object","properties":{"error":{"type":"string"},"reason":{"type":"string"}}}}}},"default":{"description":"Error in authentication\n"}}}},"/users/register":{"post":{"operationId":"register","summary":"Register with REST API","description":"Notes:\n - You will need to provide the token for any of the authenticated methods.\n","tags":["Login"],"requestBody":{"content":{"application/x-www-form-urlencoded":{"schema":{"type":"object","properties":{"username":{"description":"Your username\n","type":"string"},"password":{"description":"Your password\n","type":"string","format":"password"},"email":{"description":"Your email\n","type":"string"}},"required":["username","password","email"]}}},"required":true},"responses":{"200":{"description":"Successful registration","content":{"*/*":{"schema":{"items":{"properties":{"id":{"type":"string"},"token":{"type":"string"},"tokenExpires":{"type":"string"}}}}}}},"400":{"description":"Error in registration\n","content":{"*/*":{"schema":{"items":{"properties":{"error":{"type":"number"},"reason":{"type":"string"}}}}}}},"default":{"description":"Error in registration\n"}}}},"/api/admin/attachment-settings":{"get":{"operationId":"get_attachment_settings","summary":"Get the attachment storage / upload-block settings (GlobalAdmin)","description":"Only the global admin can call this. Returns the Admin Panel >\n Attachments settings document, including `limitSettings.attachmentsUploadBlocked`\n and `limitSettings.avatarsUploadBlocked` plus the size limits. Cloud-storage\n secret keys (S3 secretAccessKey, Azure accountKey/connectionString, GCS\n credentials) are masked and never returned; a boolean `<field>Set` marker\n shows whether a value exists.\n","tags":["AttachmentStorageSettings","Settings"],"security":[{"UserSecurity":[]}],"responses":{"200":{"description":"200 response","content":{"application/json":{"schema":{"type":"Object"}}}}}},"put":{"operationId":"update_attachment_settings","summary":"Update the attachment storage / upload-block settings (GlobalAdmin)","description":"Only the global admin can call this. The request body is a\n partial settings object (for example\n `{\"limitSettings\": {\"avatarsUploadBlocked\": true}}`). It is applied through\n the same update path as the `updateAttachmentStorageSettings` Meteor method,\n so validation and `$set` semantics are identical: cloud-storage secrets left\n blank are preserved. The masked, updated settings document is returned.\n","tags":["AttachmentStorageSettings","Settings"],"security":[{"UserSecurity":[]}],"responses":{"200":{"description":"200 response","content":{"application/json":{"schema":{"type":"Object"}}}}}}},"/api/admin/domains":{"get":{"operationId":"get_admin_domains","summary":"List email domains with their user counts (GlobalAdmin)","description":"Only the gLine truncated
const __redoc_state = {"menu":{"activeItemIdx":-1},"spec":{"data":{"openapi":"3.0.0","info":{"title":"Wekan REST API","version":"v9.99","description":"The REST API allows you to control and extend Wekan with ease.\n\nIf you are an end-user and not a dev or a tester, [create an issue](https://github.com/wekan/wekan/issues/new) to request new APIs.\n\n> All API calls in the documentation are made using `curl`. However, you are free to use Java / Python / PHP / Golang / Ruby / Swift / Objective-C / Rust / Scala / C# or any other programming languages.\n\n# Production Security Concerns\nWhen calling a production Wekan server, ensure it is running via HTTPS and has a valid SSL Certificate. The login method requires you to post your username and password in plaintext, which is why we highly suggest only calling the REST login api over HTTPS. Also, few things to note:\n\n* Only call via HTTPS\n* Implement a timed authorization token expiration strategy\n* Ensure the calling user only has permissions for what they are calling and no more\n"},"paths":{"/users/login":{"post":{"operationId":"login","summary":"Login with REST API","tags":["Login"],"requestBody":{"content":{"application/json":{"schema":{"type":"object","required":["username","password"],"properties":{"username":{"description":"Your username\n","type":"string"},"password":{"description":"Your password\n","type":"string","format":"password"}}}},"application/x-www-form-urlencoded":{"schema":{"type":"object","required":["username","password"],"properties":{"username":{"description":"Your username\n","type":"string"},"password":{"description":"Your password\n","type":"string","format":"password"}}}}},"description":"Login credentials","required":true},"responses":{"200":{"description":"Successful authentication","content":{"*/*":{"schema":{"type":"object","required":["id","token","tokenExpires"],"properties":{"id":{"type":"string","description":"User ID"},"token":{"type":"string","description":"Authentication token\n"},"tokenExpires":{"type":"string","format":"date-time","description":"Token expiration date\n"}}}}}},"400":{"description":"Error in authentication\n","content":{"*/*":{"schema":{"type":"object","properties":{"error":{"type":"string"},"reason":{"type":"string"}}}}}},"default":{"description":"Error in authentication\n"}}}},"/users/register":{"post":{"operationId":"register","summary":"Register with REST API","description":"Notes:\n - You will need to provide the token for any of the authenticated methods.\n","tags":["Login"],"requestBody":{"content":{"application/x-www-form-urlencoded":{"schema":{"type":"object","properties":{"username":{"description":"Your username\n","type":"string"},"password":{"description":"Your password\n","type":"string","format":"password"},"email":{"description":"Your email\n","type":"string"}},"required":["username","password","email"]}}},"required":true},"responses":{"200":{"description":"Successful registration","content":{"*/*":{"schema":{"items":{"properties":{"id":{"type":"string"},"token":{"type":"string"},"tokenExpires":{"type":"string"}}}}}}},"400":{"description":"Error in registration\n","content":{"*/*":{"schema":{"items":{"properties":{"error":{"type":"number"},"reason":{"type":"string"}}}}}}},"default":{"description":"Error in registration\n"}}}},"/api/admin/attachment-settings":{"get":{"operationId":"get_attachment_settings","summary":"Get the attachment storage / upload-block settings (GlobalAdmin)","description":"Only the global admin can call this. Returns the Admin Panel >\n Attachments settings document, including `limitSettings.attachmentsUploadBlocked`\n and `limitSettings.avatarsUploadBlocked` plus the size limits. Cloud-storage\n secret keys (S3 secretAccessKey, Azure accountKey/connectionString, GCS\n credentials) are masked and never returned; a boolean `<field>Set` marker\n shows whether a value exists.\n","tags":["AttachmentStorageSettings","Settings"],"security":[{"UserSecurity":[]}],"responses":{"200":{"description":"200 response","content":{"application/json":{"schema":{"type":"Object"}}}}}},"put":{"operationId":"update_attachment_settings","summary":"Update the attachment storage / upload-block settings (GlobalAdmin)","description":"Only the global admin can call this. The request body is a\n partial settings object (for example\n `{\"limitSettings\": {\"avatarsUploadBlocked\": true}}`). It is applied through\n the same update path as the `updateAttachmentStorageSettings` Meteor method,\n so validation and `$set` semantics are identical: cloud-storage secrets left\n blank are preserved. The masked, updated settings document is returned.\n","tags":["AttachmentStorageSettings","Settings"],"security":[{"UserSecurity":[]}],"responses":{"200":{"description":"200 response","content":{"application/json":{"schema":{"type":"Object"}}}}}}},"/api/admin/domains":{"get":{"operationId":"get_admin_domains","summary":"List email domains with their user counts (GlobalAdmin)","description":"Only the gLine truncated
var container = document.getElementById('redoc');
Redoc.hydrate(__redoc_state, container);
+1 -1
View File
@@ -1,7 +1,7 @@
swagger: '2.0'
info:
title: Wekan REST API
version: v9.97
version: v9.99
description: |
The REST API allows you to control and extend Wekan with ease.
+23 -3
View File
@@ -8,11 +8,31 @@
# back to English on every pull.
../tx --config .tx/config pull -a -f
# After pulling, list the language files where a previously-translated string reverted
# to English (untranslated on Transifex), so you can (re)translate them on Transifex or
# revert the file instead of committing the regression. Read-only; needs node + git.
# After pulling, find the language files where a previously-translated string
# reverted to English (untranslated on Transifex). Needs node + git.
if command -v node >/dev/null 2>&1; then
# Human-readable report for the log (also flags files that are NOT auto-healed
# because they carry other, non-revert changes from Transifex).
node releases/translations/report-english-regressions.mjs || true
# Auto-heal, but ONLY files whose changes are ENTIRELY reverts (the --files
# mode already excludes any file that also has a real new/updated translation
# from Transifex, which a whole-file checkout would throw away). For each such
# file: restore the committed translations with `git checkout --`, then force
# push them back to Transifex (force, or the push is skipped by the timestamp
# guard) so they stop reverting on future pulls.
reverted=$(node releases/translations/report-english-regressions.mjs --files 2>/dev/null)
if [ -n "$reverted" ]; then
echo "$reverted" | while IFS= read -r f; do
[ -n "$f" ] || continue
lang=$(basename "$f" .i18n.json)
echo "[i18n] auto-heal: git checkout HEAD -- $f && push $lang back to Transifex"
# Restore from HEAD explicitly (the version the detector compared against),
# not the index, so a staged change can't restore the wrong content.
git checkout HEAD -- "$f"
../tx push -t -l "$lang" -f
done
fi
else
echo "[i18n] node not found - skipping the English-regression report."
fi
+41
View File
@@ -0,0 +1,41 @@
#!/bin/sh
# Populate British English (en-GB) on Transifex with the English source verbatim.
#
# British English differs from the en source for only a handful of strings; the
# rest are legitimately identical. This copies imports/i18n/data/en.i18n.json to
# imports/i18n/data/en-GB.i18n.json (same content) and force-pushes it as the
# en_GB TRANSLATION, so every en-GB string shows as translated on Transifex
# (100%) instead of coming back as the English source on the next `tx pull`.
#
# lang_map in .tx/config maps `en_GB: en-GB`, so the Transifex language en_GB is
# stored locally as imports/i18n/data/en-GB.i18n.json. `-l en_GB` selects it (the
# Go CLI matches either the remote or the local code in getFilesToPush).
#
# -f is REQUIRED: without it the Go tx CLI skips the language whenever the local
# file is not newer than the server copy (internal/txlib/push.go shouldSkipPush),
# so the push would silently upload nothing.
#
# Needs the sibling ../tx binary and a valid Transifex token, same as the other
# scripts here.
set -e
# Move to the repo root regardless of where this is invoked from, so ../tx and
# the imports/ paths resolve the same way as the other translation scripts.
cd "$(dirname "$0")/../.."
en="imports/i18n/data/en.i18n.json"
engb="imports/i18n/data/en-GB.i18n.json"
if [ ! -f "$en" ]; then
echo "[i18n] $en not found - are you in the wekan repo?"
exit 1
fi
# en-GB is a verbatim copy of the English source.
cp "$en" "$engb"
echo "[i18n] copied $en -> $engb"
# Force-push the en-GB translation so the identical-to-source strings are stored
# as real translations (100% translated) and survive future pulls.
../tx push -t -l en_GB -f
+9 -1
View File
@@ -1 +1,9 @@
../tx push -t -l $1
#!/bin/sh
# Push one language's translations to Transifex. Usage: push-translation.sh ja
#
# -f is REQUIRED: without it the Go tx CLI skips the language whenever the local
# file is not newer than the server copy (internal/txlib/push.go shouldSkipPush),
# so a freshly reverted file silently uploads nothing and the next `tx pull`
# reverts it back to English again. Run from the repo root; needs ../tx and auth.
[ -z "$1" ] && { echo "usage: $0 <lang>"; exit 1; }
../tx push -t -l "$1" -f
@@ -21,6 +21,12 @@ import path from 'node:path';
const DATA_DIR = 'imports/i18n/data';
const EN_FILE = path.join(DATA_DIR, 'en.i18n.json');
// --files: machine-readable mode for the pull script's auto-heal loop. Print
// ONLY the reverted language-file paths (one per line) to stdout, nothing else,
// so the shell can `git checkout --` and re-push each. Exit 2 when there are
// reverts (0 otherwise), like the human report.
const filesMode = process.argv.slice(2).includes('--files');
function parse(text) { try { return JSON.parse(text); } catch { return null; } }
function readFile(p) { try { return parse(fs.readFileSync(p, 'utf8')); } catch { return null; } }
function gitShow(ref, p) {
@@ -39,7 +45,7 @@ try {
} catch { /* not a git repo / no git → nothing to compare */ }
if (!changed.length) {
console.log('[i18n] No changed language files to check.');
if (!filesMode) console.log('[i18n] No changed language files to check.');
process.exit(0);
}
@@ -49,35 +55,72 @@ for (const f of changed) {
const oldJson = before != null ? (parse(before) || {}) : {};
const newJson = readFile(f);
if (!newJson) continue;
const regressed = [];
for (const key of Object.keys(newJson)) {
let otherChanges = 0;
// Walk every key touched by the pull (union of old and new).
for (const key of new Set([...Object.keys(oldJson), ...Object.keys(newJson)])) {
const enV = en[key];
if (typeof enV !== 'string') continue;
const oldV = oldJson[key];
const newV = newJson[key];
// Previously a real translation (differed from English), now exactly the English source.
if (typeof oldV === 'string' && typeof newV === 'string' && oldV !== enV && newV === enV) {
if (oldV === newV) continue; // unchanged by the pull
// Revert-to-English: previously a real translation (differed from English),
// now exactly the English source.
if (
typeof enV === 'string' && typeof oldV === 'string' && typeof newV === 'string' &&
oldV !== enV && newV === enV
) {
regressed.push(key);
continue;
}
// Any OTHER change where the pull brought a real (non-English) value: a new
// or improved translation from Transifex that a whole-file `git checkout`
// would throw away. Its presence means this file is NOT purely a revert.
if (typeof newV === 'string' && newV !== enV) {
otherChanges += 1;
}
}
if (regressed.length) report.push({ file: f, keys: regressed });
if (regressed.length) report.push({ file: f, keys: regressed, otherChanges });
}
if (!report.length) {
console.log('[i18n] OK: no translations reverted to English after the pull.');
if (!filesMode) console.log('[i18n] OK: no translations reverted to English after the pull.');
process.exit(0);
}
report.sort((a, b) => b.keys.length - a.keys.length);
// A file is safe to auto-heal (whole-file `git checkout --` + force re-push) ONLY
// when ALL of the pull's changes to it are reverts — i.e. it carries no other
// real translation from Transifex that the checkout would discard.
const healable = report.filter((r) => r.otherChanges === 0);
if (filesMode) {
// Machine-readable: only the safe-to-auto-heal files, one path per line.
for (const r of healable) console.log(r.file);
process.exit(report.length ? 2 : 0);
}
console.log('\n[i18n] WARNING — these language files had translations REVERT to English after the pull');
console.log(' (a previously-translated string is now exactly the English source, i.e. it is');
console.log(' untranslated on Transifex). (Re)translate them on Transifex, or revert the file:\n');
console.log(' untranslated on Transifex):\n');
let total = 0;
for (const r of report) {
total += r.keys.length;
const sample = r.keys.slice(0, 8).join(', ');
console.log(` ${r.file} — ${r.keys.length} string(s): ${sample}${r.keys.length > 8 ? ', …' : ''}`);
const mixed = r.otherChanges > 0
? ` [NOT auto-healed: also has ${r.otherChanges} non-revert change(s) — handle manually]`
: '';
console.log(` ${r.file} — ${r.keys.length} string(s): ${sample}${r.keys.length > 8 ? ', …' : ''}${mixed}`);
}
console.log(`\n[i18n] ${report.length} language file(s), ${total} string(s) reverted to English.`);
console.log(
`[i18n] ${healable.length} file(s) are safe to auto-restore (all changes are reverts); ` +
`${report.length - healable.length} also have other changes and are left for manual handling.`,
);
// Non-zero exit so the pull script / CI can notice, but do not treat it as a hard failure.
process.exit(2);
+2 -2
View File
@@ -22,10 +22,10 @@ const pkgdef :Spk.PackageDefinition = (
appTitle = (defaultText = "Wekan"),
# The name of the app as it is displayed to the user.
appVersion = 997,
appVersion = 999,
# Increment this for every release.
appMarketingVersion = (defaultText = "9.97~2026-07-17"),
appMarketingVersion = (defaultText = "9.99~2026-07-17"),
# Human-readable presentation of the app version.
minUpgradableAppVersion = 0,
+11 -2
View File
@@ -69,9 +69,18 @@ const MARKER_ID = 'schema-upgrade';
// so upgraded documents look exactly like app-created ones.
const ID_CHARS = '23456789ABCDEFGHJKLMNPQRSTWXYZabcdefghijkmnopqrstuvwxyz';
function randomId(len = 17) {
const bytes = crypto.randomBytes(len);
// Rejection sampling: 256 is not a multiple of ID_CHARS.length (55), so a
// plain `byte % 55` skews toward the first characters (CodeQL
// js/biased-cryptographic-random). Accept only bytes below the largest
// multiple of the alphabet size and resample the rest.
const limit = 256 - (256 % ID_CHARS.length);
let out = '';
for (let i = 0; i < len; i++) out += ID_CHARS[bytes[i] % ID_CHARS.length];
while (out.length < len) {
const bytes = crypto.randomBytes(len - out.length);
for (let i = 0; i < bytes.length && out.length < len; i++) {
if (bytes[i] < limit) out += ID_CHARS[bytes[i] % ID_CHARS.length];
}
}
return out;
}
+41
View File
@@ -132,9 +132,50 @@ if [ "ferretdb" = "${DATABASE}" ]; then
# wait here forever.
snapctl start --enable "${WEKAN_SNAP_SVC}.ferretdb" 2>/dev/null || true
echo "Waiting for FerretDB to accept connections on ${MONGO_HOST}:${MONGO_PORT}..."
# #6476: after WEKAN_DB_WAIT_TIMEOUT seconds (default 120) print diagnostics
# once, then keep retrying. Without this the loop was silent and infinite:
# WeKan does NOT open its web port until FerretDB answers, so if FerretDB
# never starts listening (a crashed/incompatible per-arch binary, a locked or
# corrupt SQLite file, a failed migration hand-off) WeKan blocks HERE forever
# and the only symptom is "port not open, no clue why". Mirrors the MongoDB
# branch below.
WEKAN_DB_WAIT_TIMEOUT=${WEKAN_DB_WAIT_TIMEOUT:-120}
db_waited=0
db_hint_shown=false
until "$DB_EVAL" ping "$DB_URL" >/dev/null 2>&1; do
if [ "$db_hint_shown" != "true" ] && [ "$db_waited" -ge "$WEKAN_DB_WAIT_TIMEOUT" ]; then
echo ""
echo "========================================================================"
echo "WeKan: FerretDB still not accepting connections on ${MONGO_HOST}:${MONGO_PORT}."
echo ""
echo "WeKan will NOT open its web port until FerretDB answers, so it is"
echo "waiting here. The FerretDB service can show as 'active' yet have"
echo "crashed or never started listening."
echo ""
echo "Check the FerretDB log for the real reason:"
echo " snap logs ${WEKAN_SNAP_SVC}.ferretdb"
echo ""
echo "Common causes after a snap update:"
echo " - the bundled FerretDB binary cannot run on this CPU/architecture"
echo " ('exec format error' in the log) - report your 'snap version' and"
echo " CPU architecture to WeKan."
echo " - a locked or corrupt SQLite database under:"
echo " $FERRETDB_SQLITE_DIR"
echo " - FerretDB left disabled/stopped (a failed migration hand-off):"
echo " snap start --enable ${WEKAN_SNAP_SVC}.ferretdb"
echo ""
echo "To keep working on MongoDB instead while this is sorted out:"
echo " snap set ${SNAP_NAME:-wekan} database=mongodb"
echo ""
echo "Attachments and avatars are stored ON DISK under WRITABLE_PATH, NOT"
echo "in the database ($SNAP_COMMON/files), so they are safe."
echo "========================================================================"
echo ""
db_hint_shown=true
fi
echo "FerretDB not ready yet, retrying in 5 seconds..."
sleep 5
db_waited=$((db_waited + 5))
done
echo "FerretDB is ready."
# #6473: SELF-HEALING attachments. Older MongoDB -> FerretDB migrations silently
+4 -4
View File
@@ -15,7 +15,7 @@
# base: core24 (not core26) · no build-base: devel · grade: stable
# ─────────────────────────────────────────────────────────────────────────────
name: wekan
version: '9.97'
version: '9.99'
base: core24
confinement: strict
grade: stable
@@ -257,13 +257,13 @@ parts:
riscv64) WEKAN_ARCH="riscv64" ;;
*) echo "Unsupported architecture: ${CRAFT_ARCH_BUILD_FOR}"; exit 1 ;;
esac
WEKAN_ZIP="wekan-9.97-${WEKAN_ARCH}.zip"
WEKAN_ZIP="wekan-9.99-${WEKAN_ARCH}.zip"
echo "Cleaning environment first"
rm -rf .build
mkdir .build
cd .build
#wget https://github.com/wekan/wekan/releases/download/v9.97/wekan-9.97-amd64.zip
wget "https://github.com/wekan/wekan/releases/download/v9.97/${WEKAN_ZIP}"
#wget https://github.com/wekan/wekan/releases/download/v9.99/wekan-9.99-amd64.zip
wget "https://github.com/wekan/wekan/releases/download/v9.99/${WEKAN_ZIP}"
unzip "${WEKAN_ZIP}"
rm "${WEKAN_ZIP}"
# Fix: bcrypt 5.0.1 (Meteor accounts-password) ships node-addon-api@3
+84
View File
@@ -0,0 +1,84 @@
'use strict';
// Regression guard (no Meteor) for wekan/wekan#6476: "9.98: Wekan starts but
// doesn't open a listening port". With database=ferretdb, snap wekan-control
// blocks BEFORE starting node until FerretDB accepts connections. That wait loop
// used to have no timeout and no diagnostics, so when FerretDB never started
// listening (crashed/incompatible per-arch binary, locked/corrupt SQLite, failed
// migration hand-off) WeKan blocked there forever, its web port never opened,
// and the only symptom was a silent "FerretDB not ready yet" repeating.
//
// The FerretDB branch must now mirror the MongoDB branch: a WEKAN_DB_WAIT_TIMEOUT
// after which it prints an actionable hint once, then keeps retrying.
//
// Run: node tests/ferretdbWaitTimeout.test.cjs
const assert = require('assert');
const fs = require('fs');
const path = require('path');
const repoRoot = path.resolve(__dirname, '..');
const control = fs.readFileSync(path.join(repoRoot, 'snap-src/bin/wekan-control'), 'utf8');
// Slice out the FerretDB branch only (up to the MongoDB `elif`), so we assert on
// the branch that actually regressed and not the already-correct MongoDB one.
const start = control.indexOf('if [ "ferretdb" = "${DATABASE}" ]; then');
const elif = control.indexOf('elif [ "true" != "${DISABLE_MONGODB}" ]; then', start);
assert.ok(start !== -1 && elif !== -1 && elif > start, 'could not locate the FerretDB branch');
const ferretBranch = control.slice(start, elif);
let passed = 0;
function test(name, fn) {
fn();
passed += 1;
console.log(' ok -', name);
}
test('the FerretDB wait loop has a bounded, overridable timeout', () => {
assert.ok(/WEKAN_DB_WAIT_TIMEOUT=\$\{WEKAN_DB_WAIT_TIMEOUT:-120\}/.test(ferretBranch),
'WEKAN_DB_WAIT_TIMEOUT (default 120, user-overridable) must gate the hint');
assert.ok(/db_waited=\$\(\(db_waited \+ 5\)\)/.test(ferretBranch),
'elapsed wait time must be tracked so the timeout can fire');
assert.ok(/db_waited" -ge "\$WEKAN_DB_WAIT_TIMEOUT/.test(ferretBranch),
'the hint must trigger once the timeout is reached');
});
test('the hint is printed at most once', () => {
assert.ok(/db_hint_shown=false/.test(ferretBranch));
assert.ok(/db_hint_shown" != "true"/.test(ferretBranch));
assert.ok(/db_hint_shown=true/.test(ferretBranch));
});
test('the hint points to the FerretDB service log and the SQLite dir', () => {
assert.ok(/snap logs \$\{WEKAN_SNAP_SVC\}\.ferretdb/.test(ferretBranch),
'must tell the admin where the real error is');
assert.ok(ferretBranch.includes('$FERRETDB_SQLITE_DIR'),
'must name the SQLite database directory to check for locks/corruption');
assert.ok(/database=mongodb/.test(ferretBranch),
'must offer the fallback to keep working on MongoDB');
});
test('it still keeps retrying after the hint (does not give up / crash)', () => {
assert.ok(/until "\$DB_EVAL" ping "\$DB_URL"/.test(ferretBranch),
'must keep polling FerretDB readiness');
assert.ok(/FerretDB not ready yet, retrying in 5 seconds/.test(ferretBranch));
assert.ok(/\n\s*sleep 5\n/.test(ferretBranch));
assert.ok(/echo "FerretDB is ready\."/.test(ferretBranch),
'must proceed to start WeKan once FerretDB answers');
});
// Negative guard against the exact regression: the FerretDB branch must NOT be a
// bare infinite loop again (an `until ping` whose body is only echo + sleep, with
// no timeout bookkeeping).
test('NEGATIVE: the FerretDB wait is not a silent infinite loop', () => {
const loopBody = ferretBranch.slice(
ferretBranch.indexOf('until "$DB_EVAL" ping'),
ferretBranch.indexOf('echo "FerretDB is ready."'),
);
assert.ok(loopBody.includes('WEKAN_DB_WAIT_TIMEOUT'),
'the wait loop body must reference the timeout — no unbounded silent wait');
assert.ok(loopBody.includes('db_waited'),
'the wait loop must advance an elapsed-time counter');
});
console.log(`\nAll ${passed} FerretDB wait-timeout tests passed`);
+181
View File
@@ -0,0 +1,181 @@
'use strict';
// Regression tests for the LDAP connection leak (WeKan #6467 / #6469).
//
// Symptom reported by operators after an update: WeKan "dies really quickly"
// and "kills our openldap server with too many open connections", plus slow /
// failing logins ("Must be logged in"). Root cause: every LDAP login attempt
// (loginHandler.js) and every background sync run (sync.js) created a fresh
// `new LDAP()` + `connect()` but NEVER called `disconnect()`, so each attempt
// leaked a socket to the directory server until its connection limit was hit.
//
// These tests cover both the behavioural contract of the shared guard
// (runWithLdapDisconnect) and source-level guards that the leak-prone call
// sites actually route through it, including negative cases.
//
// Run: node tests/ldapConnectionRelease.test.cjs
const assert = require('assert');
const fs = require('fs');
const path = require('path');
const { runWithLdapDisconnect } = require('../packages/wekan-ldap/server/connectionGuard');
const repoRoot = path.resolve(__dirname, '..');
const read = rel => fs.readFileSync(path.join(repoRoot, rel), 'utf8');
let passed = 0;
const failures = [];
const pending = [];
function test(name, fn) {
pending.push(
Promise.resolve()
.then(fn)
.then(() => {
passed += 1;
console.log(' ok -', name);
})
.catch((err) => {
failures.push({ name, err });
console.log(' FAIL -', name, '-', err && err.message);
}),
);
}
// ---------------------------------------------------------------------------
// Behavioural contract of runWithLdapDisconnect
// ---------------------------------------------------------------------------
test('disconnects AFTER successful work and returns the work result', async () => {
const events = [];
const ldap = { disconnect: async () => { events.push('disconnect'); } };
const result = await runWithLdapDisconnect(ldap, async () => {
events.push('work');
return 42;
});
assert.strictEqual(result, 42);
// ordering matters: the connection is released only after the work is done
assert.deepStrictEqual(events, ['work', 'disconnect']);
});
test('disconnects exactly once', async () => {
let count = 0;
const ldap = { disconnect: async () => { count += 1; } };
await runWithLdapDisconnect(ldap, async () => {});
assert.strictEqual(count, 1);
});
// Negative case: work throws. The connection must STILL be released, and the
// original error must propagate unchanged (this is the leak-on-failed-login
// path that exhausted OpenLDAP).
test('disconnects even when the work throws, and rethrows the original error', async () => {
let disconnected = false;
const ldap = { disconnect: async () => { disconnected = true; } };
await assert.rejects(
() => runWithLdapDisconnect(ldap, async () => { throw new Error('login boom'); }),
/login boom/,
);
assert.strictEqual(disconnected, true);
});
// Negative case: a failing disconnect must never turn a successful login into
// a failure.
test('a failing disconnect does not turn a success into a failure', async () => {
const ldap = { disconnect: async () => { throw new Error('unbind failed'); } };
const result = await runWithLdapDisconnect(ldap, async () => 'ok');
assert.strictEqual(result, 'ok');
});
// Negative case: a failing disconnect must not hide the real work error.
test('a failing disconnect does not mask the original work error', async () => {
const ldap = { disconnect: async () => { throw new Error('unbind failed'); } };
await assert.rejects(
() => runWithLdapDisconnect(ldap, async () => { throw new Error('original'); }),
/original/,
);
});
// Negative case: tolerate an ldap object that never reached connect() (so it
// has no usable client / disconnect), and a null ldap. Cleanup must be a
// safe no-op.
test('tolerates an ldap without a disconnect method and a null ldap', async () => {
assert.strictEqual(await runWithLdapDisconnect({}, async () => 'v'), 'v');
assert.strictEqual(await runWithLdapDisconnect(null, async () => 'v2'), 'v2');
});
// ---------------------------------------------------------------------------
// Source-level guards: the leak-prone call sites route through the guard
// ---------------------------------------------------------------------------
const loginHandler = read('packages/wekan-ldap/server/loginHandler.js');
const sync = read('packages/wekan-ldap/server/sync.js');
const ldapClass = read('packages/wekan-ldap/server/ldap.js');
const testConnection = read('packages/wekan-ldap/server/testConnection.js');
const guard = read('packages/wekan-ldap/server/connectionGuard.js');
test('the shared guard releases the connection in a finally', () => {
assert.ok(/finally\s*{[\s\S]*ldap\.disconnect\(\)/.test(guard),
'runWithLdapDisconnect must call disconnect in a finally');
});
test('loginHandler runs the whole login flow through runWithLdapDisconnect', () => {
assert.ok(loginHandler.includes("from './connectionGuard'"),
'loginHandler must import the connection guard');
const wrapIdx = loginHandler.indexOf('runWithLdapDisconnect(ldap');
const connectIdx = loginHandler.indexOf('ldap.connect()');
assert.ok(wrapIdx !== -1, 'loginHandler must wrap the flow in runWithLdapDisconnect');
assert.ok(connectIdx !== -1, 'loginHandler must still connect');
// Negative guard against the regression: connect() must live INSIDE the
// guarded region, otherwise the connection could leak again.
assert.ok(connectIdx > wrapIdx,
'ldap.connect() must run inside runWithLdapDisconnect so it is always released');
});
test('background sync() disconnects in a finally on every exit path', () => {
assert.ok(/finally\s*{[\s\S]*ldap\.disconnect\(\)/.test(sync),
'sync() must release its LDAP connection in a finally');
});
test('importNewUsers disconnects ONLY the connection it owns', () => {
assert.ok(sync.includes('ownConnection'),
'importNewUsers must track whether it opened the connection itself');
assert.ok(/if \(ownConnection\)\s*{\s*await ldap\.disconnect\(\)/.test(sync),
'a borrowed connection (passed in by sync()) must not be closed here');
});
test('ldap.disconnect() is a safe no-op when connect() was never reached', () => {
assert.ok(/if \(!this\.client\)\s*{\s*return;/.test(ldapClass),
'disconnect() must guard against a missing client');
});
test('ldap_test_connection releases its connection too', () => {
assert.ok(testConnection.includes("from './connectionGuard'"),
'the admin "Test Connection" method must import the guard');
assert.ok(testConnection.includes('runWithLdapDisconnect(ldap'),
'the bind/return path must run through the guard');
// Negative case: a failed connect() must still disconnect before rethrowing.
assert.ok(/if \(ldap\)\s*{\s*await ldap\.disconnect\(\)/.test(testConnection),
'a failed connect() must release anything opened before rethrowing');
});
// ---------------------------------------------------------------------------
Promise.all(pending).then(() => {
if (failures.length) {
console.error(`\n${failures.length} test(s) failed`);
process.exit(1);
}
console.log(`\nAll ${passed} LDAP connection-release tests passed`);
});
+3 -1
View File
@@ -392,7 +392,9 @@ test('cardDetails.css: desktop-mode maximized override exists with !important vi
assert.ok(m, 'desktop-mode maximized rule exists');
for (const decl of ['position: fixed', 'top: 8px', 'inset-inline-start: 8px', 'inset-inline-end: 8px', 'bottom: 8px']) {
assert.ok(
new RegExp(`${decl.replace(/[()]/g, '\\$&')}\\s*!important`).test(m[1]),
// Escape ALL regex metacharacters (incl. backslash), not just parens —
// flagged by CodeQL js/incomplete-sanitization.
new RegExp(`${decl.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*!important`).test(m[1]),
`${decl} !important present`,
);
}
+10
View File
@@ -181,6 +181,16 @@ await test('randomId produces Meteor-style 17-char ids', () => {
assert.strictEqual(id.length, 17);
assert.ok(/^[23456789A-HJKLMNP-Zabcdefghijkmnop-z]+$/.test(id));
assert.notStrictEqual(randomId(), randomId());
// custom lengths still exact despite rejection resampling
assert.strictEqual(randomId(64).length, 64);
});
await test('negative: randomId uses rejection sampling, not biased modulo (CodeQL js/biased-cryptographic-random)', () => {
const src = read('server/lib/schemaUpgradeSteps.js');
const fn = src.match(/function randomId\([\s\S]*?\n\}/)[0];
assert.ok(fn.includes('256 % ID_CHARS.length'),
'bytes at/above the largest alphabet multiple must be rejected, or ids skew toward the first characters');
assert.ok(/if \(bytes\[i\] < limit\)/.test(fn), 'out-of-range bytes are resampled, never wrapped');
});
// ── swimlane-structure ────────────────────────────────────────────────────────