Put customer instances on their own domain, and say which domains are ours
── 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 <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 <noreply@anthropic.com>feature/betriebsmodus v1.3.16
parent
a4626d2569
commit
f8874f32ea
|
|
@ -103,7 +103,7 @@ STRIPE_SECRET=
|
||||||
# ── DNS (Hetzner DNS API) ────────────────────────────────────────────────
|
# ── DNS (Hetzner DNS API) ────────────────────────────────────────────────
|
||||||
# Creates <subdomain>.<zone> A-records for each customer instance.
|
# Creates <subdomain>.<zone> A-records for each customer instance.
|
||||||
HETZNER_DNS_TOKEN=
|
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
|
# Host names (fsn-01.node.…) are NOT published here — that would leak the
|
||||||
# internal WireGuard subnet. They go into this directory instead, which the
|
# internal WireGuard subnet. They go into this directory instead, which the
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,75 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Models\DnsRecord;
|
||||||
|
use App\Models\Instance;
|
||||||
|
use App\Models\MonitoringTarget;
|
||||||
|
use App\Support\ProvisioningSettings;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What changing the instance zone would touch, before anything is touched.
|
||||||
|
*
|
||||||
|
* The zone is one setting, and changing it silently re-addresses every
|
||||||
|
* instance: `subdomain` holds only the label, and every caller composes
|
||||||
|
* `label.zone` at read time. So the moment the setting changes, the console,
|
||||||
|
* the portal, the acceptance checks and the mails all name an address that
|
||||||
|
* does not resolve — while the DNS records, the certificates and Nextcloud's
|
||||||
|
* trusted_domains still hold the old one.
|
||||||
|
*
|
||||||
|
* Harmless with no instances, which is exactly why it is worth doing now. This
|
||||||
|
* says which it is.
|
||||||
|
*/
|
||||||
|
class CheckDnsZone extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'clupilot:check-zone {zone? : the zone you are considering}';
|
||||||
|
|
||||||
|
protected $description = 'Report what a change of the instance DNS zone would affect';
|
||||||
|
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
$current = ProvisioningSettings::dnsZone();
|
||||||
|
$target = (string) ($this->argument('zone') ?? '');
|
||||||
|
|
||||||
|
$this->line('');
|
||||||
|
$this->line(" Current zone: <options=bold>{$current}</>");
|
||||||
|
$this->line(' Proposed zone: '.($target !== '' ? "<options=bold>{$target}</>" : '<comment>none given</comment>'));
|
||||||
|
$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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,92 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Support;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which addresses genuinely belong to this installation.
|
||||||
|
*
|
||||||
|
* Derived, never typed. A hard-coded list is the same mistake the price sheet
|
||||||
|
* made when it promised "Adresse auf clupilot.cloud" while provisioning issued
|
||||||
|
* something else — except here the consequence is a customer being told that a
|
||||||
|
* phishing domain is one of ours, or that one of ours is not.
|
||||||
|
*
|
||||||
|
* Two registrable domains by design: the company's, which serves the website,
|
||||||
|
* the portal and the console, and the instance zone, which serves customer
|
||||||
|
* workloads. Keeping them apart is the point — a Nextcloud is third-party
|
||||||
|
* software that strangers sign in to, and on the same registrable domain as
|
||||||
|
* the portal it would share cookie scope with it.
|
||||||
|
*/
|
||||||
|
class OfficialDomains
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* The hostnames a customer legitimately sees, in the order they meet them.
|
||||||
|
*
|
||||||
|
* @return array<int, array{host: string, what: string}>
|
||||||
|
*/
|
||||||
|
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<string, string>
|
||||||
|
*/
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -129,6 +129,33 @@ return [
|
||||||
'extra_backups' => ['price_cents' => 500],
|
'extra_backups' => ['price_cents' => 500],
|
||||||
'priority_support' => ['price_cents' => 2900],
|
'priority_support' => ['price_cents' => 2900],
|
||||||
'collabora_pro' => ['price_cents' => 1900],
|
'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
|
// Feature bullets moved onto plan_versions.features, so that what a version
|
||||||
|
|
@ -138,7 +165,13 @@ return [
|
||||||
'dns' => [
|
'dns' => [
|
||||||
'provider' => 'hetzner',
|
'provider' => 'hetzner',
|
||||||
'token' => env('HETZNER_DNS_TOKEN', ''),
|
'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
|
// Internal, WireGuard-only host names (fsn-01.node.…) — the vpn-dns
|
||||||
// container's dnsmasq --hostsdir. NOT the public Hetzner zone above:
|
// container's dnsmasq --hostsdir. NOT the public Hetzner zone above:
|
||||||
|
|
|
||||||
|
|
@ -59,4 +59,7 @@ return [
|
||||||
'account_suspended' => 'Ihr Konto ist gesperrt. Bitte kontaktieren Sie den Support.',
|
'account_suspended' => 'Ihr Konto ist gesperrt. Bitte kontaktieren Sie den Support.',
|
||||||
'account_closed' => 'Dieses Konto wurde geschlossen.',
|
'account_closed' => 'Dieses Konto wurde geschlossen.',
|
||||||
'not_an_operator' => 'Dieses Konto hat keinen Zugang zur Konsole.',
|
'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',
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -14,4 +14,7 @@ return [
|
||||||
// Der Unterschied zwischen einer Werbemail und einer Kontomail, ausdrücklich
|
// Der Unterschied zwischen einer Werbemail und einer Kontomail, ausdrücklich
|
||||||
// gesagt: für diese gibt es keinen Abmeldelink, und das ist zulässig.
|
// 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.',
|
'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',
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,43 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
'eyebrow' => '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.',
|
||||||
|
];
|
||||||
|
|
@ -59,4 +59,7 @@ return [
|
||||||
'account_suspended' => 'Your account is suspended. Please contact support.',
|
'account_suspended' => 'Your account is suspended. Please contact support.',
|
||||||
'account_closed' => 'This account has been closed.',
|
'account_closed' => 'This account has been closed.',
|
||||||
'not_an_operator' => 'This account has no access to the console.',
|
'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',
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -14,4 +14,7 @@ return [
|
||||||
// The difference between a marketing mail and an account mail, said out
|
// The difference between a marketing mail and an account mail, said out
|
||||||
// loud: this one has no unsubscribe link, and that is allowed.
|
// 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.',
|
'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',
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,43 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
'eyebrow' => '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.',
|
||||||
|
];
|
||||||
|
|
@ -100,6 +100,7 @@
|
||||||
<a href="{{ route('legal.datenschutz') }}" class="transition-colors hover:text-ink">Datenschutz</a>
|
<a href="{{ route('legal.datenschutz') }}" class="transition-colors hover:text-ink">Datenschutz</a>
|
||||||
<a href="{{ route('legal.agb') }}" class="transition-colors hover:text-ink">AGB</a>
|
<a href="{{ route('legal.agb') }}" class="transition-colors hover:text-ink">AGB</a>
|
||||||
<a href="{{ route('status') }}" class="transition-colors hover:text-ink">Betriebsstatus</a>
|
<a href="{{ route('status') }}" class="transition-colors hover:text-ink">Betriebsstatus</a>
|
||||||
|
<a href="{{ route('security') }}" class="transition-colors hover:text-ink">Echte Adressen erkennen</a>
|
||||||
<a href="{{ route('login') }}" class="transition-colors hover:text-ink">Anmelden</a>
|
<a href="{{ route('login') }}" class="transition-colors hover:text-ink">Anmelden</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -69,6 +69,10 @@
|
||||||
·
|
·
|
||||||
<a href="{{ route('legal.agb') }}" style="color:#6e6e7a;text-decoration:underline;">{{ __('mail.terms') }}</a>
|
<a href="{{ route('legal.agb') }}" style="color:#6e6e7a;text-decoration:underline;">{{ __('mail.terms') }}</a>
|
||||||
</p>
|
</p>
|
||||||
|
<p style="margin:0 0 6px 0;font-size:12px;line-height:18px;color:#6e6e7a;text-align:center;">
|
||||||
|
{{ __('mail.domains_note', ['domains' => implode(' · ', array_keys(App\Support\OfficialDomains::registrable()))]) }}
|
||||||
|
<a href="{{ route('security') }}" style="color:#6e6e7a;text-decoration:underline;">{{ __('mail.domains_link') }}</a>
|
||||||
|
</p>
|
||||||
<p style="margin:0;font-size:12px;line-height:18px;color:#b4b4be;text-align:center;">{{ __('mail.sender_line') }}</p>
|
<p style="margin:0;font-size:12px;line-height:18px;color:#b4b4be;text-align:center;">{{ __('mail.sender_line') }}</p>
|
||||||
<p style="margin:10px 0 0 0;font-size:12px;line-height:18px;color:#b4b4be;text-align:center;">{{ __('mail.transactional_note') }}</p>
|
<p style="margin:10px 0 0 0;font-size:12px;line-height:18px;color:#b4b4be;text-align:center;">{{ __('mail.transactional_note') }}</p>
|
||||||
</td></tr>
|
</td></tr>
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,13 @@
|
||||||
<x-ui.button type="submit" variant="primary" size="md" class="w-full">{{ __('auth.sign_in') }}</x-ui.button>
|
<x-ui.button type="submit" variant="primary" size="md" class="w-full">{{ __('auth.sign_in') }}</x-ui.button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
|
||||||
|
{{-- 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. --}}
|
||||||
|
<p class="mt-6 border-t border-line pt-4 text-xs leading-relaxed text-muted">
|
||||||
|
{{ __('auth.phishing_note', ['host' => request()->getHost()]) }}
|
||||||
|
<a href="{{ route('security') }}" class="font-medium text-accent-text underline-offset-4 hover:underline">{{ __('auth.phishing_link') }}</a>
|
||||||
|
</p>
|
||||||
<p class="mt-9 border-t border-line pt-6 text-center text-sm text-muted">
|
<p class="mt-9 border-t border-line pt-6 text-center text-sm text-muted">
|
||||||
{{ __('auth.no_account') }}
|
{{ __('auth.no_account') }}
|
||||||
<a href="{{ route('register') }}" wire:navigate class="font-medium text-accent-text hover:underline">{{ __('auth.sign_up') }}</a>
|
<a href="{{ route('register') }}" wire:navigate class="font-medium text-accent-text hover:underline">{{ __('auth.sign_up') }}</a>
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
--}}
|
||||||
|
<x-layouts.site :title="__('security.title').' — CluPilot Cloud'">
|
||||||
|
|
||||||
|
<article class="mx-auto max-w-[820px] px-5 pb-24 pt-32 sm:px-6 lg:pt-36">
|
||||||
|
<p class="lbl">{{ __('security.eyebrow') }}</p>
|
||||||
|
<h1 class="mt-4 text-[clamp(2rem,4vw,3rem)] font-bold leading-[1.08] tracking-[-0.035em] text-ink">{{ __('security.title') }}</h1>
|
||||||
|
<p class="mt-5 max-w-[62ch] text-md leading-relaxed text-muted">{{ __('security.lead') }}</p>
|
||||||
|
|
||||||
|
{{-- ── The addresses ────────────────────────────────────────────────── --}}
|
||||||
|
<section class="mt-12">
|
||||||
|
<h2 class="text-lg font-bold tracking-[-0.02em] text-ink">{{ __('security.domains_title') }}</h2>
|
||||||
|
<p class="mt-2 max-w-[62ch] text-sm leading-relaxed text-muted">{{ __('security.domains_body') }}</p>
|
||||||
|
|
||||||
|
<div class="mt-6 overflow-hidden rounded-xl border border-line bg-surface shadow-xs">
|
||||||
|
@foreach (App\Support\OfficialDomains::registrable() as $domain => $role)
|
||||||
|
<div class="flex flex-wrap items-center gap-x-4 gap-y-1 border-b border-line px-6 py-4 last:border-0">
|
||||||
|
<x-ui.icon name="shield-check" class="size-4 shrink-0 text-success" />
|
||||||
|
<b class="font-mono text-md text-ink">{{ $domain }}</b>
|
||||||
|
<span class="text-sm text-muted">{{ __('security.domain_'.$role) }}</span>
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="mt-4 text-sm leading-relaxed text-muted">{{ __('security.domains_rule') }}</p>
|
||||||
|
|
||||||
|
<div class="mt-6 rounded-xl border border-line bg-surface-2 p-6">
|
||||||
|
<p class="lbl">{{ __('security.hosts_title') }}</p>
|
||||||
|
<ul class="mt-4 space-y-2">
|
||||||
|
@foreach (App\Support\OfficialDomains::hosts() as $entry)
|
||||||
|
<li class="flex flex-wrap items-baseline gap-x-4 gap-y-1 text-sm">
|
||||||
|
<span class="font-mono text-ink">{{ $entry['host'] }}</span>
|
||||||
|
<span class="text-muted">{{ __('security.host_'.$entry['what']) }}</span>
|
||||||
|
</li>
|
||||||
|
@endforeach
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{{-- ── What we never do ─────────────────────────────────────────────── --}}
|
||||||
|
<section class="mt-14">
|
||||||
|
<h2 class="text-lg font-bold tracking-[-0.02em] text-ink">{{ __('security.never_title') }}</h2>
|
||||||
|
<div class="mt-5 space-y-3">
|
||||||
|
@foreach (['password', 'card', 'remote', 'urgency'] as $never)
|
||||||
|
<div class="flex gap-3 rounded-lg border border-line bg-surface px-5 py-4">
|
||||||
|
<x-ui.icon name="x" class="mt-0.5 size-4 shrink-0 text-danger" />
|
||||||
|
<p class="text-sm leading-relaxed text-body">{{ __('security.never_'.$never) }}</p>
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{{-- ── What to do ───────────────────────────────────────────────────── --}}
|
||||||
|
<section class="mt-14">
|
||||||
|
<h2 class="text-lg font-bold tracking-[-0.02em] text-ink">{{ __('security.compromised_title') }}</h2>
|
||||||
|
<p class="mt-2 max-w-[62ch] text-sm leading-relaxed text-muted">{{ __('security.compromised_lead') }}</p>
|
||||||
|
|
||||||
|
<ol class="mt-6 space-y-4">
|
||||||
|
@foreach (['change', 'sessions', 'others', 'tell'] as $i => $step)
|
||||||
|
<li class="relative flex gap-4 rounded-xl border border-line bg-surface p-6 shadow-xs">
|
||||||
|
<span class="grid size-8 shrink-0 place-items-center rounded-pill bg-accent-subtle font-mono text-sm font-medium text-accent-text">{{ $i + 1 }}</span>
|
||||||
|
<div>
|
||||||
|
<b class="block font-semibold text-ink">{{ __('security.step_'.$step) }}</b>
|
||||||
|
<p class="mt-1 max-w-[58ch] text-sm leading-relaxed text-muted">{{ __('security.step_'.$step.'_body') }}</p>
|
||||||
|
@if ($step === 'change')
|
||||||
|
<x-ui.button href="{{ route('login') }}" size="sm" class="mt-4">{{ __('security.step_change_action') }}</x-ui.button>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
@endforeach
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<div class="mt-8 rounded-xl border border-accent-border bg-accent-subtle p-6">
|
||||||
|
<p class="text-sm leading-relaxed text-body">{{ __('security.tell_us') }}</p>
|
||||||
|
<a href="mailto:office@clupilot.com" class="mt-3 inline-flex items-center gap-2 text-sm font-semibold text-accent-text underline-offset-4 hover:underline">
|
||||||
|
<x-ui.icon name="mail" class="size-4" />office@clupilot.com
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
</x-layouts.site>
|
||||||
|
|
@ -180,6 +180,11 @@ $publicSite = function () {
|
||||||
|
|
||||||
// Generated, not a static file: while the site is hidden this has to say
|
// 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.
|
// 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 () {
|
Route::get('/robots.txt', function () {
|
||||||
$body = App\Support\Settings::bool('site.public', true)
|
$body = App\Support\Settings::bool('site.public', true)
|
||||||
? "User-agent: *\nAllow: /\n"
|
? "User-agent: *\nAllow: /\n"
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,79 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Support\OfficialDomains;
|
||||||
|
use App\Support\Settings;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Telling a customer which addresses are ours.
|
||||||
|
*
|
||||||
|
* The failure mode is specific and bad in both directions: a stale list tells
|
||||||
|
* somebody that a phishing domain is one of ours, or that one of ours is not.
|
||||||
|
* So the list is derived from configuration and nothing here is allowed to be
|
||||||
|
* a literal.
|
||||||
|
*/
|
||||||
|
beforeEach(function () {
|
||||||
|
config()->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'));
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue