From ee48a0ba8c53c12d6a197539683751b8cb582866 Mon Sep 17 00:00:00 2001 From: Manuel Trezza <5673677+mtrezza@users.noreply.github.com> Date: Tue, 25 Aug 2026 12:47:58 +0200 Subject: [PATCH 1/3] fix: GHSA-863r-39r9-vfcf --- spec/LdapAuth.spec.js | 98 ++++++++++++++++++++++++++++++++++ spec/support/MockLdapServer.js | 15 +++++- src/Adapters/Auth/ldap.js | 15 +++++- 3 files changed, 126 insertions(+), 2 deletions(-) diff --git a/spec/LdapAuth.spec.js b/spec/LdapAuth.spec.js index b577defcd9..7c68252b12 100644 --- a/spec/LdapAuth.spec.js +++ b/spec/LdapAuth.spec.js @@ -1,4 +1,5 @@ const ldap = require('../lib/Adapters/Auth/ldap'); +const ldapjs = require('ldapjs'); const mockLdapServer = require('./support/MockLdapServer'); const fs = require('fs'); const port = 12345; @@ -125,6 +126,103 @@ describe('LDAP Injection Prevention', () => { } server.close(done); }); + + // A zero-length credential in a simple bind is the unauthenticated authentication + // mechanism of RFC 4513 section 5.1.2. Directories may answer it with success and map + // the connection to anonymous, so the credential must be refused by Parse Server + // before it is sent. The mock directory used by the tests below accepts such a bind; + // this test is the control that proves it does. + it('mock directory accepts a bind with a zero-length credential', async done => { + const server = await mockLdapServer(port, 'uid=testuser, o=example', false, false, { + allowUnauthenticatedBind: true, + }); + const client = ldapjs.createClient({ url: `ldap://localhost:${port}` }); + await new Promise((resolve, reject) => + client.bind('uid=testuser, o=example', '', err => (err ? reject(err) : resolve())) + ); + client.destroy(); + expect(server.bindAttempts.length).toBe(1); + expect(server.bindAttempts[0].credentials).toBe(''); + server.close(done); + }); + + it('should reject empty authData.password', async done => { + const server = await mockLdapServer(port, 'uid=testuser, o=example', false, false, { + allowUnauthenticatedBind: true, + }); + const options = { + suffix: 'o=example', + url: `ldap://localhost:${port}`, + dn: 'uid={{id}}, o=example', + }; + try { + await ldap.validateAuthData({ id: 'testuser', password: '' }, options); + fail('Should have rejected empty password'); + } catch (err) { + expect(err.message).toBe('LDAP: Wrong username or password'); + } + expect(server.bindAttempts.length).toBe(0); + server.close(done); + }); + + it('should reject missing authData.password', async done => { + const server = await mockLdapServer(port, 'uid=testuser, o=example', false, false, { + allowUnauthenticatedBind: true, + }); + const options = { + suffix: 'o=example', + url: `ldap://localhost:${port}`, + dn: 'uid={{id}}, o=example', + }; + try { + await ldap.validateAuthData({ id: 'testuser' }, options); + fail('Should have rejected missing password'); + } catch (err) { + expect(err.message).toBe('LDAP: Wrong username or password'); + } + expect(server.bindAttempts.length).toBe(0); + server.close(done); + }); + + it('should reject null authData.password', async done => { + const server = await mockLdapServer(port, 'uid=testuser, o=example', false, false, { + allowUnauthenticatedBind: true, + }); + const options = { + suffix: 'o=example', + url: `ldap://localhost:${port}`, + dn: 'uid={{id}}, o=example', + }; + try { + await ldap.validateAuthData({ id: 'testuser', password: null }, options); + fail('Should have rejected null password'); + } catch (err) { + expect(err.message).toBe('LDAP: Wrong username or password'); + } + expect(server.bindAttempts.length).toBe(0); + server.close(done); + }); + + it('should reject non-string authData.password', async done => { + const server = await mockLdapServer(port, 'uid=testuser, o=example', false, false, { + allowUnauthenticatedBind: true, + }); + const options = { + suffix: 'o=example', + url: `ldap://localhost:${port}`, + dn: 'uid={{id}}, o=example', + }; + for (const password of [123, {}, [], true]) { + try { + await ldap.validateAuthData({ id: 'testuser', password }, options); + fail(`Should have rejected non-string password: ${JSON.stringify(password)}`); + } catch (err) { + expect(err.message).toBe('LDAP: Wrong username or password'); + } + } + expect(server.bindAttempts.length).toBe(0); + server.close(done); + }); }); describe('DN injection prevention', () => { diff --git a/spec/support/MockLdapServer.js b/spec/support/MockLdapServer.js index 935f0703d6..6ae087b06b 100644 --- a/spec/support/MockLdapServer.js +++ b/spec/support/MockLdapServer.js @@ -6,10 +6,23 @@ const tlsOptions = { certificate: fs.readFileSync(__dirname + '/cert/cert.pem'), }; -function newServer(port, dn, provokeSearchError = false, ssl = false) { +function newServer(port, dn, provokeSearchError = false, ssl = false, options = {}) { const server = ssl ? ldapjs.createServer(tlsOptions) : ldapjs.createServer(); + // Records every bind the directory actually receives, so tests can assert that a + // credential is refused before the directory is contacted. + server.bindAttempts = []; + server.bind('o=example', function (req, res, next) { + server.bindAttempts.push({ dn: req.dn.toString(), credentials: req.credentials }); + // Models a directory that honors the unauthenticated authentication mechanism of + // simple bind (RFC 4513 section 5.1.2), which Active Directory permits by default: + // a valid DN with a zero-length credential binds successfully and the connection is + // mapped to anonymous. + if (options.allowUnauthenticatedBind && req.dn.toString() === dn && req.credentials === '') { + res.end(); + return next(); + } if (req.dn.toString() !== dn || req.credentials !== 'secret') { return next(new ldapjs.InvalidCredentialsError()); } res.end(); diff --git a/src/Adapters/Auth/ldap.js b/src/Adapters/Auth/ldap.js index 7312aea67b..f4a1f9e353 100644 --- a/src/Adapters/Auth/ldap.js +++ b/src/Adapters/Auth/ldap.js @@ -40,7 +40,8 @@ * ## Auth Payload * The adapter requires the following `authData` fields: * - `id`: The user's LDAP username. - * - `password`: The user's LDAP password. + * - `password`: The user's LDAP password. Must be a non-empty string; an empty or missing + * password is rejected without contacting the directory. * * ### Example Auth Payload * ```json @@ -122,6 +123,18 @@ function validateAuthData(authData, options) { new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'LDAP: Wrong username or password') ); } + // A missing, empty or non-string password is serialized by ldapjs as a zero-length + // credential, which makes the bind an unauthenticated authentication mechanism of simple + // bind (RFC 4513 section 5.1.2). A directory may answer that with success and map the + // connection to anonymous, which this adapter would otherwise read as a successful + // authentication. RFC 4513 section 5.1.2 states that clients must not use the + // unauthenticated mechanism to authenticate, so the credential is rejected here, before + // the directory is contacted. + if (typeof authData.password !== 'string' || authData.password.length === 0) { + return Promise.reject( + new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'LDAP: Wrong username or password') + ); + } const client = ldapjs.createClient(clientOptions); const escapedId = escapeDN(authData.id); const userCn = From 7e7dc63fbf734ece8b603888584ff39390fc1ffd Mon Sep 17 00:00:00 2001 From: Manuel Trezza <5673677+mtrezza@users.noreply.github.com> Date: Tue, 25 Aug 2026 14:00:10 +0200 Subject: [PATCH 2/3] test: Await server close in new LDAP specs --- spec/LdapAuth.spec.js | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/spec/LdapAuth.spec.js b/spec/LdapAuth.spec.js index 7c68252b12..c2cacac379 100644 --- a/spec/LdapAuth.spec.js +++ b/spec/LdapAuth.spec.js @@ -132,7 +132,7 @@ describe('LDAP Injection Prevention', () => { // the connection to anonymous, so the credential must be refused by Parse Server // before it is sent. The mock directory used by the tests below accepts such a bind; // this test is the control that proves it does. - it('mock directory accepts a bind with a zero-length credential', async done => { + it('mock directory accepts a bind with a zero-length credential', async () => { const server = await mockLdapServer(port, 'uid=testuser, o=example', false, false, { allowUnauthenticatedBind: true, }); @@ -143,10 +143,10 @@ describe('LDAP Injection Prevention', () => { client.destroy(); expect(server.bindAttempts.length).toBe(1); expect(server.bindAttempts[0].credentials).toBe(''); - server.close(done); + await new Promise(resolve => server.close(resolve)); }); - it('should reject empty authData.password', async done => { + it('should reject empty authData.password', async () => { const server = await mockLdapServer(port, 'uid=testuser, o=example', false, false, { allowUnauthenticatedBind: true, }); @@ -162,10 +162,10 @@ describe('LDAP Injection Prevention', () => { expect(err.message).toBe('LDAP: Wrong username or password'); } expect(server.bindAttempts.length).toBe(0); - server.close(done); + await new Promise(resolve => server.close(resolve)); }); - it('should reject missing authData.password', async done => { + it('should reject missing authData.password', async () => { const server = await mockLdapServer(port, 'uid=testuser, o=example', false, false, { allowUnauthenticatedBind: true, }); @@ -181,10 +181,10 @@ describe('LDAP Injection Prevention', () => { expect(err.message).toBe('LDAP: Wrong username or password'); } expect(server.bindAttempts.length).toBe(0); - server.close(done); + await new Promise(resolve => server.close(resolve)); }); - it('should reject null authData.password', async done => { + it('should reject null authData.password', async () => { const server = await mockLdapServer(port, 'uid=testuser, o=example', false, false, { allowUnauthenticatedBind: true, }); @@ -200,10 +200,10 @@ describe('LDAP Injection Prevention', () => { expect(err.message).toBe('LDAP: Wrong username or password'); } expect(server.bindAttempts.length).toBe(0); - server.close(done); + await new Promise(resolve => server.close(resolve)); }); - it('should reject non-string authData.password', async done => { + it('should reject non-string authData.password', async () => { const server = await mockLdapServer(port, 'uid=testuser, o=example', false, false, { allowUnauthenticatedBind: true, }); @@ -221,7 +221,7 @@ describe('LDAP Injection Prevention', () => { } } expect(server.bindAttempts.length).toBe(0); - server.close(done); + await new Promise(resolve => server.close(resolve)); }); }); From 849843c27b8fab2c5c7e4facea10a13d1559d1b3 Mon Sep 17 00:00:00 2001 From: Manuel Trezza <5673677+mtrezza@users.noreply.github.com> Date: Tue, 25 Aug 2026 14:11:33 +0200 Subject: [PATCH 3/3] test: Release mock LDAP server in finally block --- spec/LdapAuth.spec.js | 81 +++++++++++++++++++++++++------------------ 1 file changed, 48 insertions(+), 33 deletions(-) diff --git a/spec/LdapAuth.spec.js b/spec/LdapAuth.spec.js index c2cacac379..7a793ec8f3 100644 --- a/spec/LdapAuth.spec.js +++ b/spec/LdapAuth.spec.js @@ -137,13 +137,16 @@ describe('LDAP Injection Prevention', () => { allowUnauthenticatedBind: true, }); const client = ldapjs.createClient({ url: `ldap://localhost:${port}` }); - await new Promise((resolve, reject) => - client.bind('uid=testuser, o=example', '', err => (err ? reject(err) : resolve())) - ); - client.destroy(); - expect(server.bindAttempts.length).toBe(1); - expect(server.bindAttempts[0].credentials).toBe(''); - await new Promise(resolve => server.close(resolve)); + try { + await new Promise((resolve, reject) => + client.bind('uid=testuser, o=example', '', err => (err ? reject(err) : resolve())) + ); + expect(server.bindAttempts.length).toBe(1); + expect(server.bindAttempts[0].credentials).toBe(''); + } finally { + client.destroy(); + await new Promise(resolve => server.close(resolve)); + } }); it('should reject empty authData.password', async () => { @@ -156,13 +159,16 @@ describe('LDAP Injection Prevention', () => { dn: 'uid={{id}}, o=example', }; try { - await ldap.validateAuthData({ id: 'testuser', password: '' }, options); - fail('Should have rejected empty password'); - } catch (err) { - expect(err.message).toBe('LDAP: Wrong username or password'); + try { + await ldap.validateAuthData({ id: 'testuser', password: '' }, options); + fail('Should have rejected empty password'); + } catch (err) { + expect(err.message).toBe('LDAP: Wrong username or password'); + } + expect(server.bindAttempts.length).toBe(0); + } finally { + await new Promise(resolve => server.close(resolve)); } - expect(server.bindAttempts.length).toBe(0); - await new Promise(resolve => server.close(resolve)); }); it('should reject missing authData.password', async () => { @@ -175,13 +181,16 @@ describe('LDAP Injection Prevention', () => { dn: 'uid={{id}}, o=example', }; try { - await ldap.validateAuthData({ id: 'testuser' }, options); - fail('Should have rejected missing password'); - } catch (err) { - expect(err.message).toBe('LDAP: Wrong username or password'); + try { + await ldap.validateAuthData({ id: 'testuser' }, options); + fail('Should have rejected missing password'); + } catch (err) { + expect(err.message).toBe('LDAP: Wrong username or password'); + } + expect(server.bindAttempts.length).toBe(0); + } finally { + await new Promise(resolve => server.close(resolve)); } - expect(server.bindAttempts.length).toBe(0); - await new Promise(resolve => server.close(resolve)); }); it('should reject null authData.password', async () => { @@ -194,13 +203,16 @@ describe('LDAP Injection Prevention', () => { dn: 'uid={{id}}, o=example', }; try { - await ldap.validateAuthData({ id: 'testuser', password: null }, options); - fail('Should have rejected null password'); - } catch (err) { - expect(err.message).toBe('LDAP: Wrong username or password'); + try { + await ldap.validateAuthData({ id: 'testuser', password: null }, options); + fail('Should have rejected null password'); + } catch (err) { + expect(err.message).toBe('LDAP: Wrong username or password'); + } + expect(server.bindAttempts.length).toBe(0); + } finally { + await new Promise(resolve => server.close(resolve)); } - expect(server.bindAttempts.length).toBe(0); - await new Promise(resolve => server.close(resolve)); }); it('should reject non-string authData.password', async () => { @@ -212,16 +224,19 @@ describe('LDAP Injection Prevention', () => { url: `ldap://localhost:${port}`, dn: 'uid={{id}}, o=example', }; - for (const password of [123, {}, [], true]) { - try { - await ldap.validateAuthData({ id: 'testuser', password }, options); - fail(`Should have rejected non-string password: ${JSON.stringify(password)}`); - } catch (err) { - expect(err.message).toBe('LDAP: Wrong username or password'); + try { + for (const password of [123, {}, [], true]) { + try { + await ldap.validateAuthData({ id: 'testuser', password }, options); + fail(`Should have rejected non-string password: ${JSON.stringify(password)}`); + } catch (err) { + expect(err.message).toBe('LDAP: Wrong username or password'); + } } + expect(server.bindAttempts.length).toBe(0); + } finally { + await new Promise(resolve => server.close(resolve)); } - expect(server.bindAttempts.length).toBe(0); - await new Promise(resolve => server.close(resolve)); }); });