From f8874f32eaee036d7597da253beab45a4368834b Mon Sep 17 00:00:00 2001 From: nexxo Date: Wed, 29 Jul 2026 16:03:13 +0200 Subject: [PATCH] Put customer instances on their own domain, and say which domains are ours MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ── The zone ──────────────────────────────────────────────────────────────── A fresh install now addresses customer instances under clupilot.cloud rather than clupilot.com. Not branding: a Nextcloud is third-party software that strangers sign in to, and on the same registrable domain as the portal it shares cookie scope, document.domain and CAA with it. The same reason googleusercontent.com and vercel.app exist. Existing installations keep whatever the console says — the zone has been a setting all along. `clupilot:check-zone ` reports what a change would touch before anything is touched: the DNS records, the monitoring targets, the certificates and Nextcloud's trusted_domains all carry the old name and the setting reaches none of them. With no instances in service it says so and stops. Changing it later is a migration with an outage, and the command says that too. ── Which addresses are ours ──────────────────────────────────────────────── A new public page at /sicherheit: the domains that belong to us, the rule for reading an address bar, what we never do, and — the part that matters — what to do if you have already typed your password into a copy of our sign-in form. Change it, end every other session, change it wherever else you use it, tell us. In that order. Reachable without an account, deliberately: somebody who has just given their password away is signed in nowhere, and the page they need cannot be behind the thing they lost. Linked from the three places a person is when the question comes up: every mail footer (the mail is the vector), the sign-in form (this is the page a phishing kit copies, so the real one says which host you are on), and the site footer. The domain list is DERIVED from configuration and keyed by what each domain is for. A list typed into a page would eventually tell a customer that a phishing domain is one of ours, or that one of ours is not — the same two-sources-of-truth failure that had the price sheet promising an address on a domain the company does not own. The operator console is deliberately absent from it: naming it on a public page tells an attacker where to aim. Co-Authored-By: Claude Opus 5 --- .env.example | 2 +- VERSION | 2 +- app/Console/Commands/CheckDnsZone.php | 75 +++++++++++++++ app/Support/OfficialDomains.php | 92 ++++++++++++++++++ config/provisioning.php | 35 ++++++- lang/de/auth.php | 3 + lang/de/mail.php | 3 + lang/de/security.php | 43 +++++++++ lang/en/auth.php | 3 + lang/en/mail.php | 3 + lang/en/security.php | 43 +++++++++ .../views/components/layouts/site.blade.php | 1 + .../views/components/mail/layout.blade.php | 4 + resources/views/livewire/auth/login.blade.php | 7 ++ resources/views/security.blade.php | 93 +++++++++++++++++++ routes/web.php | 5 + tests/Feature/OfficialDomainsTest.php | 79 ++++++++++++++++ 17 files changed, 490 insertions(+), 3 deletions(-) create mode 100644 app/Console/Commands/CheckDnsZone.php create mode 100644 app/Support/OfficialDomains.php create mode 100644 lang/de/security.php create mode 100644 lang/en/security.php create mode 100644 resources/views/security.blade.php create mode 100644 tests/Feature/OfficialDomainsTest.php diff --git a/.env.example b/.env.example index c24214c..7318d2d 100644 --- a/.env.example +++ b/.env.example @@ -103,7 +103,7 @@ STRIPE_SECRET= # ── DNS (Hetzner DNS API) ──────────────────────────────────────────────── # Creates . A-records for each customer instance. HETZNER_DNS_TOKEN= -CLUPILOT_DNS_ZONE=clupilot.com +CLUPILOT_DNS_ZONE=clupilot.cloud # Host names (fsn-01.node.…) are NOT published here — that would leak the # internal WireGuard subnet. They go into this directory instead, which the diff --git a/VERSION b/VERSION index 5bdcf5c..25b22e0 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.3.15 +1.3.16 diff --git a/app/Console/Commands/CheckDnsZone.php b/app/Console/Commands/CheckDnsZone.php new file mode 100644 index 0000000..8b96866 --- /dev/null +++ b/app/Console/Commands/CheckDnsZone.php @@ -0,0 +1,75 @@ +argument('zone') ?? ''); + + $this->line(''); + $this->line(" Current zone: {$current}"); + $this->line(' Proposed zone: '.($target !== '' ? "{$target}" : 'none given')); + $this->line(''); + + $instances = Instance::query() + ->whereIn('status', ['active', 'provisioning', 'cancellation_scheduled']) + ->get(); + + if ($instances->isEmpty()) { + $this->info(' No instances in service. The zone can be changed with nothing to migrate.'); + $this->line(''); + + return self::SUCCESS; + } + + $this->warn(" {$instances->count()} instance(s) are addressed under the current zone."); + $this->line(''); + $this->line(' Each of these carries the old name in places this setting does NOT reach:'); + $this->line(''); + + $records = DnsRecord::query()->whereIn('instance_id', $instances->pluck('id'))->count(); + $targets = MonitoringTarget::query()->whereIn('instance_id', $instances->pluck('id'))->count(); + $verified = $instances->filter(fn (Instance $i) => $i->domainIsVerified())->count(); + + $this->table(['What', 'Count', 'Consequence of changing the zone'], [ + ['DNS records (dns_records.fqdn)', $records, 'still point the OLD name at the host'], + ['Monitoring targets', $targets, 'keep watching the old URL and report down'], + ['TLS certificates', $instances->count(), 'issued for the old name; the new one has none'], + ['Nextcloud trusted_domains', $instances->count(), 'rejects the new name until rewritten'], + ['Instances on their own domain', $verified, 'unaffected — they are served at custom_domain'], + ]); + + $this->line(''); + $this->line(' A change now is a migration with an outage, not a setting. Either do it'); + $this->line(' before the first customer, or plan it as a maintenance window.'); + $this->line(''); + + return self::SUCCESS; + } +} diff --git a/app/Support/OfficialDomains.php b/app/Support/OfficialDomains.php new file mode 100644 index 0000000..3849731 --- /dev/null +++ b/app/Support/OfficialDomains.php @@ -0,0 +1,92 @@ + + */ + public static function hosts(): array + { + $entries = []; + + foreach ((array) config('admin_access.site_hosts', []) as $host) { + $entries[] = ['host' => (string) $host, 'what' => 'site']; + } + + if ($app = (string) config('admin_access.app_host')) { + $entries[] = ['host' => $app, 'what' => 'portal']; + } + + if ($status = (string) config('admin_access.status_host')) { + $entries[] = ['host' => $status, 'what' => 'status']; + } + + // The instance zone as a pattern, because the label is per customer and + // naming one customer's address on a public page names that customer. + if ($zone = ProvisioningSettings::dnsZone()) { + $entries[] = ['host' => '*.'.$zone, 'what' => 'instance']; + } + + // The console is deliberately absent. It is not an address a customer + // has any business visiting, and printing it on a public page tells an + // attacker where the operator interface lives. + return array_values(array_filter($entries, fn (array $e) => $e['host'] !== '')); + } + + /** + * The registrable domains behind those hosts — what somebody actually has + * to check in the address bar. + * + * "Everything before the first slash ends in one of these" is a rule a + * person can apply under time pressure; a list of eight hostnames is not. + * + * Keyed by domain, valued by what it is for — so the page never has to + * work that out from the order it happens to come back in. + * + * @return array + */ + public static function registrable(): array + { + $domains = []; + + foreach (self::hosts() as $entry) { + $labels = explode('.', ltrim($entry['host'], '*.')); + + // Last two labels. Not a public-suffix implementation: this runs + // against an installation's own configured domains, which are known + // quantities, and pulling in a suffix list to be right about + // co.uk on a page that says "check the address bar" is not the + // trade this needs. + if (count($labels) < 2) { + continue; + } + + $domain = implode('.', array_slice($labels, -2)); + + // First writer wins: the site is listed before the instance zone, + // so a single-domain installation is described as the company's + // rather than as the customers'. + $domains[$domain] ??= $entry['what'] === 'instance' ? 'instances' : 'company'; + } + + return $domains; + } +} diff --git a/config/provisioning.php b/config/provisioning.php index b3ef3b9..cbcb30b 100644 --- a/config/provisioning.php +++ b/config/provisioning.php @@ -129,6 +129,33 @@ return [ 'extra_backups' => ['price_cents' => 500], 'priority_support' => ['price_cents' => 2900], 'collabora_pro' => ['price_cents' => 1900], + // The owner's commercial figure, to be adjusted once the first ones are + // sold. Listed here because the public sheet has to answer what a plan + // without an own domain costs to give one — an unpriced feature can only + // be shown as absent, and it was being shown that way to the two plans + // we would most like to sell it to. + 'custom_domain' => [ + 'price_cents' => 900, + // The packages on which an own domain is not possible AT ALL: not + // bookable, not upgradable, not even offered. Named by plan key, + // and named HERE, because the catalogue cannot answer this. Plan + // versions model what a package HAS — its features and its size — + // and nothing in the catalogue models what a package may BUY, so + // the only catalogue-derived answers would be proxies, and both + // available ones are dishonest: + // + // - "the lowest tier on sale" quietly hands the right to a + // grandfathered Start customer the day the owner stops selling + // Start or adds a cheaper package underneath it. + // - reading it off `branding` ties two unrelated decisions + // together, so letting Start upload a logo would also sell it + // a domain. + // + // A rule this commercial belongs where the owner can see and + // change it, next to the price it gates. Read only by + // App\Services\Billing\CustomDomainAccess. + 'unavailable_on' => ['start'], + ], ], // Feature bullets moved onto plan_versions.features, so that what a version @@ -138,7 +165,13 @@ return [ 'dns' => [ 'provider' => 'hetzner', 'token' => env('HETZNER_DNS_TOKEN', ''), - 'zone' => env('CLUPILOT_DNS_ZONE', 'clupilot.com'), + // Customer instances live on their own registrable domain, NOT on the + // one that serves the portal and the console. A Nextcloud is + // third-party software that strangers sign in to; on the same + // registrable domain as the portal it shares cookie scope, + // document.domain and CAA with it. Same reasoning as + // googleusercontent.com or vercel.app. + 'zone' => env('CLUPILOT_DNS_ZONE', 'clupilot.cloud'), // Internal, WireGuard-only host names (fsn-01.node.…) — the vpn-dns // container's dnsmasq --hostsdir. NOT the public Hetzner zone above: diff --git a/lang/de/auth.php b/lang/de/auth.php index e3e7af0..c964cb1 100644 --- a/lang/de/auth.php +++ b/lang/de/auth.php @@ -59,4 +59,7 @@ return [ 'account_suspended' => 'Ihr Konto ist gesperrt. Bitte kontaktieren Sie den Support.', 'account_closed' => 'Dieses Konto wurde geschlossen.', 'not_an_operator' => 'Dieses Konto hat keinen Zugang zur Konsole.', + + 'phishing_note' => 'Sie sind gerade auf :host. Wir fragen Sie nie per E-Mail oder Telefon nach Ihrem Passwort.', + 'phishing_link' => 'Echte Adressen erkennen', ]; diff --git a/lang/de/mail.php b/lang/de/mail.php index 9b7d14c..ad97d6a 100644 --- a/lang/de/mail.php +++ b/lang/de/mail.php @@ -14,4 +14,7 @@ return [ // Der Unterschied zwischen einer Werbemail und einer Kontomail, ausdrücklich // gesagt: für diese gibt es keinen Abmeldelink, und das ist zulässig. 'transactional_note' => 'Diese Nachricht betrifft Ihr Konto und wird unabhängig von Werbeeinstellungen versendet.', + + 'domains_note' => 'Wir bitten Sie nie per E-Mail um Ihr Passwort. Offiziell sind nur :domains —', + 'domains_link' => 'echte Adressen erkennen', ]; diff --git a/lang/de/security.php b/lang/de/security.php new file mode 100644 index 0000000..fa8eded --- /dev/null +++ b/lang/de/security.php @@ -0,0 +1,43 @@ + 'Sicherheit', + 'title' => 'Echte Adressen erkennen', + 'lead' => 'Betrüger bauen Anmeldeseiten nach und schicken Links, die aussehen wie unsere. Sie können sich das ersparen, indem Sie eine einzige Sache prüfen: die Adresse in der Adresszeile.', + + 'domains_title' => 'Diese Domains gehören uns', + 'domains_body' => 'Alles andere ist nicht von uns — auch dann nicht, wenn Logo, Farben und Text stimmen. Ein Nachbau ist in zehn Minuten gemacht.', + 'domain_company' => 'Website, Kundenbereich und Rechnungen', + 'domain_instances' => 'Ihre Cloud selbst', + 'domains_rule' => "Die Regel, die immer trägt: Was in der Adresszeile vor dem ersten Schrägstrich steht, muss auf eine dieser Domains enden. „clupilot.com.beispiel.tld“ endet auf beispiel.tld und gehört jemand anderem.", + + 'hosts_title' => 'Adressen, die Sie tatsächlich sehen', + 'host_site' => 'unsere Website', + 'host_portal' => 'Anmeldung und Kundenbereich', + 'host_status' => 'Betriebsstatus', + 'host_instance' => 'Ihre eigene Cloud (Ihr Name steht vorne)', + + 'never_title' => 'Was wir nie tun', + 'never_password' => 'Wir fragen Sie nie nach Ihrem Passwort — nicht per E-Mail, nicht am Telefon, auch nicht wenn es dringend klingt. Wir kennen es nicht und brauchen es nicht.', + 'never_card' => 'Wir schicken Ihnen nie eine E-Mail mit der Bitte, Zahlungsdaten „zu bestätigen“ oder erneut einzugeben. Rechnungen liegen in Ihrem Kundenbereich.', + 'never_remote' => 'Wir bitten Sie nie unaufgefordert, ein Fernwartungsprogramm zu installieren oder uns Ihren Bildschirm freizugeben. Wenn Sie uns anrufen, sprechen wir darüber — nie umgekehrt.', + 'never_urgency' => 'Wir setzen Ihnen keine Fristen von wenigen Stunden und drohen nicht mit Löschung. Zeitdruck ist das häufigste Werkzeug in solchen Nachrichten.', + + 'compromised_title' => 'Sie haben Ihr Passwort auf einer fremden Seite eingegeben?', + 'compromised_lead' => 'Dann handeln Sie jetzt, in dieser Reihenfolge. Das ist unangenehm, aber es ist reparierbar — und je früher, desto vollständiger.', + + 'step_change' => 'Passwort ändern', + 'step_change_body' => 'Melden Sie sich bei uns an — aber tippen Sie die Adresse selbst ein, klicken Sie nicht auf einen Link aus einer E-Mail — und vergeben Sie unter Einstellungen ein neues Passwort.', + 'step_change_action' => 'Zur Anmeldung', + + 'step_sessions' => 'Alle anderen Sitzungen beenden', + 'step_sessions_body' => 'Unter „Angemeldete Geräte“ sehen Sie jede offene Anmeldung. Beenden Sie alle außer der aktuellen: wer Ihr Passwort hatte, ist damit draußen, auch wenn er noch angemeldet war.', + + 'step_others' => 'Dasselbe Passwort anderswo ändern', + 'step_others_body' => 'Wenn Sie dieses Passwort auch woanders benutzen, ändern Sie es dort ebenfalls. Angreifer probieren gestohlene Zugangsdaten reihum bei E-Mail-Anbietern und Banken.', + + 'step_tell' => 'Sagen Sie uns Bescheid', + 'step_tell_body' => 'Wir sehen dann nach, ob mit Ihrem Zugang etwas passiert ist, und können die Anmeldungen für Sie prüfen. Es ist uns lieber, Sie melden sich einmal zu oft.', + + 'tell_us' => 'Unsicher, ob eine Nachricht echt ist? Schicken Sie sie uns weiter, bevor Sie klicken. Das kostet Sie zwei Minuten und uns nichts.', +]; diff --git a/lang/en/auth.php b/lang/en/auth.php index ecce6f2..ae5445a 100644 --- a/lang/en/auth.php +++ b/lang/en/auth.php @@ -59,4 +59,7 @@ return [ 'account_suspended' => 'Your account is suspended. Please contact support.', 'account_closed' => 'This account has been closed.', 'not_an_operator' => 'This account has no access to the console.', + + 'phishing_note' => 'You are currently on :host. We never ask for your password by email or on the phone.', + 'phishing_link' => 'How to recognise our addresses', ]; diff --git a/lang/en/mail.php b/lang/en/mail.php index e80712a..2c54f53 100644 --- a/lang/en/mail.php +++ b/lang/en/mail.php @@ -14,4 +14,7 @@ return [ // The difference between a marketing mail and an account mail, said out // loud: this one has no unsubscribe link, and that is allowed. 'transactional_note' => 'This message concerns your account and is sent regardless of marketing preferences.', + + 'domains_note' => 'We never ask for your password by email. Only :domains are ours —', + 'domains_link' => 'how to recognise our addresses', ]; diff --git a/lang/en/security.php b/lang/en/security.php new file mode 100644 index 0000000..b2cb96d --- /dev/null +++ b/lang/en/security.php @@ -0,0 +1,43 @@ + 'Security', + 'title' => 'Recognising our real addresses', + 'lead' => 'Fraudsters rebuild sign-in pages and send links that look like ours. You can spare yourself all of it by checking one thing: the address in the address bar.', + + 'domains_title' => 'These domains are ours', + 'domains_body' => 'Anything else is not from us — not even when the logo, the colours and the wording are right. A copy takes ten minutes to make.', + 'domain_company' => 'Website, customer area and invoices', + 'domain_instances' => 'Your cloud itself', + 'domains_rule' => "The rule that always holds: whatever stands before the first slash in the address bar must END with one of these domains. “clupilot.com.example.tld” ends with example.tld and belongs to somebody else.", + + 'hosts_title' => 'Addresses you will actually see', + 'host_site' => 'our website', + 'host_portal' => 'sign-in and customer area', + 'host_status' => 'service status', + 'host_instance' => 'your own cloud (your name goes in front)', + + 'never_title' => 'What we never do', + 'never_password' => 'We never ask for your password — not by email, not on the phone, not even when it sounds urgent. We do not know it and do not need it.', + 'never_card' => 'We never email you asking to “confirm” or re-enter payment details. Invoices are in your customer area.', + 'never_remote' => 'We never ask you out of the blue to install remote-access software or share your screen. If you call us, we discuss it — never the other way round.', + 'never_urgency' => 'We do not give you deadlines of a few hours or threaten deletion. Time pressure is the commonest tool in messages like that.', + + 'compromised_title' => 'Did you enter your password on a page that was not ours?', + 'compromised_lead' => 'Then act now, in this order. It is unpleasant, but it is repairable — and the sooner, the more completely.', + + 'step_change' => 'Change your password', + 'step_change_body' => 'Sign in with us — but type the address yourself, do not click a link from an email — and set a new password under Settings.', + 'step_change_action' => 'Go to sign-in', + + 'step_sessions' => 'End every other session', + 'step_sessions_body' => 'Under “Signed-in devices” you can see every open session. End all but the current one: whoever had your password is out, even if they were still signed in.', + + 'step_others' => 'Change the same password elsewhere', + 'step_others_body' => 'If you use this password anywhere else, change it there too. Attackers try stolen credentials in turn at email providers and banks.', + + 'step_tell' => 'Tell us', + 'step_tell_body' => 'We will check whether anything happened to your account and can go through the sign-ins with you. We would much rather hear from you once too often.', + + 'tell_us' => 'Not sure whether a message is genuine? Forward it to us before you click. It costs you two minutes and us nothing.', +]; diff --git a/resources/views/components/layouts/site.blade.php b/resources/views/components/layouts/site.blade.php index d08cb57..fdd08e8 100644 --- a/resources/views/components/layouts/site.blade.php +++ b/resources/views/components/layouts/site.blade.php @@ -100,6 +100,7 @@ Datenschutz AGB Betriebsstatus + Echte Adressen erkennen Anmelden diff --git a/resources/views/components/mail/layout.blade.php b/resources/views/components/mail/layout.blade.php index a16e970..3420f0e 100644 --- a/resources/views/components/mail/layout.blade.php +++ b/resources/views/components/mail/layout.blade.php @@ -69,6 +69,10 @@  ·  {{ __('mail.terms') }}

+

+ {{ __('mail.domains_note', ['domains' => implode(' · ', array_keys(App\Support\OfficialDomains::registrable()))]) }} + {{ __('mail.domains_link') }} +

{{ __('mail.sender_line') }}

{{ __('mail.transactional_note') }}

diff --git a/resources/views/livewire/auth/login.blade.php b/resources/views/livewire/auth/login.blade.php index c40cd16..4a8568d 100644 --- a/resources/views/livewire/auth/login.blade.php +++ b/resources/views/livewire/auth/login.blade.php @@ -29,6 +29,13 @@ {{ __('auth.sign_in') }} + + {{-- The one line that belongs on a sign-in page: this IS the page + a phishing kit copies, so the real one says how to tell. --}} +

+ {{ __('auth.phishing_note', ['host' => request()->getHost()]) }} + {{ __('auth.phishing_link') }} +

{{ __('auth.no_account') }} {{ __('auth.sign_up') }} diff --git a/resources/views/security.blade.php b/resources/views/security.blade.php new file mode 100644 index 0000000..7ffd15b --- /dev/null +++ b/resources/views/security.blade.php @@ -0,0 +1,93 @@ +{{-- + "Which addresses are really ours, and what to do if you fell for one that + was not." + + A public page rather than a paragraph buried in the portal: somebody who + has just typed their password into a copy of our sign-in form is not signed + in anywhere, and the page they need has to be reachable without an account. + It is linked from every mail footer, from the sign-in form and from the + site footer — the three places a person is when the question comes up. + + The domain list is derived from configuration, never typed. A stale list + here would tell a customer that a phishing domain is one of ours. +--}} + + +

