Files
awo-hamburg-intranet/mysyde/frontend/frontend_userdata_controller.php

683 lines
19 KiB
PHP
Raw Normal View History

<?php
declare(strict_types=1);
const SESSION_COOKIE_CANDIDATES = ['sidintranet', 'sidcms', 'mysyde', 'PHPSESSID', 'sidawo'];
const LOGIN_REDIRECT = '/';
const USERDATA_BASE_DIR = __DIR__ . '/../../userdata';
const USE_X_SENDFILE = true;
const ALLOW_PHP_STREAM_FALLBACK = true;
const MEDIA_CACHE_MAX_AGE = 3600;
const TRUSTED_PROXY_HEADER = true;
const STREAM_CHUNK_SIZE = 1048576;
const ENABLE_INLINE_SVG_FOR_IMAGE_REQUESTS = true;
const ENABLE_TEMP_SESSION_DEBUG = false;
// Optional hardening: e.g. ['icons/', 'logos/']; empty array allows all paths.
const INLINE_SVG_ALLOWED_PATH_PREFIXES = [];
// Main flow:
// 1. Start session (cookie hardening)
// 2. Enforce authentication
// 3. Validate + deliver requested file (X-Sendfile preferred, PHP fallback)
bootstrapSession();
maybeOutputSessionDebug();
requireAuthenticatedUser();
serveRequestedFile();
function bootstrapSession(): void
{
ini_set('session.use_only_cookies', '1');
ini_set('session.use_strict_mode', '1');
session_set_cookie_params([
'lifetime' => 0,
'path' => '/',
'secure' => isSecureConnection(),
'httponly' => true,
'samesite' => 'Lax',
]);
$availableCookies = [];
foreach (SESSION_COOKIE_CANDIDATES as $cookieName) {
if (isset($_COOKIE[$cookieName]) && $_COOKIE[$cookieName] !== '') {
$availableCookies[] = $cookieName;
}
}
if ($availableCookies === []) {
session_start();
return;
}
foreach ($availableCookies as $cookieName) {
if (session_name() !== $cookieName) {
session_name($cookieName);
}
session_id((string)$_COOKIE[$cookieName]);
session_start();
if (isCurrentSessionAuthenticated()) {
return;
}
session_write_close();
}
// No authenticated session found: reopen the first available session.
$fallbackCookie = $availableCookies[0];
if (session_name() !== $fallbackCookie) {
session_name($fallbackCookie);
}
session_id((string)$_COOKIE[$fallbackCookie]);
session_start();
}
function requireAuthenticatedUser(): void
{
$loginId = (int)($_SESSION['login_id'] ?? 0);
$adminId = (int)(
$_SESSION['id']
?? $_SESSION['main_admin_user_id']
?? $_SESSION['admin_user_id']
?? 0
);
$sidCmsCookie = (string)($_COOKIE['sidcms'] ?? '');
$hasAdminSid = (
session_name() === 'sidcms'
&& $sidCmsCookie !== ''
&& (
hash_equals($sidCmsCookie, session_id())
|| hash_equals($sidCmsCookie, (string)($_SESSION['sidcms'] ?? ''))
)
);
$hasAdminAuthFlags = isAdminSessionFlagSet();
if ($loginId <= 0 && !($hasAdminSid && ($adminId > 0 || $hasAdminAuthFlags))) {
header('Location: ' . LOGIN_REDIRECT, true, 302);
exit;
}
// Release session lock early so parallel media requests do not block.
session_write_close();
}
function maybeOutputSessionDebug(): void
{
if (!ENABLE_TEMP_SESSION_DEBUG) {
return;
}
$debugFlag = strtolower(trim((string)($_GET['__debug_session'] ?? '0')));
if (!in_array($debugFlag, ['1', 'true', 'yes'], true)) {
return;
}
$sessionId = session_id();
$sidCmsCookie = (string)($_COOKIE['sidcms'] ?? '');
$loginId = (int)($_SESSION['login_id'] ?? 0);
$adminId = (int)(
$_SESSION['id']
?? $_SESSION['main_admin_user_id']
?? $_SESSION['admin_user_id']
?? 0
);
$hasAdminSid = (
session_name() === 'sidcms'
&& $sidCmsCookie !== ''
&& (
hash_equals($sidCmsCookie, $sessionId)
|| hash_equals($sidCmsCookie, (string)($_SESSION['sidcms'] ?? ''))
)
);
$hasAdminAuthFlags = isAdminSessionFlagSet();
$payload = [
'debug' => true,
'session_name' => session_name(),
'session_id' => $sessionId,
'cookie_candidates_present' => array_values(array_filter(
SESSION_COOKIE_CANDIDATES,
static fn(string $name): bool => !empty($_COOKIE[$name])
)),
'auth_evaluation' => [
'login_id' => $loginId,
'admin_id' => $adminId,
'has_admin_sid' => $hasAdminSid,
'has_admin_auth_flags' => $hasAdminAuthFlags,
'would_pass_auth' => ($loginId > 0) || ($hasAdminSid && ($adminId > 0 || $hasAdminAuthFlags)),
],
'session' => $_SESSION,
'cookies' => $_COOKIE,
'request' => [
'method' => (string)($_SERVER['REQUEST_METHOD'] ?? ''),
'uri' => (string)($_SERVER['REQUEST_URI'] ?? ''),
'remote_addr' => (string)($_SERVER['REMOTE_ADDR'] ?? ''),
],
];
while (ob_get_level() > 0) {
ob_end_clean();
}
header('Content-Type: application/json; charset=UTF-8');
header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0');
header('Pragma: no-cache');
echo json_encode($payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
exit;
}
function isCurrentSessionAuthenticated(): bool
{
$loginId = (int)($_SESSION['login_id'] ?? 0);
if ($loginId > 0) {
return true;
}
if (session_name() !== 'sidcms') {
return false;
}
$adminId = (int)(
$_SESSION['id']
?? $_SESSION['main_admin_user_id']
?? $_SESSION['admin_user_id']
?? 0
);
if ($adminId <= 0 && !isAdminSessionFlagSet()) {
return false;
}
$sidCmsCookie = (string)($_COOKIE['sidcms'] ?? '');
if ($sidCmsCookie === '') {
return false;
}
return hash_equals($sidCmsCookie, session_id())
|| hash_equals($sidCmsCookie, (string)($_SESSION['sidcms'] ?? ''));
}
function isAdminSessionFlagSet(): bool
{
if (!empty($_SESSION['IsAuthorized'])) {
return true;
}
if ((int)($_SESSION['session_login'] ?? 0) > 0) {
return true;
}
if ((int)($_SESSION['admin_login'] ?? 0) > 0) {
return true;
}
return false;
}
function serveRequestedFile(): void
{
$requestMethod = strtoupper((string)($_SERVER['REQUEST_METHOD'] ?? 'GET'));
if (!in_array($requestMethod, ['GET', 'HEAD'], true)) {
header('Allow: GET, HEAD', true, 405);
exit;
}
$relativePath = requestedRelativePath();
if ($relativePath === '') {
http_response_code(400);
echo 'Bad Request';
exit;
}
$baseDir = realpath(USERDATA_BASE_DIR);
if ($baseDir === false || !is_dir($baseDir)) {
http_response_code(500);
echo 'Storage Not Configured';
exit;
}
$resolvedPath = realpath($baseDir . DIRECTORY_SEPARATOR . $relativePath);
if ($resolvedPath === false || !is_file($resolvedPath) || !is_readable($resolvedPath)) {
http_response_code(404);
echo 'Not Found';
exit;
}
if (!isInsideBasePath($resolvedPath, $baseDir)) {
http_response_code(403);
echo 'Forbidden';
exit;
}
deliverFile($resolvedPath, $relativePath, $requestMethod === 'HEAD');
}
function requestedRelativePath(): string
{
// PHP already decodes $_GET values; do not decode again.
$rawPath = (string)($_GET['file'] ?? '');
$rawPath = trim(str_replace('\\', '/', $rawPath));
$rawPath = ltrim($rawPath, '/');
$rawPath = preg_replace('#/+#', '/', $rawPath) ?? '';
if ($rawPath === '' || str_contains($rawPath, "\0")) {
return '';
}
$segments = explode('/', $rawPath);
foreach ($segments as $segment) {
if ($segment === '' || $segment === '.' || $segment === '..') {
return '';
}
}
return $rawPath;
}
function isInsideBasePath(string $resolvedPath, string $baseDir): bool
{
$base = rtrim($baseDir, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR;
return strncmp($resolvedPath, $base, strlen($base)) === 0;
}
function deliverFile(string $absolutePath, string $relativePath, bool $headOnly): void
{
$fileSize = filesize($absolutePath);
if ($fileSize === false || $fileSize < 0) {
http_response_code(500);
echo 'Unable to read file metadata';
exit;
}
$lastModifiedTs = filemtime($absolutePath);
if ($lastModifiedTs === false) {
$lastModifiedTs = time();
}
$mimeType = detectSafeMimeType($absolutePath, $relativePath);
$isInlineSvg = ($mimeType === 'image/svg+xml');
$isMedia = isMediaMimeType($mimeType);
$etag = buildEtag($absolutePath, $fileSize, $lastModifiedTs);
$ifNoneMatch = trim((string)($_SERVER['HTTP_IF_NONE_MATCH'] ?? ''));
if ($ifNoneMatch !== '' && requestEtagMatches($ifNoneMatch, $etag)) {
sendNotModified($etag, $lastModifiedTs, $isMedia, $isInlineSvg);
}
$ifModifiedSince = trim((string)($_SERVER['HTTP_IF_MODIFIED_SINCE'] ?? ''));
if ($ifNoneMatch === '' && $ifModifiedSince !== '' && requestNotModifiedSince($ifModifiedSince, $lastModifiedTs)) {
sendNotModified($etag, $lastModifiedTs, $isMedia, $isInlineSvg);
}
$useXSendfile = shouldUseXSendfile();
$rangeRequest = ['status' => 'none'];
if ($isMedia && !$useXSendfile) {
$rangeRequest = parseRangeHeader((string)($_SERVER['HTTP_RANGE'] ?? ''), $fileSize);
if ($rangeRequest['status'] === 'invalid') {
sendRangeNotSatisfiable($fileSize, $etag, $lastModifiedTs, $isMedia);
}
}
sendSharedSecurityHeaders($isInlineSvg);
sendCacheHeaders($isMedia);
header('Content-Type: ' . $mimeType);
header('ETag: "' . $etag . '"');
header('Last-Modified: ' . gmdate('D, d M Y H:i:s', $lastModifiedTs) . ' GMT');
header('Content-Disposition: inline; filename="' . sanitizeFilenameForHeader(basename($absolutePath)) . '"');
if ($isMedia) {
header('Accept-Ranges: bytes');
}
if ($useXSendfile) {
header('X-Sendfile: ' . $absolutePath);
exit;
}
if ($rangeRequest['status'] !== 'none' || ALLOW_PHP_STREAM_FALLBACK || !USE_X_SENDFILE) {
streamFileWithPhp($absolutePath, $fileSize, $headOnly, $rangeRequest);
}
// X-Sendfile requested, but not available and fallback disabled.
http_response_code(503);
echo 'File delivery backend unavailable';
exit;
}
function sendSharedSecurityHeaders(bool $isInlineSvg = false): void
{
if ($isInlineSvg) {
// Inline SVG only for image requests with a strict CSP.
header("Content-Security-Policy: default-src 'none'; img-src data:; style-src 'unsafe-inline'; script-src 'none'; object-src 'none'; base-uri 'none'; sandbox");
header('X-Content-Type-Options: nosniff');
return;
}
header("Content-Security-Policy: default-src 'none'; style-src 'unsafe-inline'; img-src data:; sandbox");
header('X-Content-Type-Options: nosniff');
}
function sendCacheHeaders(bool $isMedia): void
{
if ($isMedia) {
header('Cache-Control: private, max-age=' . (string)MEDIA_CACHE_MAX_AGE . ', must-revalidate');
return;
}
header('Cache-Control: private, no-store, no-cache, must-revalidate, max-age=0');
header('Pragma: no-cache');
header('Expires: 0');
}
function sendNotModified(string $etag, int $lastModifiedTs, bool $isMedia, bool $isInlineSvg = false): void
{
http_response_code(304);
sendSharedSecurityHeaders($isInlineSvg);
sendCacheHeaders($isMedia);
header('ETag: "' . $etag . '"');
header('Last-Modified: ' . gmdate('D, d M Y H:i:s', $lastModifiedTs) . ' GMT');
exit;
}
function sendRangeNotSatisfiable(int $fileSize, string $etag, int $lastModifiedTs, bool $isMedia): void
{
http_response_code(416);
sendSharedSecurityHeaders();
sendCacheHeaders($isMedia);
header('ETag: "' . $etag . '"');
header('Last-Modified: ' . gmdate('D, d M Y H:i:s', $lastModifiedTs) . ' GMT');
header('Content-Range: bytes */' . (string)$fileSize);
exit;
}
function shouldUseXSendfile(): bool
{
if (!USE_X_SENDFILE) {
return false;
}
if (!function_exists('apache_get_modules')) {
return false;
}
$modules = apache_get_modules();
return in_array('mod_xsendfile', $modules, true) || in_array('mod_xsendfile.c', $modules, true);
}
function streamFileWithPhp(string $absolutePath, int $fileSize, bool $headOnly, array $rangeRequest): void
{
$start = 0;
$end = $fileSize > 0 ? $fileSize - 1 : 0;
$statusCode = 200;
if ($rangeRequest['status'] === 'range') {
$start = $rangeRequest['start'];
$end = $rangeRequest['end'];
$statusCode = 206;
header('Content-Range: bytes ' . $start . '-' . $end . '/' . $fileSize);
}
$length = ($fileSize > 0) ? ($end - $start + 1) : 0;
http_response_code($statusCode);
header('Content-Length: ' . (string)$length);
if ($headOnly || $length <= 0) {
exit;
}
$handle = fopen($absolutePath, 'rb');
if ($handle === false) {
http_response_code(500);
echo 'Unable to open file stream';
exit;
}
while (ob_get_level() > 0) {
ob_end_clean();
}
if ($start > 0) {
fseek($handle, $start);
}
$bytesRemaining = $length;
while ($bytesRemaining > 0 && !feof($handle)) {
$chunkSize = (int)min(STREAM_CHUNK_SIZE, $bytesRemaining);
$buffer = fread($handle, $chunkSize);
if ($buffer === false || $buffer === '') {
break;
}
echo $buffer;
flush();
$bytesRemaining -= strlen($buffer);
}
fclose($handle);
exit;
}
function parseRangeHeader(string $rangeHeader, int $fileSize): array
{
$rangeHeader = trim($rangeHeader);
if ($rangeHeader === '') {
return ['status' => 'none'];
}
if ($fileSize <= 0 || !str_starts_with($rangeHeader, 'bytes=')) {
return ['status' => 'invalid'];
}
$rangeValue = trim(substr($rangeHeader, 6));
if ($rangeValue === '' || str_contains($rangeValue, ',')) {
return ['status' => 'invalid'];
}
if (!preg_match('/^(\d*)-(\d*)$/', $rangeValue, $matches)) {
return ['status' => 'invalid'];
}
$startRaw = $matches[1];
$endRaw = $matches[2];
if ($startRaw === '' && $endRaw === '') {
return ['status' => 'invalid'];
}
if ($startRaw === '') {
$suffixLength = (int)$endRaw;
if ($suffixLength <= 0) {
return ['status' => 'invalid'];
}
$suffixLength = min($suffixLength, $fileSize);
$start = $fileSize - $suffixLength;
$end = $fileSize - 1;
return ['status' => 'range', 'start' => $start, 'end' => $end];
}
$start = (int)$startRaw;
if ($start >= $fileSize) {
return ['status' => 'invalid'];
}
if ($endRaw === '') {
$end = $fileSize - 1;
} else {
$end = (int)$endRaw;
if ($end < $start) {
return ['status' => 'invalid'];
}
$end = min($end, $fileSize - 1);
}
return ['status' => 'range', 'start' => $start, 'end' => $end];
}
function requestEtagMatches(string $ifNoneMatchHeader, string $etag): bool
{
$ifNoneMatchHeader = trim($ifNoneMatchHeader);
if ($ifNoneMatchHeader === '*') {
return true;
}
$parts = explode(',', $ifNoneMatchHeader);
foreach ($parts as $part) {
$candidate = trim($part);
if ($candidate === '') {
continue;
}
if (str_starts_with($candidate, 'W/')) {
$candidate = substr($candidate, 2);
}
$candidate = trim($candidate, " \t\n\r\0\x0B\"");
if ($candidate === $etag) {
return true;
}
}
return false;
}
function requestNotModifiedSince(string $ifModifiedSinceHeader, int $lastModifiedTs): bool
{
$requestTs = strtotime($ifModifiedSinceHeader);
if ($requestTs === false) {
return false;
}
return $lastModifiedTs <= $requestTs;
}
function buildEtag(string $absolutePath, int $fileSize, int $lastModifiedTs): string
{
return sha1($absolutePath . '|' . $fileSize . '|' . $lastModifiedTs);
}
function isMediaMimeType(string $mimeType): bool
{
$normalized = strtolower(trim($mimeType));
if ($normalized === '') {
return false;
}
if (str_starts_with($normalized, 'image/') || str_starts_with($normalized, 'video/') || str_starts_with($normalized, 'audio/')) {
return true;
}
return in_array($normalized, [
'application/dash+xml',
'application/x-mpegurl',
'application/vnd.apple.mpegurl',
'vnd.apple.mpegurl',
], true);
}
function detectSafeMimeType(string $absolutePath, string $relativePath): string
{
$mimeType = 'application/octet-stream';
$finfo = finfo_open(FILEINFO_MIME_TYPE);
if ($finfo !== false) {
$detected = finfo_file($finfo, $absolutePath);
finfo_close($finfo);
if (is_string($detected) && $detected !== '') {
$mimeType = $detected;
}
}
$dangerousMimeTypes = [
'text/html',
'application/xhtml+xml',
'image/svg+xml',
'text/xml',
'application/xml',
'text/xsl',
'application/javascript',
'text/javascript',
];
if ($mimeType === 'image/svg+xml' && shouldServeSvgInline($relativePath)) {
return $mimeType;
}
if (in_array($mimeType, $dangerousMimeTypes, true)) {
return 'application/octet-stream';
}
return $mimeType;
}
function shouldServeSvgInline(string $relativePath): bool
{
if (!ENABLE_INLINE_SVG_FOR_IMAGE_REQUESTS) {
return false;
}
$fetchDest = strtolower(trim((string)($_SERVER['HTTP_SEC_FETCH_DEST'] ?? '')));
if ($fetchDest !== '' && $fetchDest !== 'image') {
return false;
}
if ($fetchDest === '') {
$accept = strtolower(trim((string)($_SERVER['HTTP_ACCEPT'] ?? '')));
if ($accept === '' || !str_contains($accept, 'image/')) {
return false;
}
}
$normalizedPath = strtolower(ltrim(str_replace('\\', '/', $relativePath), '/'));
if ($normalizedPath === '') {
return false;
}
if (INLINE_SVG_ALLOWED_PATH_PREFIXES === []) {
return true;
}
foreach (INLINE_SVG_ALLOWED_PATH_PREFIXES as $prefix) {
$normalizedPrefix = strtolower(ltrim(str_replace('\\', '/', (string)$prefix), '/'));
if ($normalizedPrefix === '') {
continue;
}
$normalizedPrefix = rtrim($normalizedPrefix, '/') . '/';
if (str_starts_with($normalizedPath, $normalizedPrefix)) {
return true;
}
}
return false;
}
function sanitizeFilenameForHeader(string $filename): string
{
return preg_replace('/[^A-Za-z0-9._-]/', '_', $filename) ?? 'download';
}
function isSecureConnection(): bool
{
$https = strtolower((string)($_SERVER['HTTPS'] ?? ''));
if ($https !== '' && $https !== 'off') {
return true;
}
if ((int)($_SERVER['SERVER_PORT'] ?? 80) === 443) {
return true;
}
if (TRUSTED_PROXY_HEADER) {
$forwardedProto = strtolower((string)($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? ''));
return $forwardedProto === 'https';
}
return false;
}