fix: harden PHP type handling across all endpoints

This commit is contained in:
Alexander Schmidt
2026-03-26 07:57:11 +01:00
parent 5d38946c53
commit 2263fbf659
7 changed files with 97 additions and 37 deletions

View File

@@ -33,7 +33,10 @@ function verify_origin(): void {
function get_hmac_secret(): string { function get_hmac_secret(): string {
$secretFile = __DIR__ . '/../data/secret.key'; $secretFile = __DIR__ . '/../data/secret.key';
if (file_exists($secretFile)) { if (file_exists($secretFile)) {
return trim(file_get_contents($secretFile)); $raw = file_get_contents($secretFile);
if (is_string($raw) && $raw !== '') {
return trim($raw);
}
} }
$secret = bin2hex(random_bytes(32)); $secret = bin2hex(random_bytes(32));
$dir = dirname($secretFile); $dir = dirname($secretFile);
@@ -53,8 +56,10 @@ function check_rate_limit(string $action, int $limit, int $window_seconds): bool
$now = time(); $now = time();
$times = []; $times = [];
if (file_exists($rateFile)) { if (file_exists($rateFile)) {
$times = json_decode(file_get_contents($rateFile), true) ?: []; $raw = file_get_contents($rateFile);
$times = array_values(array_filter($times, fn($t) => $t > $now - $window_seconds)); $decoded = is_string($raw) ? json_decode($raw, true) : [];
$times = is_array($decoded) ? $decoded : [];
$times = array_values(array_filter($times, fn($t) => is_numeric($t) && (int)$t > $now - $window_seconds));
} }
if (count($times) >= $limit) return false; if (count($times) >= $limit) return false;
$times[] = $now; $times[] = $now;
@@ -68,9 +73,15 @@ function read_json_locked(string $file): array {
$dir = dirname($file); $dir = dirname($file);
if (!is_dir($dir)) mkdir($dir, 0750, true); if (!is_dir($dir)) mkdir($dir, 0750, true);
$fp = fopen($file, 'c+'); $fp = fopen($file, 'c+');
if ($fp === false) {
throw new RuntimeException('Unable to open file: ' . $file);
}
flock($fp, LOCK_EX); flock($fp, LOCK_EX);
$size = filesize($file); $size = filesize($file);
$data = ($size > 0) ? (json_decode(fread($fp, $size), true) ?: []) : []; $size = is_int($size) ? $size : 0;
$raw = $size > 0 ? fread($fp, $size) : '';
$decoded = is_string($raw) ? json_decode($raw, true) : [];
$data = is_array($decoded) ? $decoded : [];
return [$fp, $data]; return [$fp, $data];
} }

View File

@@ -18,7 +18,7 @@ if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
exit; exit;
} }
$code = $_GET['code'] ?? ''; $code = isset($_GET['code']) && is_string($_GET['code']) ? $_GET['code'] : '';
if (empty($code) || !preg_match('/^[a-z0-9]{4,10}$/', $code)) { if (empty($code) || !preg_match('/^[a-z0-9]{4,10}$/', $code)) {
http_response_code(400); http_response_code(400);
echo json_encode(['error' => 'Invalid code']); echo json_encode(['error' => 'Invalid code']);
@@ -32,7 +32,9 @@ if (!file_exists($dbFile)) {
exit; exit;
} }
$urls = json_decode(file_get_contents($dbFile), true) ?: []; $rawUrls = file_get_contents($dbFile);
$decodedUrls = is_string($rawUrls) ? json_decode($rawUrls, true) : [];
$urls = is_array($decodedUrls) ? $decodedUrls : [];
if (!isset($urls[$code])) { if (!isset($urls[$code])) {
http_response_code(404); http_response_code(404);
echo json_encode(['error' => 'Invoice not found']); echo json_encode(['error' => 'Invoice not found']);
@@ -40,7 +42,8 @@ if (!isset($urls[$code])) {
} }
$data = $urls[$code]; $data = $urls[$code];
$hash = is_array($data) ? $data['h'] : $data; $hash = is_array($data) ? ($data['h'] ?? '') : $data;
$hash = is_string($hash) ? $hash : '';
$signature = is_array($data) ? $data['s'] : null; $signature = is_array($data) ? $data['s'] : null;
// Re-derive expected signature so client can verify // Re-derive expected signature so client can verify

View File

@@ -40,9 +40,11 @@ $now = time();
$rateData = []; $rateData = [];
if (file_exists($rateFile)) { if (file_exists($rateFile)) {
$rateData = json_decode(file_get_contents($rateFile), true) ?: []; $rawRate = file_get_contents($rateFile);
$decodedRate = is_string($rawRate) ? json_decode($rawRate, true) : [];
$rateData = is_array($decodedRate) ? $decodedRate : [];
// Clean old entries // Clean old entries
$rateData = array_filter($rateData, fn($t) => $t > $now - 60); $rateData = array_values(array_filter($rateData, fn($t) => is_numeric($t) && (int)$t > $now - 60));
} }
if (count($rateData) >= 60) { if (count($rateData) >= 60) {
@@ -55,15 +57,16 @@ $rateData[] = $now;
file_put_contents($rateFile, json_encode($rateData)); file_put_contents($rateFile, json_encode($rateData));
// Parse request // Parse request
$input = json_decode(file_get_contents('php://input'), true); $rawInput = file_get_contents('php://input');
if (!$input || !isset($input['method'])) { $input = is_string($rawInput) ? json_decode($rawInput, true) : null;
if (!is_array($input) || !isset($input['method']) || !is_string($input['method'])) {
http_response_code(400); http_response_code(400);
echo json_encode(['error' => 'Missing method']); echo json_encode(['error' => 'Missing method']);
exit; exit;
} }
$method = $input['method']; $method = $input['method'];
$params = $input['params'] ?? []; $params = isset($input['params']) && is_array($input['params']) ? $input['params'] : [];
// Determine endpoint type // Determine endpoint type
$isJsonRpc = in_array($method, $ALLOWED_JSON_RPC); $isJsonRpc = in_array($method, $ALLOWED_JSON_RPC);
@@ -79,8 +82,9 @@ if (!$isJsonRpc && !$isHttp) {
$cacheFile = __DIR__ . '/../data/node_cache.json'; $cacheFile = __DIR__ . '/../data/node_cache.json';
$cachedNode = null; $cachedNode = null;
if (file_exists($cacheFile)) { if (file_exists($cacheFile)) {
$cache = json_decode(file_get_contents($cacheFile), true); $rawCache = file_get_contents($cacheFile);
if ($cache && ($cache['time'] ?? 0) > $now - 300) { $cache = is_string($rawCache) ? json_decode($rawCache, true) : null;
if (is_array($cache) && ($cache['time'] ?? 0) > $now - 300 && isset($cache['node']) && is_string($cache['node'])) {
$cachedNode = $cache['node']; $cachedNode = $cache['node'];
} }
} }
@@ -108,6 +112,10 @@ foreach ($orderedNodes as $node) {
} }
$ch = curl_init($url); $ch = curl_init($url);
if ($ch === false) {
$lastError = 'cURL init failed';
continue;
}
curl_setopt_array($ch, [ curl_setopt_array($ch, [
CURLOPT_POST => true, CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $body, CURLOPT_POSTFIELDS => $body,

View File

@@ -6,8 +6,10 @@ $cacheFile = __DIR__ . '/../data/rates_cache.json';
$cacheTTL = 120; // seconds $cacheTTL = 120; // seconds
if (file_exists($cacheFile)) { if (file_exists($cacheFile)) {
$cached = json_decode(file_get_contents($cacheFile), true); $rawCached = file_get_contents($cacheFile);
if ($cached && (time() - ($cached['_time'] ?? 0)) < $cacheTTL) { $cached = is_string($rawCached) ? json_decode($rawCached, true) : null;
$cachedTime = is_array($cached) && isset($cached['_time']) && is_numeric($cached['_time']) ? (int)$cached['_time'] : 0;
if (is_array($cached) && (time() - $cachedTime) < $cacheTTL) {
unset($cached['_time']); unset($cached['_time']);
header('Cache-Control: public, max-age=60'); header('Cache-Control: public, max-age=60');
echo json_encode($cached); echo json_encode($cached);
@@ -15,10 +17,15 @@ if (file_exists($cacheFile)) {
} }
} }
$currencies = $_GET['c'] ?? 'eur,usd,chf,gbp,jpy,rub,brl'; $currencies = isset($_GET['c']) && is_string($_GET['c']) ? $_GET['c'] : 'eur,usd,chf,gbp,jpy,rub,brl';
$currencies = preg_replace('/[^a-z,]/', '', strtolower($currencies)); $currencies = preg_replace('/[^a-z,]/', '', strtolower($currencies));
$url = 'https://api.coingecko.com/api/v3/simple/price?ids=monero&vs_currencies=' . $currencies; $url = 'https://api.coingecko.com/api/v3/simple/price?ids=monero&vs_currencies=' . $currencies;
$ch = curl_init($url); $ch = curl_init($url);
if ($ch === false) {
http_response_code(502);
echo json_encode(['error' => 'Failed to initialize request']);
exit;
}
curl_setopt_array($ch, [ curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true, CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 10, CURLOPT_TIMEOUT => 10,
@@ -30,7 +37,7 @@ curl_close($ch);
if ($response !== false && $httpCode === 200) { if ($response !== false && $httpCode === 200) {
$data = json_decode($response, true); $data = json_decode($response, true);
if ($data) { if (is_array($data)) {
$data['_time'] = time(); $data['_time'] = time();
file_put_contents($cacheFile, json_encode($data)); file_put_contents($cacheFile, json_encode($data));
unset($data['_time']); unset($data['_time']);
@@ -42,8 +49,9 @@ if ($response !== false && $httpCode === 200) {
// On error, serve stale cache if available // On error, serve stale cache if available
if (file_exists($cacheFile)) { if (file_exists($cacheFile)) {
$cached = json_decode(file_get_contents($cacheFile), true); $rawCached = file_get_contents($cacheFile);
if ($cached) { $cached = is_string($rawCached) ? json_decode($rawCached, true) : null;
if (is_array($cached)) {
unset($cached['_time']); unset($cached['_time']);
header('Cache-Control: public, max-age=30'); header('Cache-Control: public, max-age=30');
echo json_encode($cached); echo json_encode($cached);

View File

@@ -21,8 +21,9 @@ if (!check_rate_limit('shorten', 20, 3600)) {
$dbFile = __DIR__ . '/../data/urls.json'; $dbFile = __DIR__ . '/../data/urls.json';
$input = json_decode(file_get_contents('php://input'), true); $rawInput = file_get_contents('php://input');
$hash = $input['hash'] ?? ''; $input = is_string($rawInput) ? json_decode($rawInput, true) : null;
$hash = is_array($input) && isset($input['hash']) && is_string($input['hash']) ? $input['hash'] : '';
if (empty($hash) || strlen($hash) > 500 || !preg_match('/^[a-zA-Z0-9%+_=&.-]{1,500}$/', $hash)) { if (empty($hash) || strlen($hash) > 500 || !preg_match('/^[a-zA-Z0-9%+_=&.-]{1,500}$/', $hash)) {
http_response_code(400); http_response_code(400);
@@ -33,10 +34,16 @@ if (empty($hash) || strlen($hash) > 500 || !preg_match('/^[a-zA-Z0-9%+_=&.-]{1,5
$secret = get_hmac_secret(); $secret = get_hmac_secret();
[$fp, $urls] = read_json_locked($dbFile); [$fp, $urls] = read_json_locked($dbFile);
if (!is_array($urls)) {
$urls = [];
}
// Check if this hash already exists // Check if this hash already exists
foreach ($urls as $code => $data) { foreach ($urls as $code => $data) {
$stored_hash = is_array($data) ? $data['h'] : $data; $stored_hash = is_array($data) ? ($data['h'] ?? null) : $data;
if (!is_string($stored_hash)) {
continue;
}
if ($stored_hash === $hash) { if ($stored_hash === $hash) {
flock($fp, LOCK_UN); flock($fp, LOCK_UN);
fclose($fp); fclose($fp);

View File

@@ -22,14 +22,25 @@ if ($_SERVER['REQUEST_METHOD'] === 'GET') {
echo json_encode(['error' => 'Rate limit exceeded']); echo json_encode(['error' => 'Rate limit exceeded']);
exit; exit;
} }
$code = $_GET['code'] ?? ''; $code = isset($_GET['code']) && is_string($_GET['code']) ? $_GET['code'] : '';
if (empty($code) || !preg_match('/^[a-z0-9]{4,10}$/', $code)) { if (empty($code) || !preg_match('/^[a-z0-9]{4,10}$/', $code)) {
echo json_encode(['verified' => false]); echo json_encode(['verified' => false]);
exit; exit;
} }
$proofs = file_exists($dbFile) ? (json_decode(file_get_contents($dbFile), true) ?: []) : []; $rawProofs = file_exists($dbFile) ? file_get_contents($dbFile) : null;
$decodedProofs = is_string($rawProofs) ? json_decode($rawProofs, true) : [];
$proofs = is_array($decodedProofs) ? $decodedProofs : [];
if (isset($proofs[$code])) { if (isset($proofs[$code])) {
echo json_encode(array_merge(['verified' => true], $proofs[$code])); $response = ['verified' => true];
$proofEntry = $proofs[$code];
if (is_array($proofEntry)) {
foreach ($proofEntry as $k => $v) {
if (is_string($k)) {
$response[$k] = $v;
}
}
}
echo json_encode($response);
} else { } else {
echo json_encode(['verified' => false]); echo json_encode(['verified' => false]);
} }
@@ -51,15 +62,16 @@ if (!check_rate_limit('verify_post', 10, 3600)) {
exit; exit;
} }
$input = json_decode(file_get_contents('php://input'), true); $rawInput = file_get_contents('php://input');
if (!$input) { $input = is_string($rawInput) ? json_decode($rawInput, true) : null;
if (!is_array($input)) {
http_response_code(400); http_response_code(400);
echo json_encode(['error' => 'Invalid JSON']); echo json_encode(['error' => 'Invalid JSON']);
exit; exit;
} }
$code = $input['code'] ?? ''; $code = isset($input['code']) && is_string($input['code']) ? $input['code'] : '';
$txHash = $input['tx_hash'] ?? ''; $txHash = isset($input['tx_hash']) && is_string($input['tx_hash']) ? $input['tx_hash'] : '';
$amount = floatval($input['amount'] ?? 0); $amount = floatval($input['amount'] ?? 0);
$confirmations = intval($input['confirmations'] ?? 0); $confirmations = intval($input['confirmations'] ?? 0);
@@ -82,7 +94,9 @@ if (!file_exists($urlsFile)) {
echo json_encode(['error' => 'Invoice not found']); echo json_encode(['error' => 'Invoice not found']);
exit; exit;
} }
$urls = json_decode(file_get_contents($urlsFile), true) ?: []; $rawUrls = file_get_contents($urlsFile);
$decodedUrls = is_string($rawUrls) ? json_decode($rawUrls, true) : [];
$urls = is_array($decodedUrls) ? $decodedUrls : [];
if (!isset($urls[$code])) { if (!isset($urls[$code])) {
http_response_code(404); http_response_code(404);
echo json_encode(['error' => 'Invoice not found']); echo json_encode(['error' => 'Invoice not found']);
@@ -91,6 +105,9 @@ if (!isset($urls[$code])) {
// Store proof with atomic lock // Store proof with atomic lock
[$fp, $proofs] = read_json_locked($dbFile); [$fp, $proofs] = read_json_locked($dbFile);
if (!is_array($proofs)) {
$proofs = [];
}
$status = ($input['status'] ?? 'paid') === 'pending' ? 'pending' : 'paid'; $status = ($input['status'] ?? 'paid') === 'pending' ? 'pending' : 'paid';

18
s.php
View File

@@ -1,5 +1,7 @@
<?php <?php
$code = trim($_SERVER['PATH_INFO'] ?? $_GET['c'] ?? '', '/'); $pathInfo = isset($_SERVER['PATH_INFO']) && is_string($_SERVER['PATH_INFO']) ? $_SERVER['PATH_INFO'] : null;
$queryCode = isset($_GET['c']) && is_string($_GET['c']) ? $_GET['c'] : '';
$code = trim($pathInfo ?? $queryCode, '/');
if (empty($code) || !preg_match('/^[a-z0-9]{4,10}$/', $code)) { if (empty($code) || !preg_match('/^[a-z0-9]{4,10}$/', $code)) {
http_response_code(404); http_response_code(404);
@@ -14,7 +16,9 @@ if (!file_exists($dbFile)) {
exit; exit;
} }
$urls = json_decode(file_get_contents($dbFile), true) ?: []; $rawUrls = file_get_contents($dbFile);
$decodedUrls = is_string($rawUrls) ? json_decode($rawUrls, true) : [];
$urls = is_array($decodedUrls) ? $decodedUrls : [];
if (!isset($urls[$code])) { if (!isset($urls[$code])) {
http_response_code(404); http_response_code(404);
@@ -24,11 +28,12 @@ if (!isset($urls[$code])) {
// Support both old format (string) and new format (array with hash & signature) // Support both old format (string) and new format (array with hash & signature)
$data = $urls[$code]; $data = $urls[$code];
$hash = is_array($data) ? $data['h'] : $data; $hash = is_array($data) ? ($data['h'] ?? '') : $data;
$signature = is_array($data) ? $data['s'] : null; $hash = is_string($hash) ? $hash : '';
$signature = is_array($data) ? ($data['s'] ?? null) : null;
// Verify HMAC signature if present (detect server-side tampering) // Verify HMAC signature if present (detect server-side tampering)
if ($signature) { if (is_string($signature) && $signature !== '') {
require_once __DIR__ . '/api/_helpers.php'; require_once __DIR__ . '/api/_helpers.php';
$expected_sig = hash_hmac('sha256', $hash, get_hmac_secret()); $expected_sig = hash_hmac('sha256', $hash, get_hmac_secret());
if (!hash_equals($expected_sig, $signature)) { if (!hash_equals($expected_sig, $signature)) {
@@ -37,6 +42,7 @@ if ($signature) {
} }
} }
$base = (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 'https' : 'http') . '://' . $_SERVER['HTTP_HOST']; $host = isset($_SERVER['HTTP_HOST']) && is_string($_SERVER['HTTP_HOST']) ? $_SERVER['HTTP_HOST'] : 'xmrpay.link';
$base = (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 'https' : 'http') . '://' . $host;
header('Location: ' . $base . '/#' . $hash . '&c=' . $code, true, 302); header('Location: ' . $base . '/#' . $hash . '&c=' . $code, true, 302);
exit; exit;