154 lines
5.4 KiB
PHP
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;
|
|
}
|
|
}
|
|
}
|