security: fix unauthenticated file upload/RCE risk, TLS bypass, XSS, and broken gift-card/review columns

- admin/api/upload-splash.php had NO admin-auth check (included the public
  customer header, not admin/includes/header.php) and both upload endpoints
  trusted the client-supplied MIME type and filename extension, so an
  attacker could name a file "shell.php", spoof Content-Type: image/png,
  and get PHP written into a web-reachable uploads/ directory. Added
  AdminAuth check and centralized real-content validation (getimagesize +
  server-side extension mapping) in a new handleImageUpload() helper used
  by both admin/upload-image.php and admin/api/upload-splash.php.
- Removed CURLOPT_SSL_VERIFYPEER => false from the CyberMail email calls
  in includes/email.php and includes/functions.php (MITM risk on the API
  key). Rewrote functions.php's sendEmail() as a thin wrapper around
  Email::send() so there's one implementation instead of two that could
  drift (this is what had the second copy of the TLS bypass).
- Escaped customer-controlled fields (customer_name, tracking info, reset
  URL) before interpolating into outbound HTML emails — name is free text
  from registration/checkout with no length/char restriction, so it was
  stored-HTML-injectable into every transactional email.
- Fixed api/redeem-gift-card.php referencing a nonexistent `balance` column
  on gift_cards (actual column is current_balance) — gift card redemption
  was completely broken, always returning "no remaining balance".
- Fixed api/submit-review.php inserting into nonexistent `content`/`status`
  columns on reviews (actual columns are `comment`/`is_approved`) — review
  submission was crashing on every request.
- Hardened .htaccess: block /db/*, /.git/*, and *.sql. Live site currently
  serves db/schema.sql and the full .git directory (including .git/config,
  which contains a GitHub PAT with push access) over HTTP — the existing
  config/includes RedirectMatch rules are also not being enforced live,
  see report for details; this needs a server-level fix too.
- Cleaned up README's leftover install instructions pointing at a deleted
  create-admin.php with a documented default password.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-04 14:22:50 -05:00
parent 06260ed192
commit 186ac0cb6d
8 changed files with 123 additions and 90 deletions
+12 -20
View File
@@ -1,29 +1,21 @@
<?php
require_once __DIR__ . '/../includes/header.php';
require_once __DIR__ . '/../../includes/auth.php';
header('Content-Type: application/json');
if ($_SERVER['REQUEST_METHOD'] !== 'POST' || empty($_FILES['image'])) {
if (!AdminAuth::getUser()) {
http_response_code(401);
echo json_encode(['error' => 'Unauthorized']);
exit;
}
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
echo json_encode(['error' => 'No file received']); exit;
}
$file = $_FILES['image'];
$allowed = ['image/jpeg','image/png','image/gif','image/webp'];
if (!in_array($file['type'], $allowed)) {
echo json_encode(['error' => 'Invalid type. Use JPG, PNG, WebP or GIF.']); exit;
}
if ($file['size'] > 5 * 1024 * 1024) {
echo json_encode(['error' => 'File too large (max 5 MB).']); exit;
}
$result = handleImageUpload('image', __DIR__ . '/../../uploads/splashes/', 'splash_');
$dir = __DIR__ . '/../../uploads/splashes/';
if (!is_dir($dir)) mkdir($dir, 0755, true);
$ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
$name = 'splash_' . time() . '_' . bin2hex(random_bytes(4)) . '.' . $ext;
$path = $dir . $name;
if (move_uploaded_file($file['tmp_name'], $path)) {
echo json_encode(['success' => true, 'url' => '/uploads/splashes/' . $name]);
if (isset($result['success'])) {
echo json_encode(['success' => true, 'url' => '/uploads/splashes/' . $result['filename']]);
} else {
echo json_encode(['error' => 'Could not save file.']);
echo json_encode(['error' => $result['error']]);
}
+5 -26
View File
@@ -13,36 +13,15 @@ if (!AdminAuth::getUser()) {
exit;
}
if ($_SERVER['REQUEST_METHOD'] !== 'POST' || empty($_FILES['image'])) {
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
echo json_encode(['error' => 'No file received']);
exit;
}
$file = $_FILES['image'];
$allowedTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
$maxSize = 5 * 1024 * 1024; // 5MB
$result = handleImageUpload('image', __DIR__ . '/../uploads/products/', 'product_');
if (!in_array($file['type'], $allowedTypes)) {
echo json_encode(['error' => 'Invalid file type. Use JPG, PNG, WebP, or GIF.']);
exit;
}
if ($file['size'] > $maxSize) {
echo json_encode(['error' => 'File too large. Maximum 5MB.']);
exit;
}
$uploadDir = __DIR__ . '/../uploads/products/';
if (!is_dir($uploadDir)) {
mkdir($uploadDir, 0755, true);
}
$ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
$filename = 'product_' . time() . '_' . bin2hex(random_bytes(4)) . '.' . $ext;
$filepath = $uploadDir . $filename;
if (move_uploaded_file($file['tmp_name'], $filepath)) {
echo json_encode(['success' => true, 'url' => '/uploads/products/' . $filename]);
if (isset($result['success'])) {
echo json_encode(['success' => true, 'url' => '/uploads/products/' . $result['filename']]);
} else {
echo json_encode(['error' => 'Failed to save file. Check directory permissions.']);
echo json_encode(['error' => $result['error']]);
}