POSA_LEAKSMS/php/api/photo_upload.php
cw2007 7a3b571b05 feat(deploy): 기기별 턴키 배포 번들 + Pi OS 하드닝 + 원샷 설치
tools/make_deploy_bundles.py: deploy/ 아래 서버1+Pi3대 폴더 생성 —
API_KEY 1개 자동 생성·4곳 동일 주입, 관리자 bcrypt(비번 stdin 전달), LF 정규화,
단일 schema.sql, var/ 런타임 상태 제외, CRON_SECRET 사전 계산. 난독화 경로
WEB_DIR 은 소스가 아닌 deploy/.secrets/web_dir.txt 에서 로드(커밋 트리 유출 차단).
시크릿 생성 전에 deploy/.gitignore 선작성 + 루트 .gitignore 에 deploy/ 이중 차단.

tools/harden_pi.sh: SSH 완전 비활성(outbound 전용 설계) · 인바운드 전부 차단
(nftables) · 불필요 서비스 제거(avahi/bluetooth/cups 등) · 자동 보안 패치
(unattended-upgrades, 자동 리부팅 끔) · config_local.py 권한 600 · 검증 증적 출력.

setup.sh(번들 생성): 설치→서비스→검증→하드닝 원샷. 검증 실패 시 set -e 로
하드닝(SSH 차단) 전에 중단 → SSH 유지되어 디버깅 가능.

검색 비노출 하드닝: php/.htaccess X-Robots-Tag noindex + Referrer-Policy
no-referrer, api/photo_upload.php CORS 제거, test_mobile.php 세션 인증 가드.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01821XP36Q6t9eWKTPSEumnC
2026-07-02 18:51:46 +09:00

354 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":".."}
// =============================================================================
// CORS 헤더 없음 — 기기 서버측 POST 전용(브라우저 미관여), Allow-Origin 은 도메인 지문만 노출.
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 leak_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 leak_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 leak_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 가 leak_sensor_log 에 실제로 존재하는지 + 어느 센서/디바이스의 로그인지 확인한다
// (임의 log_id 로 사진 누적 방지, 교차 센서 결속). 권위값은 로그 행의 sensor_id/device_id.
$chk = $db->prepare("SELECT sensor_id, device_id FROM leak_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,
]);