Skip to content

Commit e7a06af

Browse files
author
tchapi
committed
username
1 parent 8c40fe4 commit e7a06af

14 files changed

Lines changed: 318 additions & 35 deletions

File tree

src/Controller/Api/ApiController.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
use App\Entity\CalendarSubscription;
88
use App\Entity\Principal;
99
use App\Entity\User;
10+
use App\Services\Utils;
1011
use Doctrine\Persistence\ManagerRegistry;
1112
use Sabre\DAV\Sharing\Plugin as SharingPlugin;
1213
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
@@ -26,7 +27,7 @@ class ApiController extends AbstractController
2627
*/
2728
private function validateUsername(string $username): bool
2829
{
29-
return !empty($username) && is_string($username) && !preg_match('/[^a-zA-Z0-9_.@-]/', $username);
30+
return Utils::isValidUsername($username);
3031
}
3132

3233
/**

src/Entity/User.php

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,18 @@ class User
1818
#[ORM\Column(type: 'integer')]
1919
private $id;
2020

21+
/**
22+
* A username ends up in the principal URI (`principals/<username>`), so it must not carry
23+
* anything that would change that path's structure. Letters, digits and `_ . @ + ' -` are allowed:
24+
* the punctuation is what shows up in mail-derived login names. Enforced when a user is created; existing
25+
* accounts are left alone so that an odd username created before this rule stays editable.
26+
*/
27+
public const USERNAME_PATTERN = '/^[a-zA-Z0-9_.@+\'-]+$/';
28+
2129
#[ORM\Column(type: 'string', length: 255, unique: true)]
2230
#[Assert\NotBlank]
31+
#[Assert\Length(max: 255, groups: ['creation'])]
32+
#[Assert\Regex(pattern: self::USERNAME_PATTERN, message: 'form.username.invalid', groups: ['creation'])]
2333
private $username;
2434

2535
#[ORM\Column(name: 'digesta1', type: 'string', length: 255)]

src/Form/UserType.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
1212
use Symfony\Component\Form\Extension\Core\Type\TextType;
1313
use Symfony\Component\Form\FormBuilderInterface;
14+
use Symfony\Component\Form\FormInterface;
1415
use Symfony\Component\OptionsResolver\OptionsResolver;
1516

1617
class UserType extends AbstractType
@@ -55,6 +56,11 @@ public function configureOptions(OptionsResolver $resolver): void
5556
$resolver->setDefaults([
5657
'new' => false,
5758
'data_class' => User::class,
59+
// The username rule only applies to new accounts: the field is disabled when editing,
60+
// and an account created before the rule (or by LDAP/IMAP) must stay editable.
61+
'validation_groups' => static fn (FormInterface $form): array => $form->getConfig()->getOption('new')
62+
? ['Default', 'creation']
63+
: ['Default'],
5864
]);
5965
}
6066
}

src/Services/AbstractAuth.php

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,13 @@
1212
* and an empty password means an *unauthenticated bind* for LDAP servers, which Active
1313
* Directory (and OpenLDAP with `allow bind_anon_cred`) answers with success, i.e. it
1414
* would log the caller in as any user.
15+
*
16+
* It also rejects usernames that would not survive being put in a principal URI. sabre
17+
* derives the principal from the login name (`principals/<username>`), so a name containing
18+
* a slash would address a different, possibly existing, node: `alice/calendar-proxy-write`
19+
* is exactly the URI Davis uses for alice's delegation proxy. Only structural characters are
20+
* refused here, not the stricter set required when creating an account, so that an unusual
21+
* but working username keeps authenticating.
1522
*/
1623
abstract class AbstractAuth extends AbstractBasic
1724
{
@@ -25,6 +32,13 @@ final protected function validateUserPass($username, $password): bool
2532
return false;
2633
}
2734

