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

60
api.php
View File

@@ -1,13 +1,22 @@
<?php
/**
* Pantry Inventory API
*
*
* Simple JSON file-based backend for the pantry inventory app.
* No database required - stores everything in inventory.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-Headers: Content-Type');
@@ -135,8 +144,26 @@ function getNextId($inventory) {
/**
* Sanitize string input
*/
function sanitize($str) {
return trim($str);
function sanitize($str, $maxLength = 200) {
$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
@@ -195,10 +222,15 @@ switch ($action) {
break;
case 'add':
if (!requirePin($input)) {
echo json_encode(['success' => false, 'error' => 'Invalid PIN']);
break;
}
$name = sanitize($input['name'] ?? '');
$container = sanitize($input['container'] ?? 'glass-bail-medium');
$container = sanitize($input['container'] ?? 'glass-bail-medium', 50);
$outOfStock = (bool)($input['outOfStock'] ?? false);
if (empty($name)) {
echo json_encode(['success' => false, 'error' => 'Name required']);
break;
@@ -219,11 +251,16 @@ switch ($action) {
break;
case 'update':
if (!requirePin($input)) {
echo json_encode(['success' => false, 'error' => 'Invalid PIN']);
break;
}
$id = (int)($input['id'] ?? 0);
$name = sanitize($input['name'] ?? '');
$container = sanitize($input['container'] ?? '');
$container = sanitize($input['container'] ?? '', 50);
$outOfStock = (bool)($input['outOfStock'] ?? false);
if ($id <= 0) {
echo json_encode(['success' => false, 'error' => 'Invalid ID']);
break;
@@ -252,8 +289,13 @@ switch ($action) {
break;
case 'delete':
if (!requirePin($input)) {
echo json_encode(['success' => false, 'error' => 'Invalid PIN']);
break;
}
$id = (int)($input['id'] ?? 0);
if ($id <= 0) {
echo json_encode(['success' => false, 'error' => 'Invalid ID']);
break;

3
deploy
View File

@@ -8,6 +8,9 @@ rsync -avz --no-t --no-p --delete \
--exclude 'data/' \
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
if [ "$1" = "--reset-data" ]; then
echo "Pushing local data to server..."

View File

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