Add backend PIN verification and security hardening

- Add requirePin() check on add/update/delete endpoints (closes PIN bypass vulnerability)
- Restrict CORS to specific allowed origins only
- Add input length limits to sanitize() function
- Frontend now sends currentPin with all write requests
- Deploy script copies data/index.php to block directory listing

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Eric Wagoner
2025-12-23 22:30:39 -05:00
parent 08d550b0bd
commit 7e523392c0
3 changed files with 63 additions and 13 deletions

52
api.php
View File

@@ -7,7 +7,16 @@
*/ */
header('Content-Type: application/json'); header('Content-Type: application/json');
header('Access-Control-Allow-Origin: *');
// CORS - restrict to same origin (remove for local development if needed)
$allowedOrigins = [
'https://pantry.kestrelsnest.social',
'http://localhost:8000'
];
$origin = $_SERVER['HTTP_ORIGIN'] ?? '';
if (in_array($origin, $allowedOrigins)) {
header("Access-Control-Allow-Origin: $origin");
}
header('Access-Control-Allow-Methods: POST, GET, OPTIONS'); header('Access-Control-Allow-Methods: POST, GET, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type'); header('Access-Control-Allow-Headers: Content-Type');
@@ -135,8 +144,26 @@ function getNextId($inventory) {
/** /**
* Sanitize string input * Sanitize string input
*/ */
function sanitize($str) { function sanitize($str, $maxLength = 200) {
return trim($str); $str = trim($str);
if (strlen($str) > $maxLength) {
$str = substr($str, 0, $maxLength);
}
return $str;
}
/**
* Check if PIN is required and valid for write operations
*/
function requirePin($input) {
// If no PIN is set yet, allow writes (first-time setup)
if (!hasPinSet()) {
return true;
}
// PIN is set, so require it for write operations
$pin = $input['pin'] ?? '';
return verifyPin($pin);
} }
// Main request handling // Main request handling
@@ -195,8 +222,13 @@ switch ($action) {
break; break;
case 'add': case 'add':
if (!requirePin($input)) {
echo json_encode(['success' => false, 'error' => 'Invalid PIN']);
break;
}
$name = sanitize($input['name'] ?? ''); $name = sanitize($input['name'] ?? '');
$container = sanitize($input['container'] ?? 'glass-bail-medium'); $container = sanitize($input['container'] ?? 'glass-bail-medium', 50);
$outOfStock = (bool)($input['outOfStock'] ?? false); $outOfStock = (bool)($input['outOfStock'] ?? false);
if (empty($name)) { if (empty($name)) {
@@ -219,9 +251,14 @@ switch ($action) {
break; break;
case 'update': case 'update':
if (!requirePin($input)) {
echo json_encode(['success' => false, 'error' => 'Invalid PIN']);
break;
}
$id = (int)($input['id'] ?? 0); $id = (int)($input['id'] ?? 0);
$name = sanitize($input['name'] ?? ''); $name = sanitize($input['name'] ?? '');
$container = sanitize($input['container'] ?? ''); $container = sanitize($input['container'] ?? '', 50);
$outOfStock = (bool)($input['outOfStock'] ?? false); $outOfStock = (bool)($input['outOfStock'] ?? false);
if ($id <= 0) { if ($id <= 0) {
@@ -252,6 +289,11 @@ switch ($action) {
break; break;
case 'delete': case 'delete':
if (!requirePin($input)) {
echo json_encode(['success' => false, 'error' => 'Invalid PIN']);
break;
}
$id = (int)($input['id'] ?? 0); $id = (int)($input['id'] ?? 0);
if ($id <= 0) { if ($id <= 0) {

3
deploy
View File

@@ -8,6 +8,9 @@ rsync -avz --no-t --no-p --delete \
--exclude 'data/' \ --exclude 'data/' \
index.html api.php containers.json og-image.png ${HOST}:${DIR} index.html api.php containers.json og-image.png ${HOST}:${DIR}
# Deploy data directory protection
scp data/index.php ${HOST}:${DIR}/data/index.php 2>/dev/null || true
# Handle data files # Handle data files
if [ "$1" = "--reset-data" ]; then if [ "$1" = "--reset-data" ]; then
echo "Pushing local data to server..." echo "Pushing local data to server..."

View File

@@ -790,6 +790,7 @@
let inventory = []; let inventory = [];
let isUnlocked = false; let isUnlocked = false;
let currentPin = null;
let currentFilter = 'all'; let currentFilter = 'all';
let containerFilter = null; let containerFilter = null;
let hasPinSet = false; let hasPinSet = false;
@@ -1080,6 +1081,7 @@
function toggleLock() { function toggleLock() {
if (isUnlocked) { if (isUnlocked) {
isUnlocked = false; isUnlocked = false;
currentPin = null;
updateLockUI(); updateLockUI();
showToast('Locked'); showToast('Locked');
} else { } else {
@@ -1110,6 +1112,7 @@
if (result.success) { if (result.success) {
isUnlocked = true; isUnlocked = true;
currentPin = pin;
updateLockUI(); updateLockUI();
closeModal('pinModal'); closeModal('pinModal');
document.getElementById('pinInput').value = ''; document.getElementById('pinInput').value = '';
@@ -1149,6 +1152,7 @@
if (result.success) { if (result.success) {
hasPinSet = true; hasPinSet = true;
isUnlocked = true; isUnlocked = true;
currentPin = newPin;
updateLockUI(); updateLockUI();
closeModal('setPinModal'); closeModal('setPinModal');
document.getElementById('newPinInput').value = ''; document.getElementById('newPinInput').value = '';
@@ -1231,7 +1235,8 @@
id: parseInt(editingId), id: parseInt(editingId),
name, name,
container, container,
outOfStock outOfStock,
pin: currentPin
}); });
const item = inventory.find(i => i.id === parseInt(editingId)); const item = inventory.find(i => i.id === parseInt(editingId));
@@ -1243,7 +1248,7 @@
showToast('Item updated'); showToast('Item updated');
} else { } else {
// Add new // Add new
const result = await apiCall('add', { name, container, outOfStock }); const result = await apiCall('add', { name, container, outOfStock, pin: currentPin });
if (result.success && result.item) { if (result.success && result.item) {
inventory.push(result.item); inventory.push(result.item);
@@ -1270,7 +1275,7 @@
showLoading(); showLoading();
try { try {
await apiCall('delete', { id: parseInt(editingId) }); await apiCall('delete', { id: parseInt(editingId), pin: currentPin });
inventory = inventory.filter(i => i.id !== parseInt(editingId)); inventory = inventory.filter(i => i.id !== parseInt(editingId));
renderInventory(); renderInventory();