Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,16 @@ If that setting is turned on, guests can only be invited by [group admins](https

Upon invitation, the group admin must select at least one of their adminstrated groups the guest shall be member of.

### Blocking email domains

Administrators can prevent guest accounts from being created for specific email domains (_Settings > Administration > Guests > `Block guest invitations for these email domains`_). An entry such as `example.com` blocks `example.com` and all of its subdomains (e.g. `mail.example.com`), but not other domains that merely end with the same characters (e.g. `otherexample.com`). The blocklist applies to invitations from the web UI, the API and `occ guests:add`; existing guest accounts are not affected.

The list can also be set from the command line:

```bash
occ config:app:set guests blocked_domains --value='["example.com","example.org"]' --type=array
```

### Share acceptance

Guest users automatically accept all files and folders that are shared with them. This is in constract to regular full users (who have to actively accept incoming shares since Nextcloud 18).
Expand Down
5 changes: 5 additions & 0 deletions lib/Command/AddCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,11 @@ protected function execute(InputInterface $input, OutputInterface $output): int
return self::FAILURE;
}

if ($this->config->isEmailDomainBlocked($email)) {
$output->writeln('<error>Guest accounts cannot be created for the domain of "' . $email . '", it is on the blocklist.</error>');
return self::FAILURE;
}

$password = null;
if (!$input->getOption('generate-password')) {
if ($input->getOption('password-from-env')) {
Expand Down
95 changes: 95 additions & 0 deletions lib/Config.php
Original file line number Diff line number Diff line change
Expand Up @@ -163,4 +163,99 @@ public function getCreateRestrictedToGroup(): array {
public function setCreateRestrictedToGroup(array $groups): void {
$this->appConfig->setAppValueArray('create_restricted_to_group', $groups);
}

/**
* Email domains that must not be invited as guests.
*
* @return list<string>
*/
public function getBlockedDomains(): array {
return self::normalizeDomains($this->appConfig->getAppValueArray(ConfigLexicon::BLOCKED_DOMAINS));
}

/**
* @param list<string> $domains
*/
public function setBlockedDomains(array $domains): void {
$this->appConfig->setAppValueArray(ConfigLexicon::BLOCKED_DOMAINS, self::normalizeDomains($domains));
}

/**
* Whether the domain of the given email address is blocked for guest
* invitations. A blocked entry like "example.com" matches the domain
* itself and all of its subdomains (e.g. "mail.example.com"), but not
* unrelated domains sharing the same suffix (e.g. "otherexample.com").
*/
public function isEmailDomainBlocked(string $email): bool {
$atPosition = strrpos($email, '@');
if ($atPosition === false) {
return false;
}
$rawDomain = rtrim(mb_strtolower(trim(substr($email, $atPosition + 1))), '.');
if ($rawDomain === '') {
return false;
}
$emailDomain = self::toAsciiDomain($rawDomain) ?? $rawDomain;

foreach ($this->getBlockedDomains() as $blockedDomain) {
$blockedDomain = self::toAsciiDomain($blockedDomain);
if ($blockedDomain === null) {
continue;
}
if ($emailDomain === $blockedDomain || str_ends_with($emailDomain, '.' . $blockedDomain)) {
return true;
}
}
return false;
}

/**
* Lower-case, trim and de-duplicate domain entries. Leading "@", "*." and
* "." as well as trailing dots are stripped, so "@Example.com" and
* "*.example.com" are both stored as "example.com". Entries that are not
* a valid domain name are dropped. Internationalized domains are kept in
* the form they were entered; "bücher.de" and "xn--bcher-kva.de" count as
* duplicates.
*
* @param array<array-key, mixed> $domains
* @return list<string>
*/
public static function normalizeDomains(array $domains): array {
$normalized = [];
foreach ($domains as $domain) {
if (!is_string($domain)) {
continue;
}
$domain = mb_strtolower(trim($domain));
$domain = preg_replace('/^(@|\*\.|\.)+/u', '', $domain) ?? '';
$domain = rtrim($domain, '.');
$asciiDomain = self::toAsciiDomain($domain);
if ($asciiDomain === null || isset($normalized[$asciiDomain])) {
continue;
}
$normalized[$asciiDomain] = $domain;
}
return array_values($normalized);
}

/**
* Convert a lower-case domain to its ASCII (punycode) form and validate it
* as a hostname: at most 253 characters, labels of 1-63 letters, digits or
* inner hyphens.
*/
private static function toAsciiDomain(string $domain): ?string {
if ($domain === '') {
return null;
}
$ascii = idn_to_ascii($domain, IDNA_NONTRANSITIONAL_TO_ASCII, INTL_IDNA_VARIANT_UTS46);
if ($ascii === false) {
return null;
}
$ascii = strtolower($ascii);
if (strlen($ascii) > 253
|| preg_match('/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)*$/', $ascii) !== 1) {
return null;
}
return $ascii;
}
}
2 changes: 2 additions & 0 deletions lib/ConfigLexicon.php
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ class ConfigLexicon implements ILexicon {
public const WHITE_LIST = 'whitelist';
public const GROUP_LIMITATION = 'create_restricted_to_group';
public const GUEST_DISK_QUOTA = 'guest_quota';
public const BLOCKED_DOMAINS = 'blocked_domains';
public const USER_CREATED_BY = 'created_by';

#[\Override]
Expand All @@ -48,6 +49,7 @@ public function getAppConfigs(): array {
default => '0 B',
}, definition: 'set default disk quota assigned to guest account at its creation'),
new Entry(self::GROUP_LIMITATION, ValueType::ARRAY, []),
new Entry(self::BLOCKED_DOMAINS, ValueType::ARRAY, [], 'email domains that cannot be invited as guests (subdomains are included)'),
];
}

