1 # Unix SMB/CIFS implementation.
3 # Copyright 2021 (C) Catalyst IT Ltd
5 # This program is free software; you can redistribute it and/or modify
6 # it under the terms of the GNU General Public License as published by
7 # the Free Software Foundation; either version 3 of the License, or
8 # (at your option) any later version.
10 # This program is distributed in the hope that it will be useful,
11 # but WITHOUT ANY WARRANTY; without even the implied warranty of
12 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 # GNU General Public License for more details.
15 # You should have received a copy of the GNU General Public License
16 # along with this program. If not, see <http://www.gnu.org/licenses/>.
21 from samba
.samdb
import SamDB
22 from samba
.auth
import system_session
24 from samba
.tests
.subunitrun
import SubunitOptions
, TestProgram
25 from samba
.tests
import TestCase
, ldb_err
26 from samba
.tests
import DynamicTestCase
27 import samba
.getopt
as options
29 from samba
.colour
import c_RED
, c_GREEN
, c_DARK_YELLOW
32 from samba
.dsdb
import (
33 UF_SERVER_TRUST_ACCOUNT
,
34 UF_TRUSTED_FOR_DELEGATION
,
38 # bad sAMAccountName characters from [MS-SAMR]
39 # "3.1.1.6 Attribute Constraints for Originating Updates"
40 BAD_SAM_CHARS
= (''.join(chr(x
) for x
in range(0, 32)) +
43 # 0x7f is *said* to be bad, but turns out to be fine.
44 ALLEGED_BAD_SAM_CHARS
= chr(127)
46 LATIN1_BAD_CHARS
= set([chr(x
) for x
in range(129, 160)] +
48 [chr(x
) for x
in range(0xc0, 0xc6)] +
49 [chr(x
) for x
in range(0xc7, 0xd7)] +
50 [chr(x
) for x
in range(0xd8, 0xde)] +
51 [chr(x
) for x
in range(0xe0, 0xe6)] +
52 [chr(x
) for x
in range(0xe7, 0xf7)] +
53 [chr(x
) for x
in range(0xf8, 0xfe)])
56 LATIN_EXTENDED_A_NO_CLASH
= {306, 307, 330, 331, 338, 339, 358, 359, 383}
58 #XXX does '\x00' just truncate the string though?
59 #XXX elsewhere we see "[\\\"|,/:<>+=;?*']" with "'"
63 # max length 1024 UTF-8 bytes, following "rfc822"
64 # for o365 sync https://docs.microsoft.com/en-us/microsoft-365/enterprise/prepare-for-directory-synchronization?view=o365-worldwide
65 # max length is 113 [64 before @] "@" [48 after @]
66 # invalid chars: '\\%&*+/=?{}|<>();:,[]"'
67 # allowed chars: A – Z, a - z, 0 – 9, ' . - _ ! # ^ ~
68 # "Letters with diacritical marks, such as umlauts, accents, and tildes, are invalid characters."
71 # "The username cannot end with a period (.), an ampersand (&), a space, or an at sign (@)."
74 # per RFC 822, «"a b" @ example.org» is
80 exists
= ldb
.ERR_ENTRY_ALREADY_EXISTS
83 if sys
.stdout
.isatty():
90 return SamDB(url
=f
"ldap://{SERVER}",
92 session_info
=system_session(),
98 s
= s
.format(realm
=REALM
.upper(),
100 other_realm
=(REALM
+ ".another.example.net"))
104 class LdapUpnSamTestBase(TestCase
):
105 """Make sure we can't add userPrincipalNames or sAMAccountNames that
111 def setUpDynamicTestCases(cls
):
112 if getattr(cls
, '_disabled', False):
114 for doc
, *rows
in cls
.cases
:
115 name
= re
.sub(r
'\W+', '_', doc
)
116 cls
.generate_dynamic_test("test_upn_sam", name
, rows
, doc
)
118 def setup_objects(self
, rows
):
119 objects
= set(r
[0] for r
in rows
)
122 objtype
, name
= name
.split(':', 1)
125 getattr(self
, f
'add_{objtype}')(name
)
126 self
.addCleanup(self
.remove_object
, name
)
128 def _test_upn_sam_with_args(self
, rows
, doc
):
129 self
.setup_objects(rows
)
132 for i
, row
in enumerate(rows
):
134 obj
, data
, expected
, op
= row
136 obj
, data
, expected
= row
137 op
= ldb
.FLAG_MOD_REPLACE
139 dn
, dnsname
= self
.objects
[obj
]
140 sam
, upn
= None, None
141 if isinstance(data
, dict):
142 sam
= data
.get('sam')
143 upn
= data
.get('upn')
144 elif isinstance(data
, str):
158 m
["userPrincipalName"] = format(upn
)
161 m
["sAMAccountName"] = format(sam
)
163 msg
= ldb
.Message
.from_dict(self
.samdb
, m
, op
)
167 self
.samdb
.modify(msg
)
168 except ldb
.LdbError
as e
:
169 print(f
"row {i+1} of '{cdoc}' failed as expected with "
172 self
.fail(f
"row {i+1} of '{cdoc}' should have failed:\n"
173 f
"{pprint.pformat(m)} on {obj}")
176 self
.samdb
.modify(msg
)
177 except ldb
.LdbError
as e
:
178 raise AssertionError(
179 f
"row {i+1} of '{cdoc}' failed with {ldb_err(e)}:\n"
180 f
"{pprint.pformat(m)} on {obj}") from None
181 elif expected
is report
:
183 self
.samdb
.modify(msg
)
184 print(f
"row {i+1} of '{cdoc}' SUCCEEDED:\n"
185 f
"{pprint.pformat(m)} on {obj}")
186 except ldb
.LdbError
as e
:
187 print(f
"row {i+1} of '{cdoc}' FAILED "
188 f
"with {ldb_err(e)}:\n"
189 f
"{pprint.pformat(m)} on {obj}")
193 self
.samdb
.modify(msg
)
194 except ldb
.LdbError
as e
:
195 if hasattr(expected
, '__contains__'):
196 if e
.args
[0] in expected
:
199 if e
.args
[0] == expected
:
202 self
.fail(f
"row {i+1} of '{cdoc}' "
203 f
"should have failed with {ldb_err(expected)} "
204 f
"but instead failed with {ldb_err(e)}:\n"
205 f
"{pprint.pformat(m)} on {obj}")
206 self
.fail(f
"row {i+1} of '{cdoc}' "
207 f
"should have failed with {ldb_err(expected)}:\n"
208 f
"{pprint.pformat(m)} on {obj}")
210 def add_dc(self
, name
):
211 dn
= f
"CN={name},OU=Domain Controllers,{self.base_dn}"
212 dnsname
= f
"{name}.{REALM}".lower()
215 "objectclass": "computer",
216 "userAccountControl": str(UF_SERVER_TRUST_ACCOUNT |
217 UF_TRUSTED_FOR_DELEGATION
),
218 "dnsHostName": dnsname
,
219 "carLicense": self
.id()
221 self
.objects
[name
] = (dn
, dnsname
)
223 def add_user(self
, name
):
224 dn
= f
"CN={name},{self.ou}"
228 "objectclass": "user",
229 "carLicense": self
.id()
231 self
.objects
[name
] = (dn
, None)
233 def remove_object(self
, name
):
234 dn
, dnsname
= self
.objects
.pop(name
)
235 self
.samdb
.delete(dn
)
239 self
.samdb
= get_samdb()
240 self
.base_dn
= self
.samdb
.get_default_basedn()
241 self
.short_id
= self
.id().rsplit('.', 1)[1][:63]
243 self
.ou
= f
"OU={ self.short_id },{ self.base_dn }"
244 self
.addCleanup(self
.samdb
.delete
, self
.ou
, ["tree_delete:1"])
245 self
.samdb
.add({"dn": self
.ou
, "objectclass": "organizationalUnit"})
249 class LdapUpnSamTest(LdapUpnSamTestBase
):
252 # ( «documentation/message that becomes test name»,
253 # («short object id», «upn or sam or mapping», «expected»),
254 # («short object id», «upn or sam or mapping», «expected»),
258 # where the first item is a one line string explaining the
259 # test, and subsequent items describe database modifications,
260 # to be applied in series.
262 # First is a short ID, which maps to an object DN. Second is
263 # either a string or a dictionary.
265 # * If a string, if it contains '@', it is a UPN, otherwise a
268 # * If a dictionary, it is a mapping of some of ['sam', 'upn']
269 # to strings (in this way, you can add two attributes in one
270 # mesage, or attempt a samaccountname with '@').
272 # expected can be «ok», «bad» (mapped to True and False,
273 # respectively), or a specific LDB error code, if that exact
274 # exception is wanted.
276 ('A', 'a@{realm}', ok
),
278 ("add the same upn to different objects",
279 ('A', 'a@{realm}', ok
),
280 ('B', 'a@{realm}', ldb
.ERR_CONSTRAINT_VIOLATION
),
281 ('B', 'a@{lrealm}', ldb
.ERR_CONSTRAINT_VIOLATION
), # lowercase realm
283 ("replace UPN with itself",
284 ('A', 'a@{realm}', ok
),
285 ('A', 'a@{realm}', ok
),
286 ('A', 'a@{lrealm}', ok
),
288 ("replace SAM with itself",
292 ("replace UPN realm",
293 ('A', 'a@{realm}', ok
),
294 ('A', 'a@{other_realm}', ok
),
296 ("matching SAM and UPN",
298 ('A', 'a@{realm}', ok
),
300 ("matching SAM and UPN, other realm",
302 ('A', 'a@{other_realm}', ok
),
304 ("matching SAM and UPN, single message",
305 ('A', {'sam': 'a', 'upn': 'a@{realm}'}, ok
),
306 ('A', {'sam': 'a', 'upn': 'a@{other_realm}'}, ok
),
308 ("different objects, different realms",
309 ('A', 'a@{realm}', ok
),
310 ('B', 'a@{other_realm}', ok
),
312 ("different objects, same UPN, different case",
313 ('A', 'a@{realm}', ok
),
314 ('B', 'A@{realm}', ldb
.ERR_CONSTRAINT_VIOLATION
),
316 ("different objects, SAM after UPN",
317 ('A', 'a@{realm}', ok
),
318 ('B', 'a', ldb
.ERR_CONSTRAINT_VIOLATION
),
320 ("different objects, SAM before UPN",
322 ('B', 'a@{realm}', exists
),
324 ("different objects, SAM account clash",
328 ("different objects, SAM account clash, different case",
333 ('A', {'sam': 'x', 'upn': 'y@{realm}'}, ok
),
334 # The sam account raises EXISTS while the UPN raises
335 # CONSTRAINT_VIOLATION. We don't really care in which order
336 # they are checked, so either error is ok.
337 ('B', {'sam': 'y', 'upn': 'x@{realm}'},
338 (exists
, ldb
.ERR_CONSTRAINT_VIOLATION
)),
340 ("two way clash, other realm",
341 ('A', {'sam': 'x', 'upn': 'y@{other_realm}'}, ok
),
342 ('B', {'sam': 'y', 'upn': 'x@{other_realm}'}, ok
),
344 # UPN versions of bad sam account names
345 ("UPN clash on other realm",
347 ('B', 'a@x.x', ldb
.ERR_CONSTRAINT_VIOLATION
),
349 ("UPN same but for trailing spaces",
350 ('A', 'a@{realm}', ok
),
351 ('B', 'a @{realm}', ok
),
355 ('A', {'upn': 'noat'}, ok
),
356 ('B', {'upn': 'noat'}, ldb
.ERR_CONSTRAINT_VIOLATION
),
357 ('C', {'upn': 'NOAT'}, ldb
.ERR_CONSTRAINT_VIOLATION
),
359 # UPN has non-ascii at, followed by real at.
360 ("UPN with non-ascii at vs real at",
361 ('A', {'upn': 'smallat﹫{realm}'}, ok
),
362 ('B', {'upn': 'smallat@{realm}'}, ok
),
363 ('C', {'upn': 'tagat\U000e0040{realm}'}, ok
),
364 ('D', {'upn': 'tagat@{realm}'}, ok
),
366 ("UPN with unicode at vs real at, real at first",
367 ('B', {'upn': 'smallat@{realm}'}, ok
),
368 ('A', {'upn': 'smallat﹫{realm}'}, ok
),
369 ('D', {'upn': 'tagat@{realm}'}, ok
),
370 ('C', {'upn': 'tagat\U000e0040{realm}'}, ok
),
372 ("UPN username too long",
373 # SPN soft limit 20; hard limit 256, overall UPN 1024
374 ('A', 'a' * 25 + '@b.c', ok
),
375 ('A', 'a' * 65 + '@b.c', ok
), # Azure AD limit is 64
376 ('A', 'a' * 257 + '@b.c', ok
), # 256 is sam account name limit
378 ("sam account name 20 long",
382 ("UPN has two at signs",
383 ('A', 'a@{realm}', ok
),
384 ('A', 'a@{realm}@{realm}', ok
),
386 ('A', 'a@a@a.b', ok
),
388 ("SAM has at signs clashing upn second, non-realm",
389 ('A', {'sam': 'a@a.b'}, ok
),
390 ('B', 'a@a.b@a.b', ok
), # UPN won't clash with SAM, because realm
392 ("SAM has at signs clashing upn second",
393 ('A', {'sam': 'a@{realm}'}, ok
),
394 ('B', 'a@{realm}@{realm}', bad
), # UPN would clashes with SAM
396 ("SAM has at signs clashing upn first",
397 ('B', 'a@{realm}@{realm}', ok
),
398 ('A', {'sam': 'a@{realm}'}, bad
),
401 ('A', 'a name @ {realm}', ok
),
402 ('B', 'a name @ {realm}', ldb
.ERR_CONSTRAINT_VIOLATION
),
403 ('B', 'a name @{realm}', ok
), # because realm looks different
404 ('C', 'a name@{realm}', ok
),
405 ('D', 'a name', ldb
.ERR_CONSTRAINT_VIOLATION
),
406 ('D', 'a name ', (exists
, ldb
.ERR_CONSTRAINT_VIOLATION
)), # matches B
408 ("SAM starts with at",
409 ('A', {'sam': '@{realm}'}, ok
),
410 ('B', {'sam': '@a'}, ok
),
411 ('C', {'sam': '@{realm}'}, exists
),
412 ('C', {'sam': '@a'}, exists
),
413 ('C', {'upn': '@{realm}@{realm}'}, bad
),
414 ('C', {'upn': '@a@{realm}'}, bad
),
416 ("UPN starts with at",
417 ('A', {'upn': '@{realm}'}, ok
),
418 ('B', {'upn': '@a@{realm}'}, ok
),
419 ('C', {'upn': '@{realm}'}, bad
),
420 ('C', {'sam': '@a'}, bad
),
423 ('A', {'sam': '{realm}@'}, ok
),
424 ('B', {'sam': 'a@'}, ok
),
425 ('C', {'sam': '{realm}@'}, exists
),
426 ('C', {'sam': 'a@'}, exists
),
427 ('C', {'upn': 'a@@{realm}'}, bad
),
428 ('C', {'upn': '{realm}@@{realm}'}, bad
),
431 ('A', {'upn': '{realm}@'}, ok
),
432 ('B', {'upn': '@a@{realm}@'}, ok
),
433 ('C', {'upn': '{realm}@'}, bad
),
434 ('C', {'sam': '@a@{realm}'}, ok
), # not like B, because other realm
440 class LdapUpnSamSambaOnlyTest(LdapUpnSamTestBase
):
441 # We don't run these ones outside of selftest, where we are
442 # probably testing against Windows and these are known failures.
443 _disabled
= 'SAMBA_SELFTEST' not in os
.environ
445 ("sam account name too long",
450 ('A', 'a' * 255, ok
),
451 ('A', 'a' * 256, ok
),
452 ('A', 'a' * 257, ldb
.ERR_INVALID_ATTRIBUTE_SYNTAX
),
454 ("UPN username too long",
455 ('A', 'a' * 254 + '@' + 'b.c' * 257,
456 ldb
.ERR_INVALID_ATTRIBUTE_SYNTAX
), # 1024 is alleged UPN limit
458 ("UPN same but for internal spaces",
459 ('A', 'a b@x.x', ok
),
460 ('B', 'a b@x.x', ldb
.ERR_CONSTRAINT_VIOLATION
),
462 ("SAM contains delete",
463 # forbidden according to documentation, but works in practice on Windows
464 ('A', 'a\x7f', ldb
.ERR_CONSTRAINT_VIOLATION
),
465 ('A', 'a\x7f'.encode(), ldb
.ERR_CONSTRAINT_VIOLATION
),
466 ('A', 'a\x7fb', ldb
.ERR_CONSTRAINT_VIOLATION
),
467 ('A', 'a\x7fb'.encode(), ldb
.ERR_CONSTRAINT_VIOLATION
),
468 ('A', '\x7fb', ldb
.ERR_CONSTRAINT_VIOLATION
),
469 ('A', '\x7fb'.encode(), ldb
.ERR_CONSTRAINT_VIOLATION
),
471 # The wide at symbol ('@' U+FF20) does not count as '@' for Samba
472 # so it will look like a string with no @s.
473 ("UPN with unicode wide at vs real at",
474 ('A', {'upn': 'wideat@{realm}'}, ok
),
475 ('B', {'upn': 'wideat@{realm}'}, ok
),
477 ("UPN with real at vs wide at",
478 ('B', {'upn': 'wideat@{realm}'}, ok
),
479 ('A', {'upn': 'wideat@{realm}'}, ok
)
485 global LP
, CREDS
, SERVER
, REALM
487 parser
= optparse
.OptionParser(
488 "python3 ldap_upn_sam_account_name.py <server> [options]")
489 sambaopts
= options
.SambaOptions(parser
)
490 parser
.add_option_group(sambaopts
)
492 # use command line creds if available
493 credopts
= options
.CredentialsOptions(parser
)
494 parser
.add_option_group(credopts
)
495 subunitopts
= SubunitOptions(parser
)
496 parser
.add_option_group(subunitopts
)
498 opts
, args
= parser
.parse_args()
503 LP
= sambaopts
.get_loadparm()
504 CREDS
= credopts
.get_credentials(LP
)
506 REALM
= CREDS
.get_realm()
508 TestProgram(module
=__name__
, opts
=subunitopts
)