Add API-only Traefik CORS middleware labels while preserving configured origins and the existing rollout/load-balancer behavior.
250 lines
8.0 KiB
PHP
250 lines
8.0 KiB
PHP
<?php
|
|
|
|
namespace classes;
|
|
|
|
class cors_policy
|
|
{
|
|
public const ALLOWED_HEADERS = 'Content-Type, Authorization, X-Customer-Number, X-Release-Trace, X-Release-Channel, X-Frontend-Version, Cache-Control, Pragma, *';
|
|
public const ALLOWED_METHODS = 'GET, POST, PUT, PATCH, DELETE, OPTIONS';
|
|
public const EXPOSED_HEADERS = 'Server-Timing';
|
|
public const MAX_AGE_SECONDS = '86400';
|
|
|
|
private const REQUIRED_ALLOWED_ORIGINS = [
|
|
'https://truckwash.io',
|
|
'https://www.truckwash.io',
|
|
'https://api.truckwash.io',
|
|
'https://api.truckwash.io:4433',
|
|
'https://api-v2.truckwash.io',
|
|
'https://web.truckwash.dk',
|
|
'https://api.truckwash.dk',
|
|
'https://truckwash.dk',
|
|
'https://www.truckwash.dk',
|
|
'https://staging.truckwash.io',
|
|
'http://localhost',
|
|
'https://localhost',
|
|
'http://localhost:4433',
|
|
'https://localhost:4433',
|
|
'https://twdev.jeppeb.dk',
|
|
'http://localhost:5173',
|
|
'http://localhost:5174',
|
|
'http://127.0.0.1:5173',
|
|
'http://127.0.0.1:5174',
|
|
'capacitor://localhost',
|
|
];
|
|
|
|
public static function normalizeOrigin(?string $value): string
|
|
{
|
|
$value = trim((string)$value);
|
|
if ($value === '' || $value === '*') {
|
|
return $value;
|
|
}
|
|
|
|
if (preg_match('#^[a-z][a-z0-9+.-]*://#i', $value) !== 1) {
|
|
return '';
|
|
}
|
|
|
|
$parts = parse_url($value);
|
|
if (!is_array($parts) || empty($parts['scheme']) || empty($parts['host'])) {
|
|
return '';
|
|
}
|
|
|
|
$scheme = strtolower((string)$parts['scheme']);
|
|
if (!in_array($scheme, ['http', 'https', 'capacitor'], true)) {
|
|
return '';
|
|
}
|
|
|
|
$host = strtolower((string)$parts['host']);
|
|
$port = isset($parts['port']) ? ':' . (int)$parts['port'] : '';
|
|
|
|
return $scheme . '://' . $host . $port;
|
|
}
|
|
|
|
public static function normalizeRequestOrigin(?string $value): string
|
|
{
|
|
$value = trim((string)$value);
|
|
if ($value === '' || $value === '*') {
|
|
return '';
|
|
}
|
|
|
|
$parts = parse_url($value);
|
|
if (!is_array($parts)) {
|
|
return '';
|
|
}
|
|
|
|
foreach (['user', 'pass', 'path', 'query', 'fragment'] as $disallowedPart) {
|
|
if (array_key_exists($disallowedPart, $parts)) {
|
|
return '';
|
|
}
|
|
}
|
|
|
|
return self::normalizeOrigin($value);
|
|
}
|
|
|
|
/**
|
|
* @return array<int,string>
|
|
*/
|
|
public static function requiredAllowedOrigins(): array
|
|
{
|
|
return self::REQUIRED_ALLOWED_ORIGINS;
|
|
}
|
|
|
|
/**
|
|
* @return array<int,string>
|
|
*/
|
|
public static function traefikHeadersMiddlewareLabels(string $middlewareName, string $corsConfig = ''): array
|
|
{
|
|
$middlewareName = trim($middlewareName);
|
|
if ($middlewareName === '' || preg_match('/^[a-zA-Z0-9-]+$/', $middlewareName) !== 1) {
|
|
return [];
|
|
}
|
|
|
|
$allowedHeaders = array_values(array_filter(
|
|
array_map('trim', explode(',', self::ALLOWED_HEADERS)),
|
|
static fn(string $header): bool => $header !== '' && $header !== '*'
|
|
));
|
|
$allowedMethods = array_values(array_filter(array_map('trim', explode(',', self::ALLOWED_METHODS))));
|
|
$exposedHeaders = array_values(array_filter(array_map('trim', explode(',', self::EXPOSED_HEADERS))));
|
|
$prefix = "traefik.http.middlewares.{$middlewareName}.headers";
|
|
$allowedOrigins = self::allowedOrigins($corsConfig);
|
|
$originLabel = $allowedOrigins === ['*']
|
|
? "{$prefix}.accesscontrolalloworiginlistregex=^(https?://[^/]+|capacitor://[^/]+)$"
|
|
: "{$prefix}.accesscontrolalloworiginlist=" . implode(',', $allowedOrigins);
|
|
|
|
return [
|
|
"{$prefix}.accesscontrolallowcredentials=true",
|
|
"{$prefix}.accesscontrolallowheaders=" . implode(',', $allowedHeaders),
|
|
"{$prefix}.accesscontrolallowmethods=" . implode(',', $allowedMethods),
|
|
$originLabel,
|
|
"{$prefix}.accesscontrolexposeheaders=" . implode(',', $exposedHeaders),
|
|
"{$prefix}.accesscontrolmaxage=" . self::MAX_AGE_SECONDS,
|
|
"{$prefix}.addvaryheader=true",
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @return array<int,string>
|
|
*/
|
|
public static function allowedOrigins(string $corsConfig): array
|
|
{
|
|
$origins = [];
|
|
foreach (self::splitOrigins($corsConfig) as $configuredOrigin) {
|
|
if ($configuredOrigin === '*') {
|
|
return ['*'];
|
|
}
|
|
|
|
$origin = self::normalizeOrigin($configuredOrigin);
|
|
if ($origin !== '') {
|
|
$origins[$origin] = true;
|
|
}
|
|
}
|
|
|
|
foreach (self::REQUIRED_ALLOWED_ORIGINS as $requiredOrigin) {
|
|
$origin = self::normalizeOrigin($requiredOrigin);
|
|
if ($origin !== '') {
|
|
$origins[$origin] = true;
|
|
}
|
|
}
|
|
|
|
return array_keys($origins);
|
|
}
|
|
|
|
public static function withRequiredOrigins(string $corsConfig): string
|
|
{
|
|
$allowedOrigins = self::allowedOrigins($corsConfig);
|
|
if ($allowedOrigins === ['*']) {
|
|
return '*';
|
|
}
|
|
|
|
return implode(',', $allowedOrigins);
|
|
}
|
|
|
|
public static function isOriginAllowed(?string $origin, string $corsConfig): bool
|
|
{
|
|
$origin = self::normalizeRequestOrigin($origin);
|
|
if ($origin === '') {
|
|
return false;
|
|
}
|
|
|
|
$allowedOrigins = self::allowedOrigins($corsConfig);
|
|
return in_array('*', $allowedOrigins, true) || in_array($origin, $allowedOrigins, true);
|
|
}
|
|
|
|
/**
|
|
* @return array<string,string>
|
|
*/
|
|
public static function responseHeaders(?string $origin, string $corsConfig): array
|
|
{
|
|
$origin = self::normalizeRequestOrigin($origin);
|
|
if ($origin === '' || !self::isOriginAllowed($origin, $corsConfig)) {
|
|
return [];
|
|
}
|
|
|
|
return [
|
|
'Access-Control-Allow-Origin' => $origin,
|
|
'Access-Control-Allow-Credentials' => 'true',
|
|
'Access-Control-Allow-Headers' => self::ALLOWED_HEADERS,
|
|
'Access-Control-Allow-Methods' => self::ALLOWED_METHODS,
|
|
'Access-Control-Expose-Headers' => self::EXPOSED_HEADERS,
|
|
'Access-Control-Max-Age' => self::MAX_AGE_SECONDS,
|
|
'Timing-Allow-Origin' => $origin,
|
|
'Vary' => 'Origin',
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @return array{allowed:bool,status:int,headers:array<string,string>,body:string}
|
|
*/
|
|
public static function preflightResponse(?string $origin, string $corsConfig): array
|
|
{
|
|
$headers = self::responseHeaders($origin, $corsConfig);
|
|
if ($headers === []) {
|
|
return [
|
|
'allowed' => false,
|
|
'status' => 403,
|
|
'headers' => ['Content-Type' => 'application/json'],
|
|
'body' => json_encode(['success' => false, 'message' => 'CORS origin not allowed']) ?: '',
|
|
];
|
|
}
|
|
|
|
$headers['Content-Type'] = 'application/json';
|
|
return [
|
|
'allowed' => true,
|
|
'status' => 200,
|
|
'headers' => $headers,
|
|
'body' => '',
|
|
];
|
|
}
|
|
|
|
public static function applyResponseHeaders(string $corsConfig, ?string $origin = null): bool
|
|
{
|
|
$headers = self::responseHeaders($origin ?? ($_SERVER['HTTP_ORIGIN'] ?? ''), $corsConfig);
|
|
if ($headers === []) {
|
|
return false;
|
|
}
|
|
|
|
self::emitHeaders($headers);
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* @param array<string,string> $headers
|
|
*/
|
|
public static function emitHeaders(array $headers): void
|
|
{
|
|
foreach ($headers as $name => $value) {
|
|
header($name . ': ' . $value, strtolower((string)$name) !== 'vary');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @return array<int,string>
|
|
*/
|
|
private static function splitOrigins(string $corsConfig): array
|
|
{
|
|
return array_values(array_filter(
|
|
array_map('trim', explode(',', $corsConfig)),
|
|
static fn(string $origin): bool => $origin !== ''
|
|
));
|
|
}
|
|
}
|