Expand Down
7 changes: 6 additions & 1 deletion lib/Controller/SettingsController.php
Original file line number Diff line number Diff line change
Expand Up @@ -58,13 +58,15 @@ public function getConfig(): DataResponse {
'createRestrictedToGroup' => $this->config->getCreateRestrictedToGroup(),
'guestQuota' => $this->config->hasGuestQuotaOverride() ? $this->config->getGuestQuota() : 'default',
'guestQuotaDefault' => $this->config->getGuestQuotaDefault(),
'blockedDomains' => $this->config->getBlockedDomains(),
]);
}

/**
* @param list<string> $whitelist
* @param list<string>|null $blockedDomains null keeps the stored list unchanged
*/
public function setConfig(bool $useWhitelist, array $whitelist, bool $allowExternalStorage, bool $useHashedEmailAsUserID, bool $hideUsers, array $createRestrictedToGroup, string $guestQuota = 'default'): DataResponse {
public function setConfig(bool $useWhitelist, array $whitelist, bool $allowExternalStorage, bool $useHashedEmailAsUserID, bool $hideUsers, array $createRestrictedToGroup, string $guestQuota = 'default', ?array $blockedDomains = null): DataResponse {
$newWhitelist = [];
foreach ($whitelist as $app) {
$newWhitelist[] = trim((string)$app);
Expand All @@ -79,6 +81,9 @@ public function setConfig(bool $useWhitelist, array $whitelist, bool $allowExter
if ($this->isValidQuota($guestQuota)) {
$this->config->setGuestQuota($guestQuota);
}
if ($blockedDomains !== null) {
$this->config->setBlockedDomains($blockedDomains);
}

return new DataResponse();
}
Expand Down
6 changes: 6 additions & 0 deletions lib/Controller/UsersController.php
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,12 @@ public function create(string $email, string $displayName, string $language, arr
);
}

if (!isset($errorMessages['email']) && $this->config->isEmailDomainBlocked($email)) {
$errorMessages['email'] = $this->l10n->t(
'Guest accounts cannot be created for this email domain.'
);
}

if ($this->config->useHashedEmailAsUserID()) {
$email = strtolower($email);
$username = hash('sha256', $email);
Expand Down
6 changes: 3 additions & 3 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

86 changes: 85 additions & 1 deletion src/views/GuestSettings.vue
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,27 @@
@update:modelValue="onSelectGroups" />
</p>

<!-- Blocked email domains -->
<div class="blocked-domains">
<label :for="blockedDomainsInputId" class="blocked-domains__label">
{{ t('guests', 'Block guest invitations for these email domains') }}
</label>
<NcSelect
v-model="config.blockedDomains"
:inputId="blockedDomainsInputId"
class="blocked-domains__select"
:options="[]"
:multiple="true"
:taggable="true"
:noDrop="true"
:placeholder="t('guests', 'e.g. example.com')"
:createOption="normalizeDomain"
@update:modelValue="onBlockedDomainsChange" />
<p class="blocked-domains__hint">
{{ t('guests', 'Subdomains are blocked as well. Existing guest accounts are not affected.') }}
</p>
</div>

<!-- Allowlist -->
<NcCheckboxRadioSwitch
v-model="config.useWhitelist"
Expand Down Expand Up @@ -198,6 +219,7 @@ export default {
whitelistDraft: [],
quotaInputId: 'guests-default-quota',
quotaSearch: '',
blockedDomainsInputId: 'guests-blocked-domains',
config: {
useWhitelist: false,
allowExternalStorage: false,
Expand All @@ -209,6 +231,7 @@ export default {
createRestrictedToGroup: [],
guestQuota: 'default',
guestQuotaDefault: '0 B',
blockedDomains: [],
},
}
},
Expand Down Expand Up @@ -317,9 +340,50 @@ export default {
|| /^\d+(\.\d+)?\s*(b|kb|mb|gb|tb|pb)$/i.test(String(value).trim())
},

normalizeDomain(value) {
return String(value ?? '')
.trim()
.toLowerCase()
.replace(/^(@|\*\.|\.)+/, '')
.replace(/\.+$/, '')
},

isValidDomain(value) {
return /^[\p{L}0-9]([\p{L}0-9-]*[\p{L}0-9])?(\.[\p{L}0-9]([\p{L}0-9-]*[\p{L}0-9])?)*$/u.test(value)
},

onBlockedDomainsChange(domains) {
const normalized = (domains ?? [])
.map((domain) => this.normalizeDomain(typeof domain === 'string' ? domain : (domain?.label ?? '')))
const invalid = normalized.filter((domain) => domain !== '' && !this.isValidDomain(domain))
if (invalid.length > 0) {
showError(t('guests', 'Invalid domain: {domain}', { domain: invalid[0] }))
}
this.config.blockedDomains = [...new Set(normalized.filter((domain) => this.isValidDomain(domain)))]
this.saveBlockedDomains()
},

async saveBlockedDomains() {
await this.saveConfig()
if (this.error) {
return
}
// The server validates and de-duplicates entries, show what it stored.
try {
const { data } = await axios.get(generateUrl('apps/guests/config'))

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fetching the whole config again just to see what the server kept is a bit much. Could setConfig return the stored blockedDomains instead? Then you can use that directly, and an invalid domain no longer shows the error twice (once from the client check, once from this diff).

const rejected = this.config.blockedDomains.filter((domain) => !data.blockedDomains.includes(domain))
if (rejected.length > 0) {
showError(t('guests', 'Invalid domain: {domain}', { domain: rejected[0] }))
}
this.config.blockedDomains = data.blockedDomains
} catch (error) {
logger.error('Error loading blocked domains', { error })
}
},

async loadConfig() {
const { data } = await axios.get(generateUrl('apps/guests/config'))
this.config = data
this.config = { blockedDomains: [], ...data }
this.loaded = true
},

Expand Down Expand Up @@ -453,6 +517,26 @@ export default {
}
}

.blocked-domains {
margin-top: 1.5rem;
margin-bottom: 1rem;
max-width: 690px;

&__label {
display: block;
margin-bottom: 4px;
}

&__select {
width: 100%;
}

&__hint {
margin-top: 4px;
color: var(--color-text-maxcontrast);
}
}

.guest-quota {
margin-top: 1.5rem;
max-width: 400px;
Expand Down
31 changes: 31 additions & 0 deletions tests/unit/Command/AddCommandTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -231,4 +231,35 @@ public function testCreateGuestWithCustomUid(): void {
$output = $this->commandTester->getDisplay();
$this->assertStringContainsString('The guest account user "karl" was created successfully', $output);
}

public function testCreateGuestBlockedDomain(): void {
$this->userManager->expects($this->once())
->method('get')
->with('creator')
->willReturn($this->createMock(IUser::class));

$this->userManager->method('userExists')
->willReturn(false);

$this->mailer->method('validateMailAddress')
->willReturn(true);

$this->config->expects($this->once())
->method('isEmailDomainBlocked')
->with('guest@blocked.example')
->willReturn(true);

$this->guestManager->expects($this->never())
->method('createGuest');

$this->commandTester->execute([
'created-by' => 'creator',
'email' => 'guest@blocked.example',
'--generate-password' => true,
]);

$output = $this->commandTester->getDisplay();
$this->assertStringContainsString('it is on the blocklist', $output);
$this->assertEquals(1, $this->commandTester->getStatusCode());
}
}
Loading