Skip to main content

bitwarden_access_connector/integrations/active_directory/
session.rs

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
21/// The account's password. A search never returns it, and replacing it is an administrative reset,
22/// which needs the Reset Password right and an encrypted connection. See
23/// <https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-adts/6e803168-f140-4d23-b2d3-c3a8ab5917d2>.
24const UNICODE_PWD_ATTR: &[u8] = b"unicodePwd";
25
26/// The "no attributes" OID (RFC 4511 §4.5.1.8); the lookup only needs the DN.
27const 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
35/// An LDAPS connection to a domain controller. Every request on it uses the operation timeout.
36pub(super) struct LdapSession {
37    ldap: Ldap,
38    driver: JoinHandle<()>,
39    operation_timeout: Duration,
40}
41
42impl LdapSession {
43    /// Connects, binds as the delegated service account and resolves `account_identity` to a DN.
44    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        // Always set: ldap3's own root store drops every native root if any one fails to load.
68        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    /// Exactly one user must match, since rotating the wrong account is worse than not rotating.
85    /// Account state isn't checked: a disabled account rotates, and then fails verification.
86    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    /// The unbind is bounded too: unbounded, it waits forever on a DC that stopped reading.
132    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        // Closes the socket, so an early return can't leak it.
144        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
170/// AD answers every subtree search with continuation references to subordinate naming contexts,
171/// so a domain-root base would look ambiguous. References are dropped rather than chased, so the
172/// bind never goes to a server nobody named.
173fn 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
180/// The DN of a search result entry, or `None` if the entry is malformed. `SearchEntry::construct`
181/// panics on a malformed entry instead, which would end the process.
182fn 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
187/// The identity comes from the server, so it is escaped before it goes into the filter.
188fn 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
195/// RFC 4515 §3, plus control and non-ASCII bytes.
196fn 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
211/// Refuses anything beyond a host name, IPv6 literal and `:port` rather than normalising it, so a
212/// typo can't downgrade the connection or send the bind elsewhere.
213pub(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    // LDAP protocol-op tags, RFC 4511 §4.5.2 and §4.13.
236    const TAG_SEARCH_RESULT_ENTRY: u64 = 4;
237    const TAG_SEARCH_RESULT_REFERENCE: u64 = 19;
238    const TAG_INTERMEDIATE_RESPONSE: u64 = 25;
239
240    /// A well-formed entry: the DN followed by an empty attribute list.
241    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    /// What AD returns for a domain-root subtree search, whatever the filter matched.
281    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        // "é" is 0xC3 0xA9 in UTF-8.
405        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}