CluPilotCloud/app/Support/OfficialDomains.php

93 lines
3.5 KiB
PHP

<?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;
}
}