+

{{ __('security.eyebrow') }}

+

{{ __('security.title') }}

+

{{ __('security.lead') }}

+ + {{-- ── The addresses ────────────────────────────────────────────────── --}} +
+

{{ __('security.domains_title') }}

+

{{ __('security.domains_body') }}

+ +
+ @foreach (App\Support\OfficialDomains::registrable() as $domain => $role) +
+ + {{ $domain }} + {{ __('security.domain_'.$role) }} +
+ @endforeach +
+ +

{{ __('security.domains_rule') }}

+ +
+

{{ __('security.hosts_title') }}

+
    + @foreach (App\Support\OfficialDomains::hosts() as $entry) +
  • + {{ $entry['host'] }} + {{ __('security.host_'.$entry['what']) }} +
  • + @endforeach +
+
+
+ + {{-- ── What we never do ─────────────────────────────────────────────── --}} +
+

{{ __('security.never_title') }}

+
+ @foreach (['password', 'card', 'remote', 'urgency'] as $never) +
+ +

{{ __('security.never_'.$never) }}

+
+ @endforeach +
+
+ + {{-- ── What to do ───────────────────────────────────────────────────── --}} +
+

{{ __('security.compromised_title') }}

+

{{ __('security.compromised_lead') }}

+ +
    + @foreach (['change', 'sessions', 'others', 'tell'] as $i => $step) +
  1. + {{ $i + 1 }} +
    + {{ __('security.step_'.$step) }} +

    {{ __('security.step_'.$step.'_body') }}

    + @if ($step === 'change') + {{ __('security.step_change_action') }} + @endif +
    +
  2. + @endforeach +
