Compare commits

..

4 Commits

Author SHA1 Message Date
myron 0238c38720 Add copyright notice 2026-07-27 04:55:21 +00:00
myron 6c7344ec52 Relocate config.php with plaintext secrets outside the webroot; add per-IP rate limiting to the unauthenticated testimonial image upload endpoint 2026-07-06 07:53:54 +00:00
myron 982a5dc5ff Harden .git/db block: use REQUEST_URI matching, direct RewriteRule patterns not reliably honored 2026-07-04 15:56:59 -05:00
myron 0dfcfa2f7e Fix security gaps and dead code found in codebase review
- mailer.php: re-enable TLS certificate verification for CyberMail API
  calls (was CURLOPT_SSL_VERIFYPEER => false, exposing outbound mail
  traffic to MITM).
- .htaccess / api/.htaccess: block direct HTTP access to .git/, db/,
  and api/config.php via mod_rewrite [F] rules. Confirmed live that
  /.git/config, /.git/logs/HEAD, and /db/schema.sql were all directly
  downloadable (200 OK with real content) - .git exposure leaks the
  repo's embedded GitHub token and full history; schema.sql leaks the
  DB layout. Also drops the dead "/setup -> setup_password.php" rewrite,
  since that file was already deleted from production.
- index.php: remove the 'download' route, which required a
  api/download.php that has never existed in this repo - confirmed
  live that GET /api/download returns a fatal 500.
- functions.php / upload.php / testimonials.php: extract the
  duplicated image-upload validation/move logic (MIME check, size
  check, UUID rename, move_uploaded_file) into one handleImageUpload()
  helper used by both upload endpoints.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-04 14:21:19 -05:00
