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')); +});