Reject LDAP empty credentials, enforce directory groups and prevent SAML replay

Audit authentication boundaries following GHSA-m87f-f43w-hwmc. Guard both LDAP user-bind paths and the DDP/REST entry point, preserve service-account group lookup, compare CAS groups literally, and require single-use SAML request correlation. Add positive, negative, protocol and browser regressions with Problems summaries.

Thanks to kta1kri and xet7 !
This commit is contained in:
Lauri Ojansivu committed 2026-09-27 16:49:39 +03:00
1 parent 90b901774e
commit 679a8b3499
21 files changed
+551 -66

No files matched your search

+13
View File
@@ -549,3 +549,16 @@ networks:
See [local login protocol tests](Testing.md) for example identities, actual
request/response checks, failure cases and deployment limitations.
### Authentication boundary checks
Both LDAP login modes require non-empty string credentials; empty passwords
cannot request an unauthenticated bind through the user login flow. Passwords
are never trimmed. Intentional anonymous service searches remain supported.
Both modes require exactly one user entry and enforce configured login-group
restrictions. Group refusal cannot fall back to a cached local password.
Service-search mode checks membership before rebinding as the user.
Blocked credential and group-policy attempts are summarized in Admin Panel /
Problems. See the [authentication boundary audit](../../Security/Authentication-Boundary-Audit-2026-09-27.md)
for validation coverage and deployment limitations.
+9
View File
@@ -116,3 +116,12 @@ configuration is in `server/saml.js` and `models/lib/samlConfig.js`.
See [local login protocol tests](Testing.md) for example identities, actual
request/response checks, failure cases and deployment limitations.
### Request correlation and replay protection
SAML login must start from WeKan's sign-in button. Responses require a live
`InResponseTo` request ID; unsolicited IdP-initiated assertions and replayed
responses are rejected. Signed responses still undergo node-saml signature,
time and audience checks. Authentication requests and replay protection are
process-local, so clustered instances require sticky routing for the login
handshake. See the [authentication boundary audit](../../Security/Authentication-Boundary-Audit-2026-09-27.md).
@@ -0,0 +1,109 @@
# Authentication boundary audit — 2026-09-27
This audit follows the private LDAP report GHSA-m87f-f43w-hwmc by
[kta1kri](https://github.com/kta1kri). It reviews the current application login
boundaries, not every possible vulnerability in the application or its identity
providers. The fixes are prepared locally for the Upcoming release.
## Confirmed findings
### LdapBindBleed — empty user credentials
Direct user-bind mode accepted an empty password. LDAP simple bind can accept a
non-empty DN with an empty password as unauthenticated access; that does not
prove the identity of the DN. If the directory also permits the user search,
WeKan's login handler could return the matching account ID. The installed
ldapts 4.2.6 passes the empty password to its bind request.
Both the login boundary and user-authentication helpers now require non-empty
string identifiers and passwords. Passwords are not trimmed. This applies to
DDP and the REST route that delegates to the LDAP handler, including the local
fallback. Intentional anonymous service searches are unaffected. Denials are
summarized as LdapBindBleed in Admin Panel / Problems without logging passwords.
The report's assertion that stock OpenLDAP permits unauthenticated binds by
default is incorrect: its current administrator guide says they are disabled
by default. This does not remove the application defect on permissive directories.
### DirectoryGroupBleed — LDAP and CAS group restrictions
Direct LDAP user-bind mode skipped the group check. Both authentication modes
now enforce it before account lookup/synchronization; a group refusal cannot
fall back to a cached local password. Both require exactly one matching entry.
The LDAP group-login query could omit its member clause when the configured
member-format attribute was absent. It now uses the same DN fallbacks as the
other group lookup and refuses a lookup without a usable member identity.
Username values are also escaped for LDAP filters and DN construction in their
respective contexts, preventing them from changing the query structure.
CAS used a substring regular expression to match allowed group names: an
allowed `wekan` matched `wekan-other`, and dots in configured names acted as
wildcards. It now compares complete, literal CN values from the membership DN,
including escaped separators. Configured empty/malformed lists and missing
memberships fail closed. Unconfigured restrictions remain unrestricted.
LDAP and CAS group denials appear as DirectoryGroupBleed summaries in Problems.
### SamlReplayBleed — response replay
The SAML adapter omitted `validateInResponseTo`; node-saml defaulted to `never`.
A local signed-response fixture reproduced accepting the same valid response
more than once, including under a different RelayState. Validation now requires
a live request ID using the maintained library's `always` mode. A bounded,
synchronous consumption guard also refuses concurrent validations of the same
verified response before storing a login credential. Detectable concurrent
replay denials appear as SamlReplayBleed summaries in Problems.
This flow supports SP-initiated login. Unsolicited IdP-initiated assertions are
now rejected. Request state remains process-local, as in the existing adapter;
clustered deployments need sticky routing for the authentication handshake.
A restart or configuration change invalidates in-flight requests. The replay
guard refuses new identities rather than evicting live entries if its bound
is reached; the request/consumption lifetime is eight hours.
## Other boundaries reviewed
| Boundary | Existing protection reviewed | Scope and result |
| --- | --- | --- |
| Password DDP | Meteor password verification; timing wrapper never returns an identity | No equivalent empty-bind bypass found |
| REST password/LDAP | Verified handler result, active-account check, optional 2FA, guarded token insertion | LDAP fix covers the delegated handler |
| CAS tickets | HTTPS provider validation, per-token credentials, single-use retrieval, explicit account-link opt-in | Group matching fixed above |
| SAML | node-saml signature, time and audience checks; explicit account-link opt-in | Request correlation and replay fixed above |
| OIDC/OAuth | Server-side code exchange/userinfo, per-login state, verified-email merge opt-in, provider-enabled checks | No additional bypass confirmed in this review |
| Passwordless | Feature gate, payload restrictions, compare-and-set token consumption | Existing regression tests retained |
| Header login | TCP-peer proxy allowlist, fail closed when unconfigured, active token insertion | Trusted proxy remains the identity authority |
| Sandstorm | Deployment-gated rendezvous and trusted Sandstorm proxy headers | No change to its platform trust model; no live Sandstorm test |
| Administrator impersonation/token creation | Administrator authorization and audit records | No anonymous token-issuance bypass found |
| Resume/disabled accounts | Existing token validation, status guard and revocation | Existing tests retained |
“No additional bypass confirmed” is not a claim that a provider or all possible
configurations are free from vulnerabilities. This audit does not validate live
production identity providers, TLS deployments or every third-party package.
## Verification
- `tests/ldapAuthenticationBoundary.test.cjs` executes the current LDAP class
and login handler with controlled directory/database boundaries. Positive,
negative, ambiguous-search, malformed-input, fallback, logging-failure and
sibling-source checks cover both LDAP modes.
- `tests/casGroupBoundary.test.cjs` executes the actual CAS validation class with
controlled HTTPS/XML responses, plus literal and escaped-DN policy cases.
- `tests/samlReplayBoundary.test.cjs` uses a generated test certificate and a
local signed protocol fixture. It reproduces the previous acceptance twice,
verifies valid login, and rejects replay, unsolicited, unsigned and tampered
responses. Atomic consumption, capacity and expiration are tested separately.
- `tests/playwright/specs/authentication-boundary.e2e.js` exercises anonymous
DDP login, unchanged session tokens and the Problems security summary.
- Existing LDAP, CAS, SAML, OIDC, OAuth, passwordless, 2FA, inactive-account and
provider regression suites are rerun. A stale OIDC VM harness was updated to
supply the existing administrator-creation context; application behavior was
unchanged by that harness repair.
References: [RFC 4513 §5.1.2](https://datatracker.ietf.org/doc/html/rfc4513#section-5.1.2),
[OpenLDAP authentication](https://www.openldap.org/doc/admin26/security.html#Authentication%20Methods),
[node-saml request validation](https://github.com/node-saml/node-saml#inresponseto-validation).
Validation result: 46 focused Node suites and five Chromium scenarios passed.
The local Meteor instance rebuilt successfully with these changes. No live
OpenLDAP/Active Directory, external CAS/SAML provider, Sandstorm deployment,
or FerretDB authentication matrix was exercised.
+16
View File
@@ -131,6 +131,22 @@ const CANARIES = {
key: 'authz.position-history',
what: 'tried to use position history across a board membership boundary',
},
'saml.response-replay': {
key: 'authn.saml-replay',
what: 'tried to reuse a SAML login response',
},
'ldap.invalid-credentials': {
key: 'authn.ldap-empty',
what: 'tried LDAP user authentication with empty or malformed credentials',
},
'ldap.group-denied': {
key: 'authn.ldap-group',
what: 'tried LDAP login outside the configured group restriction',
},
'cas.group-denied': {
key: 'authn.cas-group',
what: 'tried CAS login outside the configured group restriction',
},
'cas.account-conflict': {
key: 'authn.cas-link',
what: 'tried to link a CAS identity to an existing non-CAS account without consent',
+4
View File
@@ -9,6 +9,10 @@
// so both the server ESM code and the tests/*.test.cjs guards can load it.
const CATALOG = {
'authn.saml-replay': { category: 'authn', bleed: 'SamlReplayBleed', severity: 'high', cwe: 'CWE-294' },
'authn.ldap-empty': { category: 'authn', bleed: 'LdapBindBleed', severity: 'critical', cwe: 'CWE-287' },
'authn.ldap-group': { category: 'authn', bleed: 'DirectoryGroupBleed', severity: 'high', cwe: 'CWE-863' },
'authn.cas-group': { category: 'authn', bleed: 'DirectoryGroupBleed', severity: 'high', cwe: 'CWE-863' },
'authn.inactive': { category: 'authn', bleed: 'InactiveBleed', severity: 'critical', cwe: 'CWE-613' },
'authz.subtask-deposit': { category: 'authz', bleed: 'SubtaskDepositBleed', severity: 'high', cwe: 'CWE-639' },
'authz.linked-write': { category: 'authz', bleed: 'LinkedWriteBleed', severity: 'high', cwe: 'CWE-863' },
+12 -23
View File
@@ -4,6 +4,7 @@ import https from 'https';
import { URL } from 'url';
import { validationUrl, callbackUrl } from './cas_url';
import xml2js from 'xml2js';
import { isCasGroupAllowed } from './groupPolicy';
// Library
class CAS {
@@ -53,40 +54,28 @@ class CAS {
} else {
if (result['cas:serviceResponse'] == null) {
console.log('Empty response.');
callback({message: 'Empty response.'});
return callback({message: 'Empty response.'});
}
if (result['cas:serviceResponse']['cas:authenticationSuccess']) {
const userData = {
id: result['cas:serviceResponse']['cas:authenticationSuccess'][0]['cas:user'][0].toLowerCase(),
};
const attributes = result['cas:serviceResponse']['cas:authenticationSuccess'][0]['cas:attributes'][0];
const attributes = result['cas:serviceResponse']['cas:authenticationSuccess'][0]['cas:attributes']?.[0] || {};
// Check allowed ldap groups if exist (array only)
// example cas settings : "allowedLdapGroups" : ["wekan", "admin"],
let findedGroup = false;
const allowedLdapGroups = Meteor.settings.cas.allowedLdapGroups || false;
for (const fieldName in attributes) {
if (allowedLdapGroups && fieldName === 'cas:memberOf') {
for (const groups in attributes[fieldName]) {
const str = attributes[fieldName][groups];
if (!Array.isArray(allowedLdapGroups)) {
callback({message: 'Settings "allowedLdapGroups" must be an array'});
}
for (const allowedLdapGroup in allowedLdapGroups) {
if (str.search(`cn=${allowedLdapGroups[allowedLdapGroup]}`) >= 0) {
findedGroup = true;
}
}
if (!isCasGroupAllowed(Meteor.settings.cas.allowedLdapGroups, attributes['cas:memberOf'])) {
try {
if (typeof global.__wekanTripCanary === 'function') {
global.__wekanTripCanary('cas.group-denied');
}
}
} catch (e) { /* logging must never break the guard */ }
return callback({message: 'Group not allowed.'}, false);
}
for (const fieldName in attributes) {
userData[fieldName] = attributes[fieldName][0];
}
if (allowedLdapGroups && !findedGroup) {
callback({message: 'Group not finded.'}, false);
} else {
callback(undefined, true, userData);
}
callback(undefined, true, userData);
} else {
callback(undefined, false);
}
@@ -0,0 +1,40 @@
'use strict';
// Split an LDAP DN without treating an escaped separator as a new RDN.
function splitUnescaped(value, separator) {
const parts = [''];
for (let i = 0; i < value.length; i++) {
const c = value[i];
if (c === '\\' && i + 1 < value.length) {
parts[parts.length - 1] += c + value[++i];
} else if (c === separator) parts.push('');
else parts[parts.length - 1] += c;
}
return parts;
}
function commonNames(dn) {
if (typeof dn !== 'string') return [];
return splitUnescaped(splitUnescaped(dn, ',')[0], '+').flatMap(attribute => {
const equal = attribute.indexOf('=');
if (equal < 0 || attribute.slice(0, equal).trim().toLowerCase() !== 'cn') return [];
const raw = attribute.slice(equal + 1).trim();
const value = raw.replace(/(?:\\[0-9a-fA-F]{2})+|\\(.)/g, (escape, literal) =>
literal === undefined
? Buffer.from(escape.replaceAll('\\', ''), 'hex').toString('utf8')
: literal);
return [value];
});
}
function isCasGroupAllowed(allowed, memberships) {
if (allowed === undefined || allowed === null || allowed === false) return true;
// A configured empty or malformed allowlist grants nobody access.
if (!Array.isArray(allowed) || !allowed.length ||
allowed.some(name => typeof name !== 'string' || !name.trim())) return false;
const names = new Set(allowed.map(name => name.trim().toLowerCase()));
return (Array.isArray(memberships) ? memberships : []).some(dn =>
commonNames(dn).some(name => names.has(name.toLowerCase())));
}
module.exports = { isCasGroupAllowed };
@@ -0,0 +1,20 @@
'use strict';
// node-saml validates and removes request IDs asynchronously. Two concurrent
// validations can both read the same ID before either removes it. Consume the
// verified response ID synchronously before handing an identity to Accounts.
function createResponseReplayGuard({ maxEntries = 10000, ttlMs = 8 * 60 * 60 * 1000 } = {}) {
const used = new Map();
return function accept(profile, now = Date.now()) {
for (const [id, expires] of used) {
if (expires > now) break;
used.delete(id);
}
const id = profile?.getInResponseTo?.();
if (typeof id !== 'string' || !id || used.has(id) || used.size >= maxEntries) return false;
used.set(id, now + ttlMs);
return true;
};
}
module.exports = { createResponseReplayGuard };
@@ -12,6 +12,7 @@
// docs/Features/Login/SAML.md.
import { SAML } from '@node-saml/node-saml';
import { createResponseReplayGuard } from './responseReplay';
import bodyParser from 'body-parser';
const urlEncodedParser = bodyParser.urlencoded({ extended: false });
@@ -19,6 +20,7 @@ const urlEncodedParser = bodyParser.urlencoded({ extended: false });
let _samlCredentialTokens = {};
let _samlInstanceCacheKey = null;
let _samlInstance = null;
const acceptLoginResponse = createResponseReplayGuard();
async function getSamlServiceConfig() {
// eslint-disable-next-line no-undef
@@ -65,6 +67,10 @@ async function getSaml() {
logoutUrl: config.idpSLORedirectURL || undefined,
logoutCallbackUrl: Meteor.absoluteUrl(`_saml/logout/${provider}`),
wantAssertionsSigned: false,
// This application initiates the login. Require a live request ID rather
// than accepting a still-valid assertion again under a new RelayState.
validateInResponseTo: 'always',
requestIdExpirationPeriodMs: 8 * 60 * 60 * 1000,
});
_samlInstanceCacheKey = cacheKey;
return { saml: _samlInstance, config };
@@ -154,6 +160,14 @@ WebApp.connectHandlers.use('/_saml/validate', (req, res) => {
return;
}
const { profile } = await saml.validatePostResponseAsync(body);
if (!acceptLoginResponse(profile)) {
try {
if (typeof global.__wekanTripCanary === 'function') {
global.__wekanTripCanary('saml.response-replay');
}
} catch (e) { /* logging must never break the guard */ }
throw new Error('SAML login response has already been consumed or is unavailable');
}
_storeCredential(credentialToken, { profile });
closePopup(res);
} catch (err) {
+15 -14
View File
@@ -8,6 +8,7 @@ import {
loginGroupNames,
} from './groupFilterConfig';
import { resolveConfigValue } from './configResolver';
import { requireUserCredentials, escapeUserDnValue } from './userCredentials';
// Admin Panel -> LDAP override (models/settings.js's `ldap` sub-document): maps
// each env var this module reads to the admin-editable field that may override
@@ -384,7 +385,7 @@ export default class LDAP {
}
// Escape the username to prevent LDAP injection
const escapedUsername = escapedToHex(username);
const escapedUsername = escapeLdapFilterValue(username);
const usernameFilter = this.options.User_Search_Field.split(',').map((item) => `(${item}=${escapedUsername})`);
if (usernameFilter.length === 0) {
@@ -399,6 +400,7 @@ export default class LDAP {
}
async bindUserIfNecessary(username, password) {
requireUserCredentials(username, password);
if (this.domainBinded === true) {
return;
@@ -412,10 +414,10 @@ export default class LDAP {
if (!this.options.BaseDN && !this.options.AD_Simple_Auth) throw new Error('BaseDN is not provided');
// Escape the username to prevent LDAP injection in DN construction
const escapedUsername = escapedToHex(username);
const escapedUsername = escapeUserDnValue(username);
var userDn = "";
if (this.options.AD_Simple_Auth === true || this.options.AD_Simple_Auth === 'true') {
userDn = `${escapedUsername}@${this.options.Default_Domain}`;
userDn = `${username}@${this.options.Default_Domain}`;
} else {
userDn = `${this.options.User_Authentication_Field}=${escapedUsername},${this.options.BaseDN}`;
}
@@ -612,9 +614,9 @@ export default class LDAP {
filter.push(')');
// Escape the username to prevent LDAP injection
const escapedUsername = escapedToHex(username);
const escapedUsername = escapeLdapFilterValue(username);
const searchOptions = {
filter: filter.join('').replace(/#{username}/g, escapedUsername),
filter: filter.join('').replace(/#{username}/g, () => escapedUsername),
scope : 'sub',
};
@@ -675,10 +677,11 @@ export default class LDAP {
}
if (this.options.group_filter_group_member_attribute !== '') {
const format_value = ldapUser[this.options.group_filter_group_member_format];
if (format_value) {
filter.push(`(${this.options.group_filter_group_member_attribute}=${escapeLdapFilterValue(format_value)})`);
}
const format_value = ldapUser[this.options.group_filter_group_member_format] ||
ldapUser.dn || ldapUser.objectName || ldapUser.distinguishedName;
// Never turn a membership query into a search for any allowed group.
if (typeof format_value !== 'string' || !format_value) return false;
filter.push(`(${this.options.group_filter_group_member_attribute}=${escapeLdapFilterValue(format_value)})`);
}
if (this.options.group_filter_group_id_attribute !== '') {
@@ -701,9 +704,9 @@ export default class LDAP {
filter.push(')');
// Escape the username to prevent LDAP injection
const escapedUsername = escapedToHex(username);
const escapedUsername = escapeLdapFilterValue(username);
const searchOptions = {
filter: filter.join('').replace(/#{username}/g, escapedUsername),
filter: filter.join('').replace(/#{username}/g, () => escapedUsername),
scope : 'sub',
};
@@ -722,9 +725,7 @@ export default class LDAP {
Log.info(`Authenticating ${dn}`);
try {
if (password === '') {
throw new Error('Password is not provided');
}
requireUserCredentials(dn, password);
await this.bind(dn, password);
Log.info(`Authenticated ${dn}`);
return true;
+40 -28
View File
@@ -3,6 +3,7 @@ import LDAP from './ldap';
import { runWithLdapDisconnect } from './connectionGuard';
import { log_debug, log_info, log_warn, log_error } from './logger';
import { isAdminByGroups } from './adminGroups';
import { requireUserCredentials } from './userCredentials';
// Org/team sync is optional enrichment; failed sync must not block login.
async function syncUserGroupsToOrgsTeamsSafe(ldap, ldapUser, userId) {
@@ -42,6 +43,14 @@ Accounts.registerLoginHandler('ldap', async function(loginRequest) {
return undefined;
}
// DDP and REST both reach this handler. Reject malformed credentials before
// connecting, looking up an account, or entering the local-login fallback.
try {
requireUserCredentials(loginRequest.username, loginRequest.ldapPass);
} catch (error) {
throw new Meteor.Error('LDAP-login-error', 'LDAP authentication failed');
}
log_info('Init LDAP login', loginRequest.username);
if (LDAP.settings_get('LDAP_ENABLE') !== true) {
@@ -58,37 +67,19 @@ Accounts.registerLoginHandler('ldap', async function(loginRequest) {
// server until it hit "too many open connections" and fell over.
return await runWithLdapDisconnect(ldap, async () => {
let ldapUser;
const userAuthentication = !!LDAP.settings_get('LDAP_USER_AUTHENTICATION');
try {
await ldap.connect();
if (!!LDAP.settings_get('LDAP_USER_AUTHENTICATION')) {
await ldap.bindUserIfNecessary(loginRequest.username, loginRequest.ldapPass);
ldapUser = (await ldap.searchUsers(loginRequest.username))[0];
} else {
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)
}
}
await ldap.connect();
if (userAuthentication) {
await ldap.bindUserIfNecessary(loginRequest.username, loginRequest.ldapPass);
}
const users = await ldap.searchUsers(loginRequest.username);
if (users.length !== 1) throw new Error('User not uniquely identified');
ldapUser = users[0];
} catch (error) {
log_error(error);
ldapUser = null;
log_error(error);
}
if (!ldapUser) {
@@ -99,6 +90,27 @@ Accounts.registerLoginHandler('ldap', async function(loginRequest) {
throw new Meteor.Error('LDAP-login-error', `LDAP Authentication failed with provided username [${ loginRequest.username }]`);
}
// The directory group policy applies to both user-bind and service-bind
// authentication. A verified identity outside the allowed group must not
// fall back to a cached local password and bypass this policy.
if (!(await ldap.isUserInGroup(loginRequest.username, ldapUser))) {
try {
if (typeof global.__wekanTripCanary === 'function') {
global.__wekanTripCanary('ldap.group-denied');
}
} catch (e) { /* logging must never break the guard */ }
throw new Meteor.Error('LDAP-login-error', 'LDAP authentication failed');
}
// In service-search mode the group lookup must still use the service
// identity, before auth() switches the connection to the user's bind.
if (!userAuthentication && await ldap.auth(ldapUser.dn, loginRequest.ldapPass) !== true) {
if (LDAP.settings_get('LDAP_LOGIN_FALLBACK') === true) {
return fallbackDefaultAccountSystem(self, loginRequest.username, loginRequest.ldapPass);
}
throw new Meteor.Error('LDAP-login-error', 'LDAP authentication failed');
}
// Look to see if user already exists
let userQuery;
@@ -0,0 +1,25 @@
'use strict';
// A successful LDAP bind with an empty password can mean anonymous access,
// not proof of identity (RFC 4513 section 5.1.2). Do not apply this check to
// service-account or intentional anonymous directory searches.
function requireUserCredentials(identifier, password) {
if (typeof identifier === 'string' && identifier.length > 0 &&
typeof password === 'string' && password.length > 0) return;
try {
if (typeof global.__wekanTripCanary === 'function') {
global.__wekanTripCanary('ldap.invalid-credentials');
}
} catch (e) { /* logging must never break the guard */ }
throw new Error('LDAP user credentials must be non-empty strings');
}
function escapeUserDnValue(value) {
return value.replace(/[, +"\\<>;=\0#]/g, (character, offset) => {
if (character === ' ' && offset !== 0 && offset !== value.length - 1) return character;
if (character === '#' && offset !== 0) return character;
return character === '\0' ? '\\00' : `\\${character}`;
});
}
module.exports = { requireUserCredentials, escapeUserDnValue };
+36
View File
@@ -0,0 +1,36 @@
'use strict';
const {test}=require('node:test');const assert=require('node:assert/strict');
const fs=require('node:fs');const path=require('node:path');const vm=require('node:vm');
const {isCasGroupAllowed}=require('../packages/wekan-accounts-cas/groupPolicy');
test('CAS group policy compares complete literal CN values, including escaped DN characters',()=>{
assert.equal(isCasGroupAllowed(['wekan'],['CN=WeKan,OU=Groups,DC=example']),true);
assert.equal(isCasGroupAllowed(['team.a'],['cn=team.a,dc=example']),true);
assert.equal(isCasGroupAllowed(['Team, A'],['CN=Team\\, A,OU=Groups']),true);
assert.equal(isCasGroupAllowed(['Team, A'],['CN=Team\\2c A,OU=Groups']),true);
assert.equal(isCasGroupAllowed(['wekan'],['uid=group+cn=wekan,dc=example']),true);
for(const dn of ['cn=wekan-other,dc=example','cn=other,ou=cn=wekan,dc=example','cn=other\\,cn=wekan,dc=example',null])
assert.equal(isCasGroupAllowed(['wekan'],[dn]),false,String(dn));
assert.equal(isCasGroupAllowed(['team.a'],['cn=teamXa,dc=example']),false);
assert.equal(isCasGroupAllowed(['wekan'],undefined),false);
for(const config of [[],[''],['wekan',null],'wekan',0,{},true])assert.equal(isCasGroupAllowed(config,['cn=wekan']),false);
assert.equal(isCasGroupAllowed(undefined,undefined),true);
assert.equal(isCasGroupAllowed(false,undefined),true);
});
test('actual CAS validator rejects a prefix-only membership without a later success callback',async()=>{
const source=fs.readFileSync(path.resolve(__dirname,'../packages/wekan-accounts-cas/cas_server.js'),'utf8');
const code=source.slice(source.indexOf('class CAS {'),source.indexOf('////// END OF CAS MODULE'));
let group='cn=wekan-other,dc=example';const reported=[];
const context=vm.createContext({URL,isCasGroupAllowed,validationUrl:v=>v,console:{log(){}},
Meteor:{settings:{cas:{allowedLdapGroups:['wekan']}}},global:{__wekanTripCanary:key=>reported.push(key)},
https:{get(url,callback){const handlers={};callback({on(event,fn){handlers[event]=fn;},setEncoding(){}});handlers.data('xml');handlers.end();}},
xml2js:{parseString(xml,callback){callback(null,{'cas:serviceResponse':{'cas:authenticationSuccess':[{'cas:user':['alice'],'cas:attributes':[{'cas:memberOf':[group]}]}]}});}},
});
vm.runInContext(code+'\nglobalThis.CAS=CAS;',context);
const cas=new context.CAS({validate_url:'https://example.invalid/validate',service:'https://wekan.invalid'});
let replies=[];cas.validate('ST-test',(...args)=>replies.push(args));
assert.equal(replies.length,1);assert.equal(replies[0][1],false);assert.deepEqual(reported,['cas.group-denied']);
group='cn=wekan,dc=example';replies=[];cas.validate('ST-test',(...args)=>replies.push(args));
assert.equal(replies.length,1);assert.equal(replies[0][1],true);assert.equal(replies[0][2].id,'alice');
assert.doesNotMatch(source,/str\.search\(/);
assert.match(source,/isCasGroupAllowed\(Meteor\.settings\.cas\.allowedLdapGroups/);
});
+1
View File
@@ -32,6 +32,7 @@ const context = {
require(name) {
if (name === 'https') return { get(url) { requests.push(url); } };
if (name === './cas_url') return { validationUrl, callbackUrl };
if (name === './groupPolicy') return require('../packages/wekan-accounts-cas/groupPolicy');
if (name === 'xml2js') return {};
return require(name);
},
+35
View File
@@ -0,0 +1,35 @@
'use strict';
const fs = require('node:fs');
const vm = require('node:vm');
const path = require('node:path');
const directory = path.resolve(__dirname, '../../packages/wekan-ldap/server');
const read = file => fs.readFileSync(path.join(directory, file), 'utf8');
const stripImports = source => source.replace(/^import[\s\S]*?;\n/gm, '');
module.exports = function harness(overrides = {}) {
const settings = {LDAP_ENABLE:true, LDAP_USER_AUTHENTICATION:true, LDAP_BASEDN:'dc=example,dc=test',
LDAP_USER_AUTHENTICATION_FIELD:'uid',LDAP_USER_SEARCH_FIELD:'uid',LDAP_USERNAME_FIELD:'',LDAP_EMAIL_FIELD:'',
LDAP_LOGIN_FALLBACK:false, ...overrides};
const state = {events:[], entries:[{dn:'uid=alice,dc=example,dc=test',uid:'alice'}], group:true, allowEmpty:true};
const log = {info(){}, debug(){}, warn(){}, error(){}};
let login;
const context = vm.createContext({process:{env:{}}, Buffer, global,
Log:log, normalizeLdapEncryption:()=>({mode:'off'}),resolveConfigValue:()=>({value:undefined}),
...require(path.join(directory,'groupFilterConfig')), ...require(path.join(directory,'userCredentials')),
Accounts:{registerLoginHandler(name,fn){login=fn;}, _runLoginHandlers:async()=>{state.events.push('fallback');return {userId:'fallback'};}},
Meteor:{Error:class extends Error{},users:{findOneAsync:async()=>{state.events.push('lookup');return {_id:'alice-id',username:'alice',authenticationMethod:'ldap'};}}},
slug:v=>v, getLdapUserUniqueID:()=>null, syncUserData:async()=>{},syncUserGroupsToOrgsTeams:async()=>{},
log_debug:log.debug,log_info:log.info,log_warn:log.warn,log_error:log.error,
runWithLdapDisconnect:require(path.join(directory,'connectionGuard')).runWithLdapDisconnect,
});
vm.runInContext(stripImports(read('ldap.js')).replace('export function ','function ').replace('export default class LDAP','class LDAP')+'\nglobalThis.LDAP=LDAP;',context);
const LDAP=context.LDAP;
LDAP.settings_get=key=>settings[key];
LDAP.prototype.connect=async function(){state.events.push('connect');this.client={bind:async(dn,password)=>{
state.events.push({dn,password});if(password!=='correct'&&!(state.allowEmpty&&password===''))throw Error('invalid credentials');
},unbind:async()=>state.events.push('disconnect')};};
LDAP.prototype.searchAll=async()=>{state.events.push('search');return state.entries;};
const groupMethod=LDAP.prototype.isUserInGroup;
LDAP.prototype.isUserInGroup=async()=>{state.events.push('group');return state.group;};
vm.runInContext(stripImports(read('loginHandler.js')),context);
return {LDAP,state,settings,groupMethod,login:request=>login({ldap:true,ldapOptions:{},username:'alice',ldapPass:'correct',...request})};
};
+107
View File
@@ -0,0 +1,107 @@
'use strict';
const {test}=require('node:test');
const assert=require('node:assert/strict');
const fs=require('node:fs');
const path=require('node:path');
const harness=require('./helpers/ldapAuthHarness.cjs');
const {requireUserCredentials}=require('../packages/wekan-ldap/server/userCredentials');
test('user credentials reject empty/malformed values without normalizing real passwords',()=>{
for(const value of ['',undefined,null,0,false,[],{},{$ne:null}]){
assert.throws(()=>requireUserCredentials('alice',value));
assert.throws(()=>requireUserCredentials(value,'correct'));
}
requireUserCredentials('alice',' correct ');
requireUserCredentials('alice',' ');
const previous=global.__wekanTripCanary;
const events=[];
try{
global.__wekanTripCanary=(...args)=>events.push(args);
assert.throws(()=>requireUserCredentials('alice',''));
assert.deepEqual(events,[['ldap.invalid-credentials']]);
global.__wekanTripCanary=()=>{throw Error('logger unavailable');};
assert.throws(()=>requireUserCredentials('alice',''),/credentials/);
}finally{global.__wekanTripCanary=previous;}
});
test('DDP/REST LDAP handler refuses empty passwords before bind, lookup or fallback',async()=>{
for(const LDAP_USER_AUTHENTICATION of [true,false])for(const LDAP_ENABLE of [true,false]){
const h=harness({LDAP_USER_AUTHENTICATION,LDAP_ENABLE,LDAP_LOGIN_FALLBACK:true});
for(const ldapPass of ['',null,undefined,{},[]])await assert.rejects(h.login({ldapPass}));
assert.deepEqual(h.state.events,[]);
}
});
test('both modes authenticate valid passwords, reject wrong passwords, and require one matching entry',async()=>{
for(const LDAP_USER_AUTHENTICATION of [true,false]){
const h=harness({LDAP_USER_AUTHENTICATION});
assert.equal((await h.login()).userId,'alice-id');
await assert.rejects(h.login({ldapPass:'wrong'}));
for(const entries of [[],[h.state.entries[0],h.state.entries[0]]]){
h.state.entries=entries;await assert.rejects(h.login());
}
}
});
test('every LDAP login mode enforces group restrictions before lookup and never falls back on group denial',async()=>{
for(const LDAP_USER_AUTHENTICATION of [true,false]){
const h=harness({LDAP_USER_AUTHENTICATION,LDAP_LOGIN_FALLBACK:true});h.state.group=false;
await assert.rejects(h.login());
assert.ok(h.state.events.includes('group'));
assert.ok(!h.state.events.includes('lookup'));assert.ok(!h.state.events.includes('fallback'));
}
});
test('both bind helpers reject empty/malformed passwords, including already-bound user connections',async()=>{
const {LDAP}=harness();const ldap=new LDAP();await ldap.connect();
for(const value of ['',null,undefined,{}]){
await assert.rejects(ldap.bindUserIfNecessary('alice',value));
assert.equal(await ldap.auth('uid=alice',value),false);
}
ldap.domainBinded=true;await assert.rejects(ldap.bindUserIfNecessary('alice',''));
// Service searches may intentionally use anonymous credentials.
ldap.domainBinded=false;ldap.options.Authentication=true;
ldap.options.Authentication_UserDN='';ldap.options.Authentication_Password='';
await ldap.bindIfNecessary();assert.equal(ldap.domainBinded,true);
});
test('group queries cannot lose the membership clause when a configured attribute is absent',async()=>{
const {LDAP,groupMethod}=harness();const ldap=new LDAP();
Object.assign(ldap.options,{group_filter_enabled:true,group_filter_object_class:'group',group_filter_group_id_attribute:'cn',
group_filter_group_member_attribute:'member',group_filter_group_member_format:'missing',group_filter_group_name:'allowed'});
ldap.getUserGroups=async()=>[];
const filters=[];ldap.searchAll=async(base,options)=>{filters.push(options.filter);return [{cn:'allowed'}];};
assert.equal(await groupMethod.call(ldap,'alice',{}),false);assert.equal(filters.length,0);
assert.equal(await groupMethod.call(ldap,'alice',{dn:'uid=alice,dc=example'}),true);
assert.ok(filters[0].includes('(member=uid=alice,dc=example)'));
});
test('all user-password bind call sites retain guards; low-level and service binds stay distinct',()=>{
const root=path.resolve(__dirname,'../packages/wekan-ldap/server');
const source=fs.readFileSync(path.join(root,'ldap.js'),'utf8');
for(const name of ['bindUserIfNecessary','auth']){
const start=source.indexOf(` async ${name}(`);const end=source.indexOf('\n async ',start+1);
const body=source.slice(start,end<0?undefined:end);
assert.ok(body.indexOf('requireUserCredentials(')<body.indexOf('await this.bind('));
}
const callers=[];
for(const file of fs.readdirSync(root).filter(f=>f.endsWith('.js'))){
const text=fs.readFileSync(path.join(root,file),'utf8');
if(/\.bindUserIfNecessary\(/.test(text))callers.push(file);
}
assert.deepEqual(callers,['loginHandler.js']);
const login=fs.readFileSync(path.join(root,'loginHandler.js'),'utf8');
assert.ok(login.indexOf('requireUserCredentials(loginRequest.username, loginRequest.ldapPass)')<login.indexOf('const ldap = new LDAP()'));
assert.equal((login.match(/await ldap\.isUserInGroup\(/g)||[]).length,1);
const rest=fs.readFileSync(path.resolve(root,'../../../server/lib/restAuthenticationMethod.js'),'utf8');
assert.match(rest,/ldapPass: password/);
});
test('LDAP identity values cannot introduce wildcard filters or extra DN components',async()=>{
const {LDAP,state}=harness();const ldap=new LDAP();await ldap.connect();
const identifier='a*)(uid=*)';
assert.equal(ldap.getUserFilter(identifier),'(&(uid=a\\2a\\29\\28uid=\\2a\\29))');
assert.equal(ldap.getUserFilter('a\\2ab'),'(&(uid=a\\5c2ab))');
await ldap.bindUserIfNecessary('alice,ou=other','correct');
assert.equal(state.events.find(e=>e.dn).dn,'uid=alice\\,ou\\=other,dc=example,dc=test');
const source=fs.readFileSync(path.resolve(__dirname,'../packages/wekan-ldap/server/ldap.js'),'utf8');
assert.doesNotMatch(source,/escapedToHex\(username\)/);
assert.doesNotMatch(source,/replace\(\/\#\{username\}\/g, escapedUsername\)/);
});
test('service-search mode checks groups before rebinding as the user',async()=>{
const h=harness({LDAP_USER_AUTHENTICATION:false});await h.login();
assert.ok(h.state.events.indexOf('group')<h.state.events.findIndex(e=>e.dn==='uid=alice,dc=example,dc=test'));
});
+1 -1
View File
@@ -4,7 +4,7 @@ const fs = require('node:fs');
const path = require('node:path');
const source = fs.readFileSync(path.join(__dirname, '../packages/wekan-ldap/server/ldap.js'), 'utf8');
const body = source.split(' getUserFilter(username) {')[1].split('\n async bindUserIfNecessary')[0].replace(/\n }\s*$/, '');
const getUserFilter = new Function('escapedToHex', 'Log', `return function(username) {${body}}`)(value => value, { error() {} });
const getUserFilter = new Function('escapeLdapFilterValue', 'Log', `return function(username) {${body}}`)(value => value, { error() {} });
for (const value of [undefined, null, '']) {
assert.equal(getUserFilter.call({ options: { User_Search_Filter: value, User_Search_Field: 'sAMAccountName' } }, 'alice'), '(&(sAMAccountName=alice))');
}
+1
View File
@@ -51,6 +51,7 @@ function buildHook(env) {
const removed = [];
const context = {
process: { env: Object.assign({}, env) },
adminCreation: { get: () => undefined },
console,
Accounts: {
onCreateUser(fn) {
@@ -0,0 +1,17 @@
'use strict';
const {test,expect}=require('../fixtures');
const db=require('../helpers/db');
test('anonymous LDAP login with an empty password cannot create a session and records a security summary',async({page,user})=>{
await page.goto('/sign-in');
await page.waitForFunction(()=>typeof Meteor!=='undefined'&&Meteor.status().connected);
const before=db.findOne('users',{_id:user.id}).services.resume.loginTokens.length;
const result=await page.evaluate(username=>new Promise(resolve=>{
Accounts.callLoginMethod({methodArguments:[{ldap:true,ldapOptions:{},username,ldapPass:''}],
userCallback:error=>resolve({error:error?.error,userId:Meteor.userId()})});
}),user.username);
expect(result.error).toBeTruthy();expect(result.userId).toBeNull();
expect(db.findOne('users',{_id:user.id}).services.resume.loginTokens.length).toBe(before);
await expect.poll(()=>db.find('eventlog',{bleed:'LdapBindBleed'}).length).toBeGreaterThan(0);
await expect(page.locator('#at-field-password')).toBeVisible();
});
+35
View File
@@ -0,0 +1,35 @@
'use strict';
const {test}=require('node:test');const assert=require('node:assert/strict');
const fs=require('node:fs');const path=require('node:path');const {execFileSync}=require('node:child_process');
const {SAML}=require('@node-saml/node-saml');
const {startProvider}=require('./integration/login-providers/provider.cjs');
const {createResponseReplayGuard}=require('../packages/wekan-accounts-saml/responseReplay');
test('atomic response consumption rejects concurrent replay, malformed IDs and capacity overflow',async()=>{
const accept=createResponseReplayGuard({maxEntries:2,ttlMs:100});
const profile=id=>({getInResponseTo:()=>id});
assert.deepEqual(await Promise.all([1,2].map(()=>Promise.resolve().then(()=>accept(profile('a'),1)))),[true,false]);
for(const value of [undefined,null,{},profile(''),profile({})])assert.equal(accept(value,1),false);
assert.equal(accept(profile('b'),2),true);assert.equal(accept(profile('c'),3),false);
assert.equal(accept(profile('c'),102),true);
});
test('signed response is accepted once; replay, unsolicited, unsigned and tampered assertions fail',async t=>{
const root=path.resolve(__dirname,'..');const parent=path.join(root,'.tools/tmp');fs.mkdirSync(parent,{recursive:true});
const work=fs.mkdtempSync(path.join(parent,'saml-replay-'));t.after(()=>fs.rmSync(work,{recursive:true,force:true}));
execFileSync('openssl',['req','-x509','-newkey','rsa:2048','-nodes','-keyout',work+'/key.pem','-out',work+'/cert.pem','-days','2','-subj','/CN=WeKan-test-only'],{stdio:'ignore',env:{...process.env,TMPDIR:parent}});
const certificate=fs.readFileSync(work+'/cert.pem','utf8');
const provider=await startProvider({certificate,privateKey:fs.readFileSync(work+'/key.pem','utf8')});t.after(()=>provider.close());
const source=fs.readFileSync(path.join(root,'packages/wekan-accounts-saml/saml_server.js'),'utf8');
const mode=/validateInResponseTo:\s*'([^']+)'/.exec(source)?.[1];assert.equal(mode,'always');
const opts={entryPoint:provider.url+'/saml',callbackUrl:'http://wekan.invalid/_saml/validate/default',issuer:'wekan-test',idpCert:certificate,wantAssertionsSigned:false};
const saml=new SAML({...opts,validateInResponseTo:mode});
async function response(client){const url=await client.getAuthorizeUrlAsync('test-relay',undefined,{});const html=await(await fetch(url)).text();return {SAMLResponse:/name="SAMLResponse" value="([^"]+)"/.exec(html)[1],RelayState:'test-relay'};}
const body=await response(saml);const first=await saml.validatePostResponseAsync(body);assert.equal(first.profile.nameID,'alice.saml@example.invalid');
await assert.rejects(saml.validatePostResponseAsync({...body,RelayState:'different-relay'}),/InResponseTo/);
// Reproduce the old configuration to prove the test detects the vulnerability.
const old=new SAML(opts);await old.validatePostResponseAsync(body);await old.validatePostResponseAsync(body);
const another=new SAML({...opts,validateInResponseTo:mode});await assert.rejects(another.validatePostResponseAsync(body),/InResponseTo/);
for(const mode of ['unsigned','tampered']){provider.state.mode=mode;const bad=await response(saml);await assert.rejects(saml.validatePostResponseAsync(bad));}
assert.match(source,/if \(!acceptLoginResponse\(profile\)\)/);
assert.ok(source.indexOf('acceptLoginResponse(profile)')<source.indexOf('_storeCredential(credentialToken, { profile })'));
});
+1
View File
@@ -15,6 +15,7 @@ class SamlStub {
}
vm.runInNewContext(source, {
SAML: SamlStub, URL, console,
...require('../packages/wekan-accounts-saml/responseReplay'),
bodyParser: { urlencoded: () => (_req, _res, next) => next() },
ServiceConfiguration: { configurations: { findOneAsync: async () => config } },
Assets: { getTextSync() { throw Error('not configured'); } },