POSA_LEAKSMS/php/api/photo_upload.php
cw2007 73e49fd528 feat: RPi4 누수+온습도 2-모듈 단일 서버 통합 + 보안 하드닝
2번 모듈 신규(raspberry_pi4_sht30/): SHT30 온습도 → cafe24, raw-body X-Signature HMAC, 폐쇄망 오프라인 설치(vendor/install), systemd, CRC/포맷 자가점검. firmware sht30_fw wire 포맷과 byte-parity 검증.

1번 모듈 통합(raspberry_pi4/): 누수+카메라를 동일 서버로. 레거시 sha256(key+json) body-sig → X-Signature HMAC 전환, 사진 canonical=device_id|sensor_id|log_id|timestamp.

서버 통합(php/): api/sensor_data.php 누수 이벤트 수신, api/photo_upload.php 신규(HMAC·finfo·서버생성 파일명·nonce 멱등·sensor 결속), photo.php 인증 사진서빙, dashboard.php 누수 패널+사진, sms_send.php 누수 SMS, config.php 태그/요약.

DB: sql/migration_add_leak.sql(가산·멱등), schema_sht30.sql 통합 스키마.

보안 하드닝(외부 펜테스트 대비, 확정 CRITICAL/HIGH 0): cron_heartbeat api_key fail-closed, setup_mfa 부트스트랩 토큰 post-enrollment 잠금, var/ PHP 실행차단, HSTS.

문서/테스트: SYSTEM_OVERVIEW.md, security_hardening_test.php 갱신. 참고: 사용자 요청으로 세션 외 기존 미커밋 변경도 함께 포함.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 16:21:11 +09:00

356 lines
16 KiB
PHP

<?php
// =============================================================================
// api/photo_upload.php - 1번 모듈(누수+카메라) 누수 사진 업로드 수신 API
//
// [흐름] 1번 모듈은 누수 감지 시 api/sensor_data.php 에 leak_alert 를 보내 log_id 를
// 받은 뒤, Pi Camera 로 촬영한 JPEG 을 이 엔드포인트에 multipart 로 업로드한다.
//
// [요청 형식] POST multipart/form-data
// form fields: device_id, sensor_id, log_id, timestamp
// file field: photo (단일, image/jpeg)
// header : X-Signature
//
// [인증] X-Signature = lowercase_hex(HMAC-SHA256(key=API_KEY, msg=canonical))
// canonical = device_id . "|" . sensor_id . "|" . log_id . "|" . timestamp (공백 없음)
// 클라이언트(raspberry_pi4/leak_sensor.py upload_photo)와 바이트 동일해야 한다.
// - timestamp 신선도(INGEST_MAX_CLOCK_SKEW_SEC)로 재전송(replay) 방어.
// - hash_equals 로 타이밍 안전 비교, 불일치 시 403.
// - 기본 API_KEY(미설정)면 수신 거부(fail-closed).
//
// [파일 보안]
// - 단일 file field "photo" 만 허용, 크기 상한(PHOTO_MAX_BYTES) 강제.
// - MIME 은 클라이언트 신고가 아니라 임시파일을 finfo 로 직접 검사(image/jpeg 만 허용).
// - 저장 파일명은 서버가 생성하는 무작위 이름(클라이언트 파일명/경로 미사용 → 경로주입 차단).
// - 저장 위치는 php/var/photos/ (없으면 생성). leak_photo 에 메타데이터만 기록.
//
// [응답] 200 OK application/json {"status":"ok","photo_id":..,"filename":".."}
// =============================================================================
header('Access-Control-Allow-Origin: https://pores.kr');
header('Access-Control-Allow-Methods: POST, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type, X-Device-Id, X-Signature');
if (($_SERVER['REQUEST_METHOD'] ?? '') === 'OPTIONS') {
http_response_code(204);
exit;
}
if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') {
http_response_code(405);
header('Content-Type: application/json; charset=utf-8');
echo json_encode(['status' => 'error', 'message' => 'Method Not Allowed']);
exit;
}
require_once dirname(__DIR__) . '/config.php';
// 누수 사진 1장 최대 크기(바이트). 기본 2MB. 인증 전 대용량 업로드 증폭형 DoS 방지.
if (!defined('PHOTO_MAX_BYTES')) {
define('PHOTO_MAX_BYTES', 2 * 1024 * 1024);
}
// multipart 헤더/필드(boundary, device_id, sensor_id, log_id, timestamp 등) 오버헤드 여유.
// Content-Length 사전 거부에서 사진 본문 외 부수 바이트를 감안한다.
if (!defined('PHOTO_MULTIPART_OVERHEAD_BYTES')) {
define('PHOTO_MULTIPART_OVERHEAD_BYTES', 8192);
}
// ── 인증 전 사전 거부: Content-Length 가 상한+오버헤드를 넘으면 즉시 413 ──
// 서명 검증/파일 수신 등 무거운 처리에 들어가기 전에 차단(증폭형 DoS 방어).
$declared_len = (int)($_SERVER['CONTENT_LENGTH'] ?? 0);
if ($declared_len > PHOTO_MAX_BYTES + PHOTO_MULTIPART_OVERHEAD_BYTES) {
error_log("[PHOTO] Content-Length 과대({$declared_len}B) from " . ($_SERVER['REMOTE_ADDR'] ?? '?'));
record_ingest_rejection('photo_too_large');
json_response(['status' => 'error', 'message' => '사진 크기 오류(최대 ' . PHOTO_MAX_BYTES . 'B)'], 413);
}
// ── 운영 비밀값 강제: 기본 API_KEY 로는 수신 거부(fail-closed) ──
if (api_key_is_default()) {
error_log("[PHOTO] 거부: API_KEY 가 기본값입니다. config.local.php 에 운영 키를 설정하세요.");
record_ingest_rejection('key_default');
json_response(['status' => 'error', 'message' => '서버 구성 오류'], 503);
}
// 입력 문자열 정제: 제어문자 제거(로그/경로 주입 방지). 서명 검증은 정제 전 원본으로 한다.
function photo_sanitize_str(string $s): string {
return preg_replace('/[[:cntrl:]]+/', '', $s);
}
// 테이블 존재 확인(미통합 배포 graceful degrade). sensor_data.php api_table_exists 와 동일.
function photo_table_exists(PDO $db, string $table): bool {
try {
$stmt = $db->prepare("
SELECT COUNT(*)
FROM information_schema.TABLES
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = :table
");
$stmt->execute([':table' => $table]);
return (int)$stmt->fetchColumn() > 0;
} catch (Throwable $e) {
error_log("[PHOTO] table check failed: {$table} " . $e->getMessage());
return false;
}
}
// ── 재전송(replay) 멱등 선점/해제 ──────────────────────────────
// sensor_data.php 의 ingest_nonce_claim/ingest_nonce_release 와 동일하게 동작한다.
// (photo_upload.php 는 sensor_data.php 를 include 하지 않으므로 여기서 동일 헬퍼를 둔다.)
// 'claimed'/'duplicate'/'unavailable'
function photo_nonce_claim(PDO $db, string $sig): string {
if (!INGEST_NONCE_ENABLED || $sig === '') {
return 'unavailable';
}
try {
$ins = $db->prepare("INSERT INTO ingest_nonce (signature, seen_at) VALUES (:sig, NOW())");
$ins->execute([':sig' => substr($sig, 0, 128)]);
} catch (PDOException $e) {
$code = $e->getCode();
if ($code === '23000') {
return 'duplicate'; // PK 충돌 = 동일 서명 재수신
}
if ($code !== '42S02') { // 42S02 = 테이블 없음(기존 배포) → 조용히 비활성
error_log("[PHOTO] nonce claim 오류({$code}): " . $e->getMessage());
}
return 'unavailable';
}
try {
$ttl = max(60, INGEST_MAX_CLOCK_SKEW_SEC * 2);
$db->exec("DELETE FROM ingest_nonce WHERE seen_at < (NOW() - INTERVAL " . (int)$ttl . " SECOND)");
} catch (Throwable $e) {
/* 정리는 실패해도 무방 */
}
return 'claimed';
}
function photo_nonce_release(PDO $db, string $sig): void {
if ($sig === '') {
return;
}
try {
$db->prepare("DELETE FROM ingest_nonce WHERE signature = :sig")
->execute([':sig' => substr($sig, 0, 128)]);
} catch (Throwable $e) {
error_log("[PHOTO] nonce release 오류: " . $e->getMessage());
}
}
// 새 var/photos/ 디렉터리에 보호 파일(.htaccess + index.html)을 best-effort 로 기록한다.
// 첫 업로드 직후에도 웹 직접 접근이 차단된 상태가 되도록(mkdir 시점 자가보호).
function photo_write_dir_guards(string $dir): void {
$htaccess = $dir . '/.htaccess';
if (!is_file($htaccess)) {
@file_put_contents(
$htaccess,
"<IfModule mod_authz_core.c>\n Require all denied\n</IfModule>\n"
. "<IfModule !mod_authz_core.c>\n Order allow,deny\n Deny from all\n</IfModule>\n"
. "<IfModule mod_autoindex.c>\n Options -Indexes\n</IfModule>\n"
);
}
$index = $dir . '/index.html';
if (!is_file($index)) {
@file_put_contents($index, "<!DOCTYPE html><html><head><meta charset=\"utf-8\"><title>403</title></head><body></body></html>\n");
}
}
// ── 입력 추출 (multipart form fields) ──
$device_id_raw = (string)($_POST['device_id'] ?? '');
$sensor_id_raw = (string)($_POST['sensor_id'] ?? '');
$log_id_raw = (string)($_POST['log_id'] ?? '');
$timestamp_raw = (string)($_POST['timestamp'] ?? '');
if ($device_id_raw === '' || $sensor_id_raw === '' || $log_id_raw === '' || $timestamp_raw === '') {
record_ingest_rejection('missing_field');
json_response(['status' => 'error', 'message' => '필드 누락'], 400);
}
// ── 서명 검증 (canonical = device_id|sensor_id|log_id|timestamp, 공백 없음) ──
// 서명 입력은 클라이언트가 보낸 값을 "그대로" 이어 붙인다(클라이언트도 동일 문자열로 서명).
$received_sig = $_SERVER['HTTP_X_SIGNATURE'] ?? '';
$canonical = $device_id_raw . '|' . $sensor_id_raw . '|' . $log_id_raw . '|' . $timestamp_raw;
$expected_sig = hash_hmac('sha256', $canonical, API_KEY);
if ($received_sig === '' || !hash_equals($expected_sig, $received_sig)) {
error_log("[PHOTO] 서명 불일치 from " . ($_SERVER['REMOTE_ADDR'] ?? '?'));
record_ingest_rejection('signature');
json_response(['status' => 'error', 'message' => '인증 실패'], 403);
}
// ── 재전송(replay) 방어: timestamp 신선도 ──
$ts = (int)$timestamp_raw;
$now = time();
if ($ts <= 0 || abs($now - $ts) > INGEST_MAX_CLOCK_SKEW_SEC) {
error_log("[PHOTO] timestamp 신선도 위반 (ts={$ts}, now={$now}) from " . ($_SERVER['REMOTE_ADDR'] ?? '?'));
record_ingest_rejection('timestamp');
json_response(['status' => 'error', 'message' => '요청 시각이 허용 범위를 벗어났습니다'], 400);
}
// ── 값 검증 ──
$sensor_id = (int)$sensor_id_raw;
$log_id = (int)$log_id_raw;
if ($sensor_id < 1 || $sensor_id > 255) {
record_ingest_rejection('sensor_id_range');
json_response(['status' => 'error', 'message' => 'sensor_id 범위 오류(1..255)'], 400);
}
if ($log_id < 1) {
record_ingest_rejection('log_id_range');
json_response(['status' => 'error', 'message' => 'log_id 오류'], 400);
}
$device_id = photo_sanitize_str(substr(trim($device_id_raw), 0, 64));
// ── 업로드 파일 검증 ──
if (!isset($_FILES['photo']) || !is_array($_FILES['photo'])) {
record_ingest_rejection('no_file');
json_response(['status' => 'error', 'message' => 'photo 파일 누락'], 400);
}
$file = $_FILES['photo'];
if (($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
record_ingest_rejection('upload_error');
json_response(['status' => 'error', 'message' => '업로드 오류'], 400);
}
$tmp_path = (string)($file['tmp_name'] ?? '');
if ($tmp_path === '' || !is_uploaded_file($tmp_path)) {
// is_uploaded_file: HTTP 업로드로 들어온 임시파일만 허용(임의 경로 주입 차단).
record_ingest_rejection('not_uploaded');
json_response(['status' => 'error', 'message' => '잘못된 업로드'], 400);
}
$size = (int)($file['size'] ?? 0);
if ($size <= 0 || $size > PHOTO_MAX_BYTES) {
record_ingest_rejection('photo_too_large');
json_response(['status' => 'error', 'message' => '사진 크기 오류(최대 ' . PHOTO_MAX_BYTES . 'B)'], 413);
}
// MIME: 클라이언트 신고를 믿지 않고 임시파일을 finfo 로 직접 판별. image/jpeg 만 허용.
$mime = '';
if (class_exists('finfo')) {
$finfo = new finfo(FILEINFO_MIME_TYPE);
$mime = (string)$finfo->file($tmp_path);
} elseif (function_exists('mime_content_type')) {
$mime = (string)mime_content_type($tmp_path);
}
if ($mime !== 'image/jpeg') {
error_log("[PHOTO] MIME 거부: {$mime} from " . ($_SERVER['REMOTE_ADDR'] ?? '?'));
record_ingest_rejection('photo_mime');
json_response(['status' => 'error', 'message' => 'JPEG 이미지만 허용됩니다'], 415);
}
// ── 실제 파일 크기 재검증 (클라이언트 신고 $_FILES['size'] 만 믿지 않음) ──
// finfo 와 마찬가지로 임시파일을 직접 측정해 상한을 다시 확인한다.
$real_size = @filesize($tmp_path);
if ($real_size === false || $real_size <= 0 || $real_size > PHOTO_MAX_BYTES) {
record_ingest_rejection('photo_too_large');
json_response(['status' => 'error', 'message' => '사진 크기 오류(최대 ' . PHOTO_MAX_BYTES . 'B)'], 413);
}
$size = (int)$real_size; // 저장 메타에는 실측 크기를 기록
// ── log_id 존재/일관성 확인 + 저장 ──
$nonce_state = 'unavailable';
$photo_id = null;
$filename = '';
try {
$db = get_db();
// log_id 가 sensor_log 에 실제로 존재하는지 + 어느 센서/디바이스의 로그인지 확인한다
// (임의 log_id 로 사진 누적 방지, 교차 센서 결속). 권위값은 로그 행의 sensor_id/device_id.
$chk = $db->prepare("SELECT sensor_id, device_id FROM sensor_log WHERE id = :id");
$chk->execute([':id' => $log_id]);
$log_row = $chk->fetch();
if (!$log_row) {
record_ingest_rejection('log_id_unknown');
json_response(['status' => 'error', 'message' => 'log_id 를 찾을 수 없습니다'], 400);
}
// 교차 센서 업로드 차단: 요청의 sensor_id 가 로그 행의 센서와 다르면 거부.
$log_sensor_id = (int)$log_row['sensor_id'];
if ($log_sensor_id !== $sensor_id) {
error_log("[PHOTO] 교차 센서 거부: req sensor_id={$sensor_id} vs log sensor_id={$log_sensor_id} (log_id={$log_id})");
record_ingest_rejection('sensor_mismatch');
json_response(['status' => 'error', 'message' => 'log_id 와 sensor_id 가 일치하지 않습니다'], 403);
}
// 저장에는 로그 행의 권위값을 사용(클라이언트 신고가 아니라 서버가 아는 값).
$store_sensor_id = $log_sensor_id;
$store_device_id = photo_sanitize_str(substr((string)$log_row['device_id'], 0, 64));
// ── 재전송(replay) 멱등 선점 (sensor_data.php 와 동일) ──
// 같은 X-Signature 가 시계오차 창 안에서 다시 오면(클라이언트 재시도/캡처 재생)
// 두 번째 파일/행을 만들지 않고 멱등 200 으로 종료한다.
$nonce_state = photo_nonce_claim($db, $received_sig);
if ($nonce_state === 'duplicate') {
json_response([
'status' => 'ok',
'message' => '이미 처리된 요청(멱등)',
'duplicate' => true,
]);
}
// 벨트앤서스펜더: 같은 log_id 에 이미 사진이 있으면 중복으로 보고 건너뛴다
// (정책: log_id 당 사진 1장). nonce 가 비활성/정리된 경우의 추가 방어.
if (photo_table_exists($db, 'leak_photo')) {
$dup = $db->prepare("SELECT id, filename FROM leak_photo WHERE log_id = :log_id ORDER BY id ASC LIMIT 1");
$dup->execute([':log_id' => $log_id]);
$dup_row = $dup->fetch();
if ($dup_row) {
json_response([
'status' => 'ok',
'message' => '이미 사진이 저장된 log_id(멱등)',
'duplicate' => true,
'photo_id' => (int)$dup_row['id'],
'filename' => (string)$dup_row['filename'],
]);
}
}
// 저장 디렉터리 (없으면 생성). 웹에서 직접 접근되지 않도록 var/ 하위에 둔다.
$photo_dir = dirname(__DIR__) . '/var/photos';
if (!is_dir($photo_dir)) {
if (!@mkdir($photo_dir, 0750, true) && !is_dir($photo_dir)) {
error_log("[PHOTO] 디렉터리 생성 실패: {$photo_dir}");
photo_nonce_release($db, $received_sig);
json_response(['status' => 'error', 'message' => '저장소 오류'], 500);
}
}
// 새로 만든(또는 기존) 디렉터리에 보호 파일을 best-effort 로 기록(웹 직접 접근 차단).
photo_write_dir_guards($photo_dir);
// 서버 생성 무작위 파일명(클라이언트 파일명/경로 미사용 → 경로주입 원천 차단).
$filename = sprintf('leak_%d_%d_%s.jpg', $store_sensor_id, $log_id, bin2hex(random_bytes(8)));
$dest = $photo_dir . '/' . $filename;
if (!move_uploaded_file($tmp_path, $dest)) {
error_log("[PHOTO] move_uploaded_file 실패: {$dest}");
photo_nonce_release($db, $received_sig);
json_response(['status' => 'error', 'message' => '사진 저장 실패'], 500);
}
@chmod($dest, 0640);
// leak_photo 메타데이터 기록 (파라미터 바인딩, 권위 sensor_id/device_id 사용).
$captured_at = date('Y-m-d H:i:s', $ts);
$ins = $db->prepare("
INSERT INTO leak_photo
(log_id, sensor_id, device_id, filename, mime, bytes, captured_at, created_at)
VALUES
(:log_id, :sensor_id, :device_id, :filename, :mime, :bytes, :captured_at, NOW())
");
$ins->execute([
':log_id' => $log_id,
':sensor_id' => $store_sensor_id,
':device_id' => $store_device_id,
':filename' => $filename,
':mime' => $mime,
':bytes' => $size,
':captured_at' => $captured_at,
]);
$photo_id = (int)$db->lastInsertId();
} catch (PDOException $e) {
error_log("[PHOTO] DB 오류: " . $e->getMessage());
// 저장 실패 시 선점한 nonce 를 되돌려, 디바이스 재시도가 재처리되도록 한다(유실 방지).
if (isset($db) && $nonce_state === 'claimed') {
photo_nonce_release($db, $received_sig);
}
json_response(['status' => 'error', 'message' => 'DB 저장 실패'], 500);
}
json_response([
'status' => 'ok',
'message' => '사진 저장 완료',
'photo_id' => $photo_id,
'filename' => $filename,
]);