8 changed files with 88 additions and 60 deletions
+9 -3
View File
@@ -13,13 +13,19 @@ DirectoryIndex index.html index.php
RewriteEngine On RewriteEngine On
RewriteBase / RewriteBase /
# Block access to version control metadata and the raw DB schema dump.
# These were previously reachable directly over HTTP (e.g. /.git/config,
# /db/schema.sql) and leaked repo/DB internals - deny them outright.
# Note: a direct RewriteRule pattern (no leading slash) was found NOT to be
# honored consistently on this LiteSpeed setup for root-level paths; matching
# on REQUEST_URI via RewriteCond is what's confirmed to work.
RewriteCond %{REQUEST_URI} ^/(\.git|db/)
RewriteRule .* - [F,L]
# API Routes - Direct to PHP backend # API Routes - Direct to PHP backend
RewriteCond %{REQUEST_URI} ^/api/ RewriteCond %{REQUEST_URI} ^/api/
RewriteRule ^api/(.*)$ api/api/$1 [L,QSA] RewriteRule ^api/(.*)$ api/api/$1 [L,QSA]
# Admin setup page
RewriteRule ^setup/?$ setup_password.php [L]
# Static files and directories - serve directly # Static files and directories - serve directly
RewriteCond %{REQUEST_FILENAME} -f [OR] RewriteCond %{REQUEST_FILENAME} -f [OR]
RewriteCond %{REQUEST_FILENAME} -d RewriteCond %{REQUEST_FILENAME} -d
+1
View File
@@ -0,0 +1 @@
Property of TomTom Enterprises.
+9 -1
View File
@@ -4,7 +4,15 @@
<IfModule mod_rewrite.c> <IfModule mod_rewrite.c>
RewriteEngine On RewriteEngine On
RewriteBase /api/ RewriteBase /api/
# Block direct access to config.php / .env - this was reachable at
# /api/config.php in production (200 OK) despite the FilesMatch/Require
# rule below, which LiteSpeed appears not to honor from .htaccess here.
# Matching on REQUEST_URI (not a direct RewriteRule pattern) is what's
# confirmed to actually work on this LiteSpeed setup.
RewriteCond %{REQUEST_URI} /(config\.php|\.env)$
RewriteRule .* - [F,L]
# Route all requests to index.php # Route all requests to index.php
RewriteCond %{REQUEST_FILENAME} !-f RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d RewriteCond %{REQUEST_FILENAME} !-d
+22 -14
View File
@@ -74,22 +74,30 @@ if ($method === 'DELETE' && $id) {
} }
// POST upload testimonial image (public) // POST upload testimonial image (public) - rate limited per IP to prevent
// unauthenticated storage/bandwidth abuse (this endpoint has no login requirement).
if ($method === "POST" && $id === "upload") { if ($method === "POST" && $id === "upload") {
$ip = $_SERVER['REMOTE_ADDR'] ?? 'unknown';
$limitRow = $db->prepare("SELECT upload_count, window_start FROM upload_rate_limits WHERE ip_address = ?");
$limitRow->execute([$ip]);
$limit = $limitRow->fetch();
$windowFresh = $limit && (strtotime($limit['window_start']) > time() - 3600);
if ($windowFresh && (int)$limit['upload_count'] >= 5) {
jsonResponse(["error" => "Too many uploads. Please try again later."], 429);
}
if (!isset($_FILES["file"])) jsonResponse(["error" => "No file uploaded"], 400); if (!isset($_FILES["file"])) jsonResponse(["error" => "No file uploaded"], 400);
$file = $_FILES["file"]; $result = handleImageUpload($_FILES["file"]);
if ($file["error"] !== UPLOAD_ERR_OK) jsonResponse(["error" => "Upload error"], 400); if (isset($result['error'])) jsonResponse(["error" => $result['error']], $result['status']);
if ($file["size"] > MAX_UPLOAD_SIZE) jsonResponse(["error" => "File too large (max 5MB)"], 400);
$finfo = finfo_open(FILEINFO_MIME_TYPE); if ($windowFresh) {
$mime = finfo_file($finfo, $file["tmp_name"]); $db->prepare("UPDATE upload_rate_limits SET upload_count = upload_count + 1 WHERE ip_address = ?")->execute([$ip]);
finfo_close($finfo); } else {
$allowed = ["image/jpeg","image/jpg","image/png","image/webp"]; $db->prepare("REPLACE INTO upload_rate_limits (ip_address, upload_count, window_start) VALUES (?, 1, NOW())")->execute([$ip]);
if (!in_array($mime, $allowed)) jsonResponse(["error" => "Invalid file type"], 400); }
$ext = strtolower(pathinfo($file["name"], PATHINFO_EXTENSION));
$name = generateUuid() . "." . $ext; jsonResponse(["url" => $result['url']]);
$dest = UPLOAD_DIR . $name;
if (!move_uploaded_file($file["tmp_name"], $dest)) jsonResponse(["error" => "Failed to save"], 500);
jsonResponse(["url" => "/api/uploads/" . $name]);
} }
jsonResponse(['error' => 'Invalid testimonials endpoint'], 404); jsonResponse(['error' => 'Invalid testimonials endpoint'], 404);
+6 -35
View File
@@ -10,43 +10,14 @@ if ($method === 'POST' && $id === 'image') {
jsonResponse(['error' => 'No file uploaded'], 400); jsonResponse(['error' => 'No file uploaded'], 400);
} }
$file = $_FILES['file']; $result = handleImageUpload($_FILES['file']);
if (isset($result['error'])) {
// Validate file jsonResponse(['error' => $result['error']], $result['status']);
if ($file['error'] !== UPLOAD_ERR_OK) {
jsonResponse(['error' => 'File upload failed'], 400);
} }
// Check file size
if ($file['size'] > MAX_UPLOAD_SIZE) {
jsonResponse(['error' => 'File too large. Maximum size is 5MB'], 400);
}
// Check file type
$allowedTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp'];
$finfo = finfo_open(FILEINFO_MIME_TYPE);
$mimeType = finfo_file($finfo, $file['tmp_name']);
finfo_close($finfo);
if (!in_array($mimeType, $allowedTypes)) {
jsonResponse(['error' => 'Invalid file type. Only JPG, PNG, and WebP allowed'], 400);
}
// Generate unique filename
$extension = pathinfo($file['name'], PATHINFO_EXTENSION);
$filename = generateUuid() . '.' . $extension;
$filepath = UPLOAD_DIR . $filename;
// Move uploaded file
if (!move_uploaded_file($file['tmp_name'], $filepath)) {
jsonResponse(['error' => 'Failed to save file'], 500);
}
$fileUrl = '/api/uploads/' . $filename;
jsonResponse([ jsonResponse([
'url' => $fileUrl, 'url' => $result['url'],
'filename' => $filename 'filename' => $result['filename']
]); ]);
} }
+37
View File
@@ -85,3 +85,40 @@ function validateRequired($data, $requiredFields) {
return $errors; return $errors;
} }
/**
* Validate an uploaded image file ($_FILES['file']) and move it into
* UPLOAD_DIR under a fresh UUID filename. Shared by all endpoints that
* accept image uploads (was previously duplicated per-endpoint).
*
* Returns ['url' => ..., 'filename' => ...] on success, or
* ['error' => ..., 'status' => ...] on failure.
*/
function handleImageUpload($file) {
if (!$file || $file['error'] !== UPLOAD_ERR_OK) {
return ['error' => 'File upload failed', 'status' => 400];
}
if ($file['size'] > MAX_UPLOAD_SIZE) {
return ['error' => 'File too large. Maximum size is 5MB', 'status' => 400];
}
$allowedTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp'];
$finfo = finfo_open(FILEINFO_MIME_TYPE);
$mimeType = finfo_file($finfo, $file['tmp_name']);
finfo_close($finfo);
if (!in_array($mimeType, $allowedTypes)) {
return ['error' => 'Invalid file type. Only JPG, PNG, and WebP allowed', 'status' => 400];
}
$extension = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
$filename = generateUuid() . '.' . $extension;
$filepath = UPLOAD_DIR . $filename;
if (!move_uploaded_file($file['tmp_name'], $filepath)) {
return ['error' => 'Failed to save file', 'status' => 500];
}
return ['url' => '/api/uploads/' . $filename, 'filename' => $filename];
}
+2 -1
View File
@@ -28,7 +28,8 @@ function sendgridSend(string $toEmail, string $toName, string $subject, string $
'Content-Type: application/json', 'Content-Type: application/json',
], ],
CURLOPT_TIMEOUT => 20, CURLOPT_TIMEOUT => 20,
CURLOPT_SSL_VERIFYPEER => false, CURLOPT_SSL_VERIFYPEER => true,
CURLOPT_SSL_VERIFYHOST => 2,
]); ]);
$response = curl_exec($ch); $response = curl_exec($ch);
+2 -6
View File
@@ -5,7 +5,7 @@
*/ */
// Load configuration and dependencies // Load configuration and dependencies
require_once __DIR__ . '/config.php'; require_once '/home/epictravelexpeditions.com/api-secrets.php';
require_once __DIR__ . '/includes/database.php'; require_once __DIR__ . '/includes/database.php';
require_once __DIR__ . '/includes/jwt.php'; require_once __DIR__ . '/includes/jwt.php';
require_once __DIR__ . '/includes/functions.php'; require_once __DIR__ . '/includes/functions.php';
@@ -58,11 +58,7 @@ try {
case 'upload': case 'upload':
require __DIR__ . '/api/upload.php'; require __DIR__ . '/api/upload.php';
break; break;
case 'download':
require __DIR__ . '/api/download.php';
break;
case 'testimonials': case 'testimonials':
require __DIR__ . '/api/testimonials.php'; require __DIR__ . '/api/testimonials.php';
break; break;