Files
calcserubi/includes/security.php
T
2026-10-20 13:16:38 +09:00

154 lines
5.4 KiB
PHP

<?php
declare(strict_types=1);
/*
* Security baseline, loaded first by every web entry point (via helpers.php):
* error handling, request-method guard, security headers, HTTPS detection,
* and hardened session settings.
*/
require_once __DIR__ . '/db.php';
function security_config(): array
{
return (app_config()['security'] ?? []) + [
'force_https' => false,
'trusted_proxies' => [],
'setup_key' => '',
];
}
/** True when the client reached us over HTTPS (directly or via a trusted reverse proxy). */
function is_https(): bool
{
if (!empty($_SERVER['HTTPS']) && strtolower((string)$_SERVER['HTTPS']) !== 'off') {
return true;
}
if ((int)($_SERVER['SERVER_PORT'] ?? 0) === 443) {
return true;
}
$proxies = security_config()['trusted_proxies'];
if ($proxies && in_array($_SERVER['REMOTE_ADDR'] ?? '', $proxies, true)) {
return strtolower((string)($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '')) === 'https';
}
return false;
}
/** Client IP; X-Forwarded-For is honoured only when the request came from a trusted proxy. */
function client_ip(): string
{
$remote = (string)($_SERVER['REMOTE_ADDR'] ?? '0.0.0.0');
$proxies = security_config()['trusted_proxies'];
if ($proxies && in_array($remote, $proxies, true) && !empty($_SERVER['HTTP_X_FORWARDED_FOR'])) {
$chain = array_map('trim', explode(',', (string)$_SERVER['HTTP_X_FORWARDED_FOR']));
// right-most address that is not one of our proxies
foreach (array_reverse($chain) as $ip) {
if (filter_var($ip, FILTER_VALIDATE_IP) && !in_array($ip, $proxies, true)) {
return substr($ip, 0, 45);
}
}
}
return substr($remote, 0, 45);
}
/** Per-request nonce for the few inline scripts (Content-Security-Policy). */
function csp_nonce(): string
{
static $nonce = null;
if ($nonce === null) {
$nonce = rtrim(strtr(base64_encode(random_bytes(18)), '+/', '-_'), '=');
}
return $nonce;
}
function send_security_headers(bool $sensitive = false): void
{
if (headers_sent()) {
return;
}
header_remove('X-Powered-By');
$csp = [
"default-src 'none'",
"script-src 'self' 'nonce-" . csp_nonce() . "'",
"style-src 'self'",
"img-src 'self'",
"font-src 'self'",
"connect-src 'self'",
"manifest-src 'self'",
"form-action 'self'",
"frame-ancestors 'none'",
"base-uri 'none'",
"object-src 'none'",
];
if (is_https()) {
$csp[] = 'upgrade-insecure-requests';
header('Strict-Transport-Security: max-age=31536000; includeSubDomains');
}
header('Content-Security-Policy: ' . implode('; ', $csp));
header('X-Frame-Options: DENY');
header('X-Content-Type-Options: nosniff');
header('Referrer-Policy: same-origin');
header('Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=(), usb=(), interest-cohort=()');
header('Cross-Origin-Opener-Policy: same-origin');
header('Cross-Origin-Resource-Policy: same-origin');
header('X-Permitted-Cross-Domain-Policies: none');
// Pages always revalidate so they reference the newest versioned assets;
// admin pages are never stored at all.
header($sensitive ? 'Cache-Control: no-store, max-age=0' : 'Cache-Control: no-cache, must-revalidate');
if ($sensitive) {
header('Pragma: no-cache');
}
}
function security_bootstrap(): void
{
static $done = false;
if ($done || PHP_SAPI === 'cli') {
return;
}
$done = true;
// Never leak errors or stack traces to the client.
ini_set('display_errors', '0');
ini_set('display_startup_errors', '0');
ini_set('log_errors', '1');
ini_set('expose_php', '0');
error_reporting(E_ALL);
set_exception_handler(function (Throwable $e): void {
error_log('[serubi] ' . get_class($e) . ': ' . $e->getMessage() . ' in ' . $e->getFile() . ':' . $e->getLine());
if (!headers_sent()) {
http_response_code(500);
header('Content-Type: text/html; charset=utf-8');
header('Cache-Control: no-store');
}
echo '<!doctype html><meta charset="utf-8"><title>Terjadi kesalahan</title><p>Terjadi kesalahan pada server. Silakan coba lagi beberapa saat lagi.</p>';
});
// Hardened session settings (before any session_start()).
ini_set('session.use_strict_mode', '1');
ini_set('session.use_only_cookies', '1');
ini_set('session.use_trans_sid', '0');
ini_set('session.cookie_httponly', '1');
ini_set('session.sid_length', '48');
ini_set('session.sid_bits_per_character', '5');
ini_set('session.gc_maxlifetime', '28800');
// Only GET, HEAD and POST are meaningful here (blocks TRACE/PUT/DELETE/OPTIONS...).
$method = strtoupper((string)($_SERVER['REQUEST_METHOD'] ?? 'GET'));
if (!in_array($method, ['GET', 'HEAD', 'POST'], true)) {
header('Allow: GET, HEAD, POST');
http_response_code(405);
exit;
}
// Optional HTTP -> HTTPS redirect for production.
if (security_config()['force_https'] && !is_https()) {
$host = preg_replace('/[^A-Za-z0-9.\-:\[\]]/', '', (string)($_SERVER['HTTP_HOST'] ?? ''));
$uri = (string)($_SERVER['REQUEST_URI'] ?? '/');
if ($host !== '' && $uri !== '' && $uri[0] === '/') {
header('Location: https://' . $host . $uri, true, 301);
exit;
}
}
}