35+
// Anything that would change the shape of `principals/<username>`:
36+
// [/\\] a forward or back slash, which would add a path segment
37+
// [\x00-\x20\x7f] any control character, plus space (0x20) and DEL (0x7f)
38+
if (1 === preg_match('~[/\\\\]|[\\x00-\\x20\\x7f]~', $username)) {
39+
return false;
40+
}
41+
2842
return $this->checkCredentials($username, $password);
2943
}
3044

src/Services/IMAPAuth.php

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -130,15 +130,17 @@ protected function imapOpen(string $username, string $password): bool
130130
$user = $this->doctrine->getRepository(User::class)->findOneBy(['username' => $username]);
131131

132132
if (!$user) {
133-
// We only have a username, so we use it for displayname and email
134-
$this->utils->createPasswordlessUserWithDefaultObjects($username, $username, $username);
135-
136-
$em = $this->doctrine->getManager();
137-
138133
try {
139-
$em->flush();
140-
} catch (\Exception $e) {
141-
error_log('IMAP Error (flush): '.$e->getMessage());
134+
// We only have a username, so we use it for displayname and email
135+
$this->utils->createPasswordlessUserWithDefaultObjects($username, $username, $username);
136+
$this->doctrine->getManager()->flush();
137+
} catch (\Throwable $e) {
138+
// Letting the login through without a principal would leave the account
139+
// authenticated but unusable: no calendar home, so clients fall back to the
140+
// server root and every write is refused.
141+
error_log('IMAP Error (could not create the user "'.$username.'"): '.$e->getMessage());
142+
143+
return false;
142144
}
143145
}
144146
}

src/Services/LDAPAuth.php

Lines changed: 43 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,39 @@ public function __construct(ManagerRegistry $doctrine, Utils $utils, string $LDA
8282
$this->utils = $utils;
8383
}
8484

85+
/**
86+
* Builds the bind DN for a username by filling the placeholders of LDAP_DN_PATTERN.
87+
*
88+
* Every substituted value is escaped for a DN context: without that, a username such as
89+
* `someone,ou=admins` would not be a value inside the DN but extra structure, changing
90+
* which entry we bind against.
91+
*/
92+
protected function buildDn(string $username): string
93+
{
94+
$escape = static fn (string $value): string => ldap_escape($value, '', LDAP_ESCAPE_DN);
95+
96+
// Extract user and domain from username (in the form user@domain.org)
97+
$user_parts = explode('@', $username, 2);
98+
99+
$ldap_user = $user_parts[0];
100+
$ldap_domain = $user_parts[1] ?? '';
101+
102+
// Replace common placeholders
103+
$dn = str_replace(
104+
['%u', '%U', '%d'],
105+
[$escape($username), $escape($ldap_user), $escape($ldap_domain)],
106+
$this->LDAPDnPattern
107+
);
108+
109+
// Replace domain parts
110+
$domain_split = array_reverse(explode('.', $ldap_domain));
111+
for ($i = 1; $i <= count($domain_split) and $i <= 9; ++$i) {
112+
$dn = str_replace('%'.$i, $escape($domain_split[$i - 1]), $dn);
113+
}
114+
115+
return $dn;
116+
}
117+
85118
/**
86119
* Connects to an LDAP server and tries to authenticate.
87120
*
@@ -140,25 +173,7 @@ protected function ldapOpen($username, $password)
140173
return false;
141174
}
142175

143-
// Extract user and domain from username (in the form user@domain.org)
144-
$user_parts = explode('@', $username, 2);
145-
146-
$ldap_user = $user_parts[0];
147-
148-
if (count($user_parts) > 1) {
149-
$ldap_domain = $user_parts[1];
150-
} else {
151-
$ldap_domain = '';
152-
}
153-
154-
// Replace common placeholders
155-
$dn = str_replace(['%u', '%U', '%d'], [$username, $ldap_user, $ldap_domain], $this->LDAPDnPattern);
156-
157-
// Replace domain parts
158-
$domain_split = array_reverse(explode('.', $ldap_domain));
159-
for ($i = 1; $i <= count($domain_split) and $i <= 9; ++$i) {
160-
$dn = str_replace('%'.$i, $domain_split[$i - 1], $dn);
161-
}
176+
$dn = $this->buildDn($username);
162177

163178
$success = false;
164179
try {
@@ -200,14 +215,16 @@ protected function ldapOpen($username, $password)
200215
}
201216
}
202217

203-
$this->utils->createPasswordlessUserWithDefaultObjects($username, $displayName, $email);
204-
205-
$em = $this->doctrine->getManager();
206-
207218
try {
208-
$em->flush();
209-
} catch (\Exception $e) {
210-
error_log('LDAP Error (flush): '.$e->getMessage());
219+
$this->utils->createPasswordlessUserWithDefaultObjects($username, $displayName, $email);
220+
$this->doctrine->getManager()->flush();
221+
} catch (\Throwable $e) {
222+
// Letting the login through without a principal would leave the account
223+
// authenticated but unusable: no calendar home, so clients fall back to the
224+
// server root and every write is refused.
225+
error_log('LDAP Error (could not create the user "'.$username.'"): '.$e->getMessage());
226+
227+
$success = false;
211228
}
212229
}
213230
}

src/Services/Utils.php

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,8 +49,20 @@ public function hashPassword(string $username, string $password): string
4949
return md5($username.':'.$this->authRealm.':'.$password);
5050
}
5151

52+
/**
53+
* A username is only acceptable if it can be used verbatim in a principal URI.
54+
*/
55+
public static function isValidUsername(?string $username): bool
56+
{
57+
return null !== $username && '' !== $username && 1 === preg_match(User::USERNAME_PATTERN, $username);
58+
}
59+
5260
public function createPasswordlessUserWithDefaultObjects(string $username, string $displayName, string $email)
5361
{
62+
if (!self::isValidUsername($username)) {
63+
throw new \InvalidArgumentException(sprintf('Refusing to create the user "%s": a username may only contain letters, digits and the characters _ . @ + \' -', $username));
64+
}
65+
5466
$user = new User();
5567
$user->setUsername($username);
5668

tests/Functional/Controllers/UserControllerTest.php

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,4 +228,29 @@ public function testDelegateRemoveThroughAnotherUsersProxyIs404(): void
228228
$this->postAdmin($client, '/users/delegates/'.$userId.'/remove/'.$foreignProxy->getId().'/'.$delegate->getId());
229229
$this->assertResponseStatusCodeSame(404);
230230
}
231+
232+
public function testUserCreationRejectsAUsernameThatBreaksThePrincipalUri(): void
233+
{
234+
$user = new AdminUser('admin', 'test');
235+
236+
$client = static::createClient();
237+
$client->loginUser($user);
238+
239+
$crawler = $client->request('GET', '/users/new');
240+
$form = $crawler->selectButton('user_save')->form();
241+
242+
$client->submit($form, [
243+
'user[username]' => 'bad/user',
244+
'user[displayName]' => 'Bad User',
245+
'user[email]' => 'bad@example.org',
246+
'user[password][first]' => 'secret',
247+
'user[password][second]' => 'secret',
248+
]);
249+
250+
// The form is re-rendered rather than redirecting, and nothing is created
251+
$this->assertResponseIsSuccessful();
252+
$this->assertNull(
253+
static::getContainer()->get('doctrine.orm.entity_manager')->getRepository(User::class)->findOneByUsername('bad/user')
254+
);
255+
}
231256
}

tests/Functional/Service/AuthBackendTest.php

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,4 +103,31 @@ public function testBasicAuthStillAcceptsValidCredentials(): void
103103
[$ok] = self::check($backend, 'test_user:wrong');
104104
$this->assertFalse($ok);
105105
}
106+
107+
/**
108+
* A username becomes the principal URI (`principals/<username>`), so one containing a
109+
* slash would address a different node — `alice/calendar-proxy-write` is exactly the URI
110+
* Davis uses for alice's delegation proxy.
111+
*/
112+
public function testUsernamesThatWouldBreakThePrincipalUriAreRejected(): void
113+
{
114+
foreach (['alice/calendar-proxy-write', 'alice\\bob', 'alice bob', "alice\tbob", "alice\nbob"] as $username) {
115+
$backend = self::acceptAllBackend();
116+
117+
[$ok] = self::check($backend, $username.':password');
118+
119+
$this->assertFalse($ok, sprintf('%s must not authenticate', var_export($username, true)));
120+
$this->assertSame([], $backend->seen, 'The backend must not even be consulted');
121+
}
122+
}
123+
124+
public function testAnUnusualButStructurallySoundUsernameStillAuthenticates(): void
125+
{
126+
$backend = self::acceptAllBackend();
127+
128+
[$ok, $principal] = self::check($backend, 'first.last+tag@example.org:password');
129+
130+
$this->assertTrue($ok);
131+
$this->assertSame('principals/first.last+tag@example.org', $principal);
132+
}
106133
}
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace App\Tests\Services;
6+
7+
use App\Services\LDAPAuth;
8+
use App\Services\Utils;
9+
use Doctrine\Persistence\ManagerRegistry;
10+
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
11+
12+
class LDAPAuthTest extends KernelTestCase
13+
{
14+
private function buildDn(string $pattern, string $username): string
15+
{
16+
self::bootKernel();
17+
$container = static::getContainer();
18+
19+
$backend = new LDAPAuth(
20+
$container->get(ManagerRegistry::class),
21+
$container->get(Utils::class),
22+
'ldap://127.0.0.1',
23+
$pattern,
24+
'mail',
25+
false,
26+
'try'
27+
);
28+
29+
// buildDn is protected; no setAccessible() needed since PHP 8.1
30+
return (new \ReflectionMethod($backend, 'buildDn'))->invoke($backend, $username);
31+
}
32+
33+
public function testPlaceholdersAreFilledIn(): void
34+
{
35+
$this->assertSame(
36+
'uid=alice,ou=users,dc=example,dc=com',
37+
$this->buildDn('uid=%u,ou=users,dc=example,dc=com', 'alice')
38+
);
39+
}
40+
41+
public function testUserAndDomainPartsAreSplitOnTheAtSign(): void
42+
{
43+
$this->assertSame(
44+
'uid=alice,dc=example.org',
45+
$this->buildDn('uid=%U,dc=%d', 'alice@example.org')
46+
);
47+
}
48+
49+
public function testDomainComponentsAreAvailableInReverseOrder(): void
50+
{
51+
$this->assertSame(
52+
'uid=alice,dc=example,dc=org',
53+
$this->buildDn('uid=%U,dc=%2,dc=%1', 'alice@example.org')
54+
);
55+
}
56+
57+
/**
58+
* Regression test: the username was interpolated into the DN pattern verbatim, so a name
59+
* carrying DN syntax added structure to the DN instead of being a value inside it.
60+
*/
61+
public function testAUsernameCannotInjectDnStructure(): void
62+
{
63+
$evil = 'alice,ou=admins';
64+
65+
$dn = $this->buildDn('uid=%u,ou=users,dc=example,dc=com', $evil);
66+
67+
$this->assertSame('uid='.ldap_escape($evil, '', LDAP_ESCAPE_DN).',ou=users,dc=example,dc=com', $dn);
68+
$this->assertStringNotContainsString('uid=alice,ou=admins,', $dn, 'The comma must not stay structural');
69+
}
70+
71+
public function testTheDomainPartIsEscapedToo(): void
72+
{
73+
$dn = $this->buildDn('uid=%U,dc=%d', 'alice@example.org,ou=admins');
74+
75+
$this->assertStringNotContainsString('dc=example.org,ou=admins', $dn);
76+
}
77+
}

0 commit comments

Comments
 (0)