mirror of
https://github.com/wekan/wekan.git
synced 2026-11-05 15:45:01 -05:00
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:
1 parent
90b901774e
commit
679a8b3499
21 files changed
+551
-66
No files matched your search
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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',
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
@@ -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})};
|
||||
};
|
||||
@@ -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'));
|
||||
});
|
||||
@@ -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))');
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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 })'));
|
||||
});
|
||||
@@ -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'); } },
|
||||
|
||||
Reference in new issue
Block a user