1use std::{collections::HashSet, time::Duration};
2
3use async_trait::async_trait;
4use bitwarden_pam::RotationFailureCode;
5use ldap3::{
6 Ldap, LdapConnAsync, LdapConnSettings, LdapError, Mod, ResultEntry, Scope, result::LdapResult,
7};
8use tokio::task::JoinHandle;
9
10use super::{
11 Credentials,
12 classify::{Phase, classify_ldap_error, target_rejected},
13 password_write::PasswordWriter,
14 tls::tls_config,
15};
16use crate::{
17 error::{Retry, SafeDetail},
18 integrations::{IntegrationError, TargetEffect},
19};
20
21const UNICODE_PWD_ATTR: &[u8] = b"unicodePwd";
25
26const NO_ATTRS: [&str; 1] = ["1.1"];
28
29#[derive(Debug, Clone, Copy)]
30pub(super) struct Timeouts {
31 pub(super) connect: Duration,
32 pub(super) operation: Duration,
33}
34
35pub(super) struct LdapSession {
37 ldap: Ldap,
38 driver: JoinHandle<()>,
39 operation_timeout: Duration,
40}
41
42impl LdapSession {
43 pub(super) async fn open_and_locate(
45 creds: &Credentials<'_>,
46 account_identity: &str,
47 timeouts: Timeouts,
48 phase: Phase,
49 ) -> Result<(Self, String), IntegrationError> {
50 let mut session = Self::connect(&creds.url, creds.ca_certificate, timeouts, phase).await?;
51 session
52 .simple_bind(creds.bind_dn, creds.bind_password)
53 .await
54 .map_err(|e| classify_ldap_error(&e, phase))?;
55 let dn = session
56 .find_account_dn(creds.base_dn, account_identity, phase)
57 .await?;
58 Ok((session, dn))
59 }
60
61 async fn connect(
62 url: &str,
63 ca_certificate: Option<&str>,
64 timeouts: Timeouts,
65 phase: Phase,
66 ) -> Result<Self, IntegrationError> {
67 let settings = LdapConnSettings::new()
69 .set_conn_timeout(timeouts.connect)
70 .set_config(tls_config(ca_certificate, phase)?);
71 let (conn, ldap) = LdapConnAsync::with_settings(settings, url)
72 .await
73 .map_err(|e| classify_ldap_error(&e, phase))?;
74 let driver = tokio::spawn(async move {
75 let _ = conn.drive().await;
76 });
77 Ok(Self {
78 ldap,
79 driver,
80 operation_timeout: timeouts.operation,
81 })
82 }
83
84 async fn find_account_dn(
87 &mut self,
88 base_dn: &str,
89 account_identity: &str,
90 phase: Phase,
91 ) -> Result<String, IntegrationError> {
92 let search = self
93 .ldap
94 .with_timeout(self.operation_timeout)
95 .search(
96 base_dn,
97 Scope::Subtree,
98 &account_filter(account_identity),
99 NO_ATTRS,
100 )
101 .await
102 .map_err(|e| classify_ldap_error(&e, phase))?;
103 let (results, _result) = search
104 .success()
105 .map_err(|e| classify_ldap_error(&e, phase))?;
106 let mut entries = search_entries_only(results);
107
108 if entries.len() > 1 {
109 return Err(target_rejected(phase.settled(), "AmbiguousAccountIdentity"));
110 }
111 let Some(entry) = entries.pop() else {
112 return Err(target_rejected(phase.settled(), "AccountNotFound"));
113 };
114 let dn = entry_dn(entry)
115 .ok_or_else(|| target_rejected(phase.settled(), "MalformedSearchEntry"))?;
116 if dn.is_empty() {
117 return Err(target_rejected(phase.settled(), "AccountWithoutDn"));
118 }
119 Ok(dn)
120 }
121
122 pub(super) async fn simple_bind(&mut self, dn: &str, password: &str) -> Result<(), LdapError> {
123 self.ldap
124 .with_timeout(self.operation_timeout)
125 .simple_bind(dn, password)
126 .await?
127 .success()?;
128 Ok(())
129 }
130
131 pub(super) async fn close(mut self) {
133 let _ = self
134 .ldap
135 .with_timeout(self.operation_timeout)
136 .unbind()
137 .await;
138 }
139}
140
141impl Drop for LdapSession {
142 fn drop(&mut self) {
143 self.driver.abort();
145 }
146}
147
148#[async_trait]
149impl PasswordWriter for LdapSession {
150 async fn replace_password(
151 &mut self,
152 account_dn: &str,
153 encoded: &[u8],
154 ) -> Result<LdapResult, LdapError> {
155 let mods = vec![Mod::Replace(
156 UNICODE_PWD_ATTR.to_vec(),
157 HashSet::from([encoded.to_vec()]),
158 )];
159 self.ldap
160 .with_timeout(self.operation_timeout)
161 .modify(account_dn, mods)
162 .await
163 }
164
165 async fn bind_as_account(&mut self, account_dn: &str, password: &str) -> Result<(), LdapError> {
166 self.simple_bind(account_dn, password).await
167 }
168}
169
170fn search_entries_only(results: Vec<ResultEntry>) -> Vec<ResultEntry> {
174 results
175 .into_iter()
176 .filter(|entry| !entry.is_ref() && !entry.is_intermediate())
177 .collect()
178}
179
180fn entry_dn(entry: ResultEntry) -> Option<String> {
183 let mut elements = entry.0.match_id(4)?.expect_constructed()?.into_iter();
184 String::from_utf8(elements.next()?.expect_primitive()?).ok()
185}
186
187fn account_filter(account_identity: &str) -> String {
189 let escaped = escape_filter_value(account_identity);
190 format!(
191 "(&(objectClass=user)(objectCategory=person)(|(sAMAccountName={escaped})(userPrincipalName={escaped})))"
192 )
193}
194
195fn escape_filter_value(value: &str) -> String {
197 let mut out = String::with_capacity(value.len());
198 for byte in value.bytes() {
199 match byte {
200 b'*' => out.push_str("\\2a"),
201 b'(' => out.push_str("\\28"),
202 b')' => out.push_str("\\29"),
203 b'\\' => out.push_str("\\5c"),
204 b if !(0x20..0x7f).contains(&b) => out.push_str(&format!("\\{b:02x}")),
205 b => out.push(b as char),
206 }
207 }
208 out
209}
210
211pub(super) fn ldaps_url(host: &str) -> Result<String, IntegrationError> {
214 let acceptable = !host.is_empty()
215 && host.bytes().all(|b| {
216 b.is_ascii_alphanumeric() || matches!(b, b'.' | b'-' | b':' | b'[' | b']' | b'_')
217 });
218 if !acceptable {
219 return Err(IntegrationError::new(
220 Retry::Permanent,
221 TargetEffect::NotApplied,
222 RotationFailureCode::CredentialsUnresolved,
223 SafeDetail::from_kind("MalformedLdapHost"),
224 ));
225 }
226 Ok(format!("ldaps://{host}"))
227}
228
229#[cfg(test)]
230mod tests {
231 use ldap3::asn1::{PL, StructureTag, TagClass};
232
233 use super::*;
234
235 const TAG_SEARCH_RESULT_ENTRY: u64 = 4;
237 const TAG_SEARCH_RESULT_REFERENCE: u64 = 19;
238 const TAG_INTERMEDIATE_RESPONSE: u64 = 25;
239
240 fn entry_pdu(dn: &str) -> ResultEntry {
242 ResultEntry::new(StructureTag {
243 class: TagClass::Application,
244 id: TAG_SEARCH_RESULT_ENTRY,
245 payload: PL::C(vec![
246 StructureTag {
247 class: TagClass::Universal,
248 id: 4,
249 payload: PL::P(dn.as_bytes().to_vec()),
250 },
251 StructureTag {
252 class: TagClass::Universal,
253 id: 16,
254 payload: PL::C(Vec::new()),
255 },
256 ]),
257 })
258 }
259
260 fn reference_pdu(uri: &str) -> ResultEntry {
261 ResultEntry::new(StructureTag {
262 class: TagClass::Application,
263 id: TAG_SEARCH_RESULT_REFERENCE,
264 payload: PL::C(vec![StructureTag {
265 class: TagClass::Universal,
266 id: 4,
267 payload: PL::P(uri.as_bytes().to_vec()),
268 }]),
269 })
270 }
271
272 fn intermediate_pdu() -> ResultEntry {
273 ResultEntry::new(StructureTag {
274 class: TagClass::Application,
275 id: TAG_INTERMEDIATE_RESPONSE,
276 payload: PL::C(Vec::new()),
277 })
278 }
279
280 fn domain_root_references() -> Vec<ResultEntry> {
282 vec![
283 reference_pdu("ldap://corp.example/DC=DomainDnsZones,DC=corp,DC=example"),
284 reference_pdu("ldap://corp.example/DC=ForestDnsZones,DC=corp,DC=example"),
285 ]
286 }
287
288 #[test]
289 fn one_match_under_a_domain_root_base_is_not_ambiguous() {
290 let mut results = domain_root_references();
291 results.insert(
292 1,
293 entry_pdu("CN=svc-app,OU=Service Accounts,DC=corp,DC=example"),
294 );
295
296 let entries = search_entries_only(results);
297
298 assert_eq!(
299 entries.len(),
300 1,
301 "continuation references must not count towards the match count"
302 );
303 assert_eq!(
304 entry_dn(entries.into_iter().next().expect("one entry")).as_deref(),
305 Some("CN=svc-app,OU=Service Accounts,DC=corp,DC=example")
306 );
307 }
308
309 #[test]
310 fn a_malformed_entry_has_no_dn_instead_of_panicking() {
311 let tag = |id, payload| StructureTag {
312 class: TagClass::Application,
313 id,
314 payload,
315 };
316 let octets = |bytes: &[u8]| StructureTag {
317 class: TagClass::Universal,
318 id: 4,
319 payload: PL::P(bytes.to_vec()),
320 };
321 let malformed = [
322 (
323 "not an entry",
324 tag(TAG_SEARCH_RESULT_REFERENCE, PL::C(vec![octets(b"CN=x")])),
325 ),
326 (
327 "primitive entry",
328 tag(TAG_SEARCH_RESULT_ENTRY, PL::P(b"CN=x".to_vec())),
329 ),
330 (
331 "no elements",
332 tag(TAG_SEARCH_RESULT_ENTRY, PL::C(Vec::new())),
333 ),
334 (
335 "constructed DN",
336 tag(
337 TAG_SEARCH_RESULT_ENTRY,
338 PL::C(vec![tag(4, PL::C(Vec::new()))]),
339 ),
340 ),
341 (
342 "DN not UTF-8",
343 tag(TAG_SEARCH_RESULT_ENTRY, PL::C(vec![octets(&[0xff, 0xfe])])),
344 ),
345 ];
346
347 for (case, pdu) in malformed {
348 assert_eq!(entry_dn(ResultEntry::new(pdu)), None, "{case}");
349 }
350 }
351
352 #[test]
353 fn no_match_under_a_domain_root_base_leaves_nothing_to_construct() {
354 let entries = search_entries_only(domain_root_references());
355
356 assert!(
357 entries.is_empty(),
358 "references alone must resolve to AccountNotFound, never be constructed as an entry"
359 );
360 }
361
362 #[test]
363 fn intermediate_responses_are_dropped_too() {
364 let results = vec![
365 intermediate_pdu(),
366 entry_pdu("CN=svc-app,DC=corp,DC=example"),
367 ];
368
369 assert_eq!(search_entries_only(results).len(), 1);
370 }
371
372 #[test]
373 fn two_real_matches_are_still_ambiguous() {
374 let mut results = domain_root_references();
375 results.push(entry_pdu("CN=svc-app,OU=A,DC=corp,DC=example"));
376 results.push(entry_pdu("CN=svc-app,OU=B,DC=corp,DC=example"));
377
378 assert_eq!(
379 search_entries_only(results).len(),
380 2,
381 "filtering references must not mask a genuinely ambiguous identity"
382 );
383 }
384
385 #[test]
386 fn escape_filter_value_escapes_rfc4515_specials() {
387 assert_eq!(escape_filter_value("a*b"), "a\\2ab");
388 assert_eq!(escape_filter_value("a(b"), "a\\28b");
389 assert_eq!(escape_filter_value("a)b"), "a\\29b");
390 assert_eq!(escape_filter_value("a\\b"), "a\\5cb");
391 assert_eq!(escape_filter_value("a\0b"), "a\\00b");
392 }
393
394 #[test]
395 fn escape_filter_value_leaves_ordinary_identities_alone() {
396 assert_eq!(
397 escape_filter_value("[email protected]"),
398 "[email protected]"
399 );
400 }
401
402 #[test]
403 fn escape_filter_value_escapes_non_ascii_bytes() {
404 assert_eq!(escape_filter_value("é"), "\\c3\\a9");
406 }
407
408 #[test]
409 fn account_filter_neutralises_hostile_identity() {
410 let filter = account_filter("*)(objectClass=*");
411 assert!(
412 !filter.contains("*)("),
413 "filter structure must not be injectable: {filter}"
414 );
415 assert!(filter.contains("\\2a\\29\\28objectClass=\\2a"));
416 assert!(filter.starts_with("(&(objectClass=user)"));
417 }
418
419 #[test]
420 fn account_filter_matches_sam_and_upn() {
421 let filter = account_filter("svc-app");
422 assert!(filter.contains("(sAMAccountName=svc-app)"));
423 assert!(filter.contains("(userPrincipalName=svc-app)"));
424 }
425
426 #[test]
427 fn ldaps_url_accepts_hostname() {
428 assert_eq!(
429 ldaps_url("dc01.corp.example").expect("valid host"),
430 "ldaps://dc01.corp.example"
431 );
432 }
433
434 #[test]
435 fn ldaps_url_accepts_explicit_port() {
436 assert_eq!(
437 ldaps_url("dc01.corp.example:3269").expect("valid host"),
438 "ldaps://dc01.corp.example:3269"
439 );
440 }
441
442 #[test]
443 fn ldaps_url_is_always_ldaps() {
444 for host in ["dc01.corp.example", "10.0.0.5", "[fe80::1]:636"] {
445 let url = ldaps_url(host).expect("valid host");
446 assert!(url.starts_with("ldaps://"), "must be LDAPS: {url}");
447 }
448 }
449
450 #[test]
451 fn ldaps_url_rejects_hostile_hosts() {
452 for host in [
453 "",
454 "ldap://dc01.corp.example",
455 "dc01.corp.example/path",
456 "[email protected]",
457 "dc01.corp.example?query",
458 "dc01 corp example",
459 "dc01.corp.example#frag",
460 ] {
461 match ldaps_url(host) {
462 Ok(url) => panic!("host {host:?} must be rejected, produced {url}"),
463 Err(err) => {
464 assert_eq!(err.retry, Retry::Permanent, "host {host:?}");
465 assert_eq!(
466 err.code,
467 RotationFailureCode::CredentialsUnresolved,
468 "host {host:?}"
469 );
470 }
471 }
472 }
473 }
474}