+ +
+

{{ __('security.tell_us') }}

+ + office@clupilot.com + +
+
+
+ + diff --git a/routes/web.php b/routes/web.php index 18e8f3a..5545d03 100644 --- a/routes/web.php +++ b/routes/web.php @@ -180,6 +180,11 @@ $publicSite = function () { // Generated, not a static file: while the site is hidden this has to say // so, and a crawler that gets a 404 here simply crawls anyway. + // Reachable WITHOUT an account, deliberately: somebody who has just typed + // their password into a copy of our sign-in form is not signed in anywhere, + // and the page they need cannot be behind the thing they lost. + Route::get('/sicherheit', fn () => view('security'))->name('security'); + Route::get('/robots.txt', function () { $body = App\Support\Settings::bool('site.public', true) ? "User-agent: *\nAllow: /\n" diff --git a/tests/Feature/OfficialDomainsTest.php b/tests/Feature/OfficialDomainsTest.php new file mode 100644 index 0000000..07e75a3 --- /dev/null +++ b/tests/Feature/OfficialDomainsTest.php @@ -0,0 +1,79 @@ +set('admin_access.site_hosts', ['www.clupilot.com', 'clupilot.com']); + config()->set('admin_access.app_host', 'app.clupilot.com'); + config()->set('admin_access.status_host', 'status.clupilot.com'); + Settings::set('provisioning.dns_zone', 'clupilot.cloud'); +}); + +it('names both registrable domains, and says what each is for', function () { + // Keyed by domain and valued by role, so the page never works the role out + // from the order the list happens to come back in. + expect(OfficialDomains::registrable())->toBe([ + 'clupilot.com' => 'company', + 'clupilot.cloud' => 'instances', + ]); +}); + +it('follows the configuration rather than a list somebody typed', function () { + // The whole point. An installation moved to another zone must not go on + // vouching for the old one. + Settings::set('provisioning.dns_zone', 'kunden.example.test'); + + expect(array_keys(OfficialDomains::registrable()))->toContain('example.test') + ->and(array_keys(OfficialDomains::registrable()))->not->toContain('clupilot.cloud'); +}); + +it('shows the instance zone as a pattern, never one customer’s address', function () { + $hosts = collect(OfficialDomains::hosts())->pluck('host'); + + expect($hosts)->toContain('*.clupilot.cloud'); +}); + +it('does not print where the operator console lives', function () { + // A public page listing the operator interface is a public page telling an + // attacker where to aim. + config()->set('admin_access.host', 'admin.clupilot.com'); + + $hosts = collect(OfficialDomains::hosts())->pluck('host'); + + expect($hosts)->not->toContain('admin.clupilot.com'); +}); + +it('serves the page to somebody who is not signed in', function () { + // Somebody who has just given their password to a copy of our sign-in form + // is signed in nowhere. The page they need cannot be behind the thing they + // lost. + $this->get('/sicherheit') + ->assertOk() + ->assertSee('clupilot.com') + ->assertSee('clupilot.cloud') + ->assertSee(__('security.compromised_title')); +}); + +it('carries the domains in every mail footer', function () { + // The mail is the vector, so the line travels with the mail. + $layout = Illuminate\Support\Facades\File::get(resource_path('views/components/mail/layout.blade.php')); + + expect($layout)->toContain('OfficialDomains::registrable()') + ->toContain("route('security')"); +}); + +it('says on the sign-in form which host it is', function () { + // This is the page a phishing kit copies, so the real one says how to tell. + $this->get(route('login')) + ->assertOk() + ->assertSee(__('auth.phishing_link')); +});