| Server IP : 202.61.199.114 / Your IP : 216.73.217.139 Web Server : nginx/1.22.1 System : Linux de.arni-solutions.de 6.1.0-49-amd64 #1 SMP PREEMPT_DYNAMIC Debian 6.1.174-1 (2026-05-26) x86_64 User : web20 ( 1018) PHP Version : 8.4.23 Disable Function : NONE MySQL : OFF | cURL : ON | WGET : ON | Perl : ON | Python : OFF | Sudo : ON | Pkexec : ON Directory : /var/www/clients/client2/web21/web/api/lib/ |
Upload File : |
<?php
/**
* SSRF-sichere HTTP-/URL-/TLS-Analyse.
*
* Sicherheit:
* - Nur http/https, nur Port 80/443.
* - Jeder Hop (inkl. Redirect-Ziel) wird auf ÖFFENTLICHE IP aufgelöst; interne
* Ziele werden blockiert.
* - Verbindung wird per CURLOPT_RESOLVE auf die geprüfte IP gepinnt
* (Schutz gegen DNS-Rebinding/TOCTOU).
* - Redirects werden manuell begrenzt gefolgt, kein Body-Download über Limit.
*
* @package SafeMyIdent\Api
*/
declare(strict_types=1);
require_once __DIR__ . '/net.php';
/**
* Sicherheits-Header, die wir bewerten.
*
* @return array<string,string>
*/
function smi_http_security_headers(): array
{
return [
'strict-transport-security' => 'HSTS (erzwingt HTTPS)',
'content-security-policy' => 'Content-Security-Policy (Schutz vor XSS/Injection)',
'x-frame-options' => 'X-Frame-Options (Clickjacking-Schutz)',
'x-content-type-options' => 'X-Content-Type-Options (MIME-Sniffing-Schutz)',
'referrer-policy' => 'Referrer-Policy (Datenschutz beim Verweisen)',
'permissions-policy' => 'Permissions-Policy (Feature-Steuerung)',
];
}
/**
* Prüft/pinnt einen Hop: löst Host auf, verweigert interne Ziele & fremde Ports.
*
* @param string $url URL des Hops.
* @return array{ok:bool,host:string,ip:string,port:int,scheme:string,error:string}
*/
function smi_http_pin(string $url): array
{
$fail = ['ok' => false, 'host' => '', 'ip' => '', 'port' => 0, 'scheme' => '', 'error' => ''];
$p = parse_url($url);
if (!is_array($p) || empty($p['host'])) {
$fail['error'] = 'Ungültige URL.';
return $fail;
}
$scheme = strtolower($p['scheme'] ?? 'http');
if (!in_array($scheme, ['http', 'https'], true)) {
$fail['error'] = 'Nur http/https erlaubt.';
return $fail;
}
$host = trim($p['host'], '[]');
$port = (int) ($p['port'] ?? ($scheme === 'https' ? 443 : 80));
if (!in_array($port, [80, 443], true)) {
$fail['error'] = 'Nur Port 80/443 erlaubt.';
return $fail;
}
$ips = smi_resolve_public_ips($host);
if (empty($ips['all'])) {
$fail['error'] = 'Ziel nicht öffentlich auflösbar (blockiert).';
return $fail;
}
// Bevorzugt IPv4 für stabile CURLOPT_RESOLVE-Pinnung.
$ip = $ips['ipv4'][0] ?? $ips['all'][0];
return ['ok' => true, 'host' => $host, 'ip' => $ip, 'port' => $port, 'scheme' => $scheme, 'error' => ''];
}
/**
* Einen einzelnen HTTP-Request (ohne Auto-Redirect) SSRF-sicher ausführen.
*
* @param string $url URL.
* @return array<string,mixed>
*/
function smi_http_once(string $url): array
{
$pin = smi_http_pin($url);
if (!$pin['ok']) {
return ['ok' => false, 'error' => $pin['error'], 'url' => $url];
}
if (!function_exists('curl_init')) {
return ['ok' => false, 'error' => 'cURL nicht verfügbar.', 'url' => $url];
}
$ch = curl_init($url);
$t0 = microtime(true);
$headerLines = [];
$limit = 262144; $downloaded = 0;
curl_setopt_array($ch, [
CURLOPT_NOBODY => false,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_FOLLOWLOCATION => false, // manuell folgen.
CURLOPT_CONNECTTIMEOUT => 6,
CURLOPT_TIMEOUT => 10,
CURLOPT_SSL_VERIFYPEER => true,
CURLOPT_SSL_VERIFYHOST => 2,
CURLOPT_CERTINFO => true,
CURLOPT_USERAGENT => 'SafeMyIdent-IPCheck/1.0 (+https://www.safemyident.de/tools/ip-check/)',
CURLOPT_RESOLVE => [$pin['host'] . ':' . $pin['port'] . ':' . $pin['ip']], // Pin.
CURLOPT_PROTOCOLS => defined('CURLPROTO_HTTP') ? (CURLPROTO_HTTP | CURLPROTO_HTTPS) : 0,
CURLOPT_BUFFERSIZE => 16384,
CURLOPT_HEADERFUNCTION => function ($c, $h) use (&$headerLines) { $headerLines[] = $h; return strlen($h); },
CURLOPT_WRITEFUNCTION => function ($c, $chunk) use (&$downloaded, $limit) {
$downloaded += strlen($chunk);
return $downloaded > $limit ? -1 : strlen($chunk);
},
]);
curl_exec($ch);
$elapsed = (microtime(true) - $t0) * 1000;
$errno = curl_errno($ch);
$info = curl_getinfo($ch);
$status = (int) ($info['http_code'] ?? 0);
curl_close($ch);
// Header aus HEADERFUNCTION (letzter Block bei evtl. mehreren).
$headers = smi_parse_headers(implode('', $headerLines));
if ($status === 0 && $errno !== 0 && $errno !== 23 && $errno !== 42) {
return ['ok' => false, 'error' => 'Verbindung fehlgeschlagen.', 'url' => $url, 'errno' => $errno];
}
// Zertifikat (nur https).
$cert = [];
if (($pin['scheme'] === 'https') && !empty($info['certinfo'][0])) {
$c = $info['certinfo'][0];
$cert = [
'issuer' => smi_cert_field($c['Issuer'] ?? '', 'O') ?: ($c['Issuer'] ?? ''),
'subject' => $c['Subject'] ?? '',
'valid_from'=> $c['Start date'] ?? '',
'valid_to' => $c['Expire date'] ?? '',
];
}
$location = $headers['location'][0] ?? '';
return [
'ok' => true,
'url' => $url,
'status' => $status,
'headers' => $headers,
'location' => $location,
'scheme' => $pin['scheme'],
'ip' => $pin['ip'],
'cert' => $cert,
'time_ms' => round($elapsed),
];
}
/**
* Header-Rohtext → assoziatives Array (lowercase keys, Werte als Liste).
*
* @param string $text Header-Block.
* @return array<string,string[]>
*/
function smi_parse_headers(string $text): array
{
$out = [];
// Nur den LETZTEN Header-Block (nach evtl. 100-continue) nehmen.
$blocks = preg_split("/\r?\n\r?\n/", trim($text));
$block = is_array($blocks) ? end($blocks) : $text;
foreach (preg_split("/\r?\n/", (string) $block) as $line) {
if (str_contains($line, ':')) {
[$k, $v] = explode(':', $line, 2);
$out[strtolower(trim($k))][] = trim($v);
}
}
return $out;
}
/**
* Feld aus einem DN-String (z. B. Issuer "O") lesen.
*
* @param string $dn DN.
* @param string $field Feldname.
* @return string
*/
function smi_cert_field(string $dn, string $field): string
{
if (preg_match('/(?:^|,|\/)\s*' . preg_quote($field, '/') . '\s*=\s*([^,\/]+)/i', $dn, $m)) {
return trim($m[1]);
}
return '';
}
/**
* Vollständige URL-Analyse inkl. Redirect-Kette, TLS & Security-Headern.
*
* @param string $url Start-URL (validiert).
* @return array<string,mixed>
*/
function smi_url_analyze(string $url): array
{
$chain = [];
$current = $url;
$final = null;
$max = 6;
for ($i = 0; $i < $max; $i++) {
$hop = smi_http_once($current);
$chain[] = [
'url' => $current,
'status' => $hop['ok'] ? ($hop['status'] ?? 0) : 0,
'ok' => (bool) $hop['ok'],
'error' => $hop['ok'] ? '' : ($hop['error'] ?? ''),
];
if (!$hop['ok']) {
break;
}
$final = $hop;
$loc = $hop['location'] ?? '';
$status = $hop['status'] ?? 0;
if ($status >= 300 && $status < 400 && $loc !== '') {
// Relativ → absolut.
$current = smi_abs_url($current, $loc);
if ($current === '') {
break;
}
continue;
}
break;
}
if ($final === null) {
return ['ok' => false, 'error' => $chain[0]['error'] ?? 'Analyse fehlgeschlagen.', 'chain' => $chain];
}
// Security-Header auswerten.
$present = [];
foreach (smi_http_security_headers() as $h => $desc) {
$present[$h] = isset($final['headers'][$h]) ? implode('; ', $final['headers'][$h]) : '';
}
$server = isset($final['headers']['server'][0]) ? $final['headers']['server'][0] : '';
// Zertifikat bewerten.
$cert = $final['cert'] ?? [];
$certValid = null; $daysLeft = null;
if (!empty($cert['valid_to'])) {
$exp = strtotime($cert['valid_to']);
if ($exp !== false) {
$daysLeft = (int) floor(($exp - time()) / 86400);
$certValid = $daysLeft >= 0;
}
}
return [
'ok' => true,
'start_url' => $url,
'final_url' => $final['url'],
'final_status' => $final['status'],
'chain' => $chain,
'redirects' => max(0, count($chain) - 1),
'https' => ($final['scheme'] ?? '') === 'https',
'hsts' => $present['strict-transport-security'] !== '',
'security' => $present,
'server' => $server,
'cert' => $cert,
'cert_valid' => $certValid,
'cert_days_left'=> $daysLeft,
'time_ms' => $final['time_ms'] ?? null,
];
}
/**
* Relative URL gegen Basis auflösen (vereinfachte, sichere Variante).
*
* @param string $base Basis-URL.
* @param string $rel Location.
* @return string Absolute URL oder ''.
*/
function smi_abs_url(string $base, string $rel): string
{
if (preg_match('#^https?://#i', $rel)) {
return $rel;
}
$b = parse_url($base);
if (!is_array($b) || empty($b['host'])) {
return '';
}
$scheme = $b['scheme'] ?? 'http';
$host = $b['host'];
$port = isset($b['port']) ? ':' . $b['port'] : '';
if (str_starts_with($rel, '//')) {
return $scheme . ':' . $rel;
}
if (str_starts_with($rel, '/')) {
return $scheme . '://' . $host . $port . $rel;
}
$path = $b['path'] ?? '/';
$dir = substr($path, 0, (int) strrpos($path, '/') + 1);
return $scheme . '://' . $host . $port . $dir . $rel;
}