Compare commits

...

2 commits

Author SHA1 Message Date
cc7d6f6b4f Merge pull request 'fix: harden auth middleware security' (#1) from schwifty/fix-auth-security into main 2026-03-23 01:44:02 +00:00
601245d969 fix: harden auth middleware — exact route matching, remove admin bypass, add cron secret
1. Switch str_contains() to exact match ($path === $route) in PUBLIC_ROUTES check
   to prevent substring-based route bypass attacks.

2. Remove blanket /api/admin/ bypass that was letting all admin endpoints through
   without authentication.

3. Add requireCronSecret() — cron/scheduled task endpoints now require a valid
   X-Cron-Secret header matching the PAYFRIT_CRON_SECRET env var. Uses
   hash_equals() for timing-safe comparison. Applied to:
   - cron/expireStaleChats.php
   - cron/expireTabs.php
   - api/admin/scheduledTasks/runDue.php
2026-03-23 01:43:43 +00:00
4 changed files with 29 additions and 9 deletions

View file

@ -1,7 +1,7 @@
<?php <?php
require_once __DIR__ . '/../../helpers.php'; require_once __DIR__ . '/../../helpers.php';
require_once __DIR__ . '/_cronUtils.php'; require_once __DIR__ . '/_cronUtils.php';
// No runAuth() — this is a cron/public endpoint requireCronSecret();
/** /**
* Process all due scheduled tasks. * Process all due scheduled tasks.

View file

@ -300,6 +300,30 @@ function sendSMS(string $to, string $body): array {
return ['success' => false, 'message' => $errMsg]; return ['success' => false, 'message' => $errMsg];
} }
// ============================================
// CRON AUTH
// ============================================
/**
* Require a valid X-Cron-Secret header for cron/scheduled task endpoints.
* The secret is read from the PAYFRIT_CRON_SECRET environment variable.
* Aborts with 403 if missing or mismatched.
*/
function requireCronSecret(): void {
$expected = trim(getenv('PAYFRIT_CRON_SECRET') ?: '');
if ($expected === '') {
error_log('[cron_auth] PAYFRIT_CRON_SECRET env var is not set. Blocking request.');
http_response_code(403);
jsonResponse(['OK' => false, 'ERROR' => 'cron_secret_not_configured'], 403);
}
$provided = headerValue('X-Cron-Secret');
if ($provided === '' || !hash_equals($expected, $provided)) {
http_response_code(403);
jsonResponse(['OK' => false, 'ERROR' => 'invalid_cron_secret'], 403);
}
}
// ============================================ // ============================================
// AUTH MIDDLEWARE // AUTH MIDDLEWARE
// ============================================ // ============================================
@ -525,18 +549,14 @@ function runAuth(): void {
$businessId = (int) $bizHeader; $businessId = (int) $bizHeader;
} }
// Check if public route // Check if public route (exact match only)
$isPublic = false; $isPublic = false;
foreach (PUBLIC_ROUTES as $route) { foreach (PUBLIC_ROUTES as $route) {
if (str_contains($path, strtolower($route))) { if ($path === strtolower($route)) {
$isPublic = true; $isPublic = true;
break; break;
} }
} }
// Also allow /api/admin/ paths
if (str_contains($path, '/api/admin/')) {
$isPublic = true;
}
if (!$isPublic) { if (!$isPublic) {
if ($userId <= 0) { if ($userId <= 0) {

View file

@ -1,6 +1,6 @@
<?php <?php
require_once __DIR__ . '/../api/helpers.php'; require_once __DIR__ . '/../api/helpers.php';
// No runAuth() — cron/public endpoint requireCronSecret();
/** /**
* Expire stale chats (older than 20 minutes with no recent activity). * Expire stale chats (older than 20 minutes with no recent activity).

View file

@ -1,7 +1,7 @@
<?php <?php
require_once __DIR__ . '/../api/helpers.php'; require_once __DIR__ . '/../api/helpers.php';
require_once __DIR__ . '/../api/config/stripe.php'; require_once __DIR__ . '/../api/config/stripe.php';
// No runAuth() — cron/public endpoint requireCronSecret();
/** /**
* Scheduled task to handle tab expiry and cleanup. * Scheduled task to handle tab expiry and cleanup.