POSA_LEAKSMS/php/photo.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

109 lines
4.5 KiB
PHP

<?php
// =============================================================================
// photo.php - 누수 사진 인증 열람 엔드포인트 (관리자 세션 전용)
//
// 누수 사진 원본은 php/var/photos/ 에 저장되며, 웹에서 직접 접근은 .htaccess 로
// 차단된다(var/photos/.htaccess: Require all denied). 따라서 대시보드의 사진
// 썸네일/원본은 반드시 이 엔드포인트(photo.php?id=<leak_photo.id>)를 통해서만
// 열람할 수 있다. 미인증 접근은 dashboard.php 와 동일하게 login.php 로 보낸다.
//
// [보안]
// - dashboard.php 와 동일한 관리자 세션 가드(세션 쿠키 하드닝 + logged_in 검사 +
// 세션 타임아웃 강제)를 그대로 사용한다(별도 인증 스킴 신설 금지).
// - id 는 양의 정수만 허용, leak_photo 에서 filename/mime/bytes 를 파라미터 바인딩
// 으로 조회. 없으면 404.
// - filename 은 서버 생성 패턴(^leak_\d+_\d+_[0-9a-f]+\.jpg$)으로 재검증
// (다중방어). 클라이언트 입력은 경로에 절대 들어가지 않는다.
// - realpath 가 var/photos/ 내부인지 확인(경로 탈출 차단) 후 readfile.
// =============================================================================
require_once __DIR__ . '/config.php';
require_once __DIR__ . '/admin_security.php';
// ── 세션 인증 (dashboard.php 와 동일한 가드) ────────────────
ini_set('session.cookie_httponly', 1);
ini_set('session.cookie_secure', 1);
ini_set('session.cookie_samesite', 'Strict');
ini_set('session.use_strict_mode', 1);
session_start();
// 세션 확인 — 미인증이면 대시보드와 동일하게 로그인으로 리다이렉트
if (empty($_SESSION['logged_in']) || $_SESSION['logged_in'] !== true) {
header('Location: login.php');
exit;
}
// 세션 타임아웃(유휴 + 절대 수명) 강제 — 인증 페이지 공통 헬퍼(admin_security.php)
admin_enforce_session_timeout();
// ── 입력: id (양의 정수) ────────────────────────────────────
$id = isset($_GET['id']) ? (int)$_GET['id'] : 0;
if ($id < 1) {
http_response_code(400);
header('Content-Type: text/plain; charset=utf-8');
echo 'invalid id';
exit;
}
// ── leak_photo 조회 (파라미터 바인딩) ───────────────────────
try {
$db = get_db();
$stmt = $db->prepare("SELECT filename, mime, bytes FROM leak_photo WHERE id = :id");
$stmt->execute([':id' => $id]);
$row = $stmt->fetch();
} catch (Throwable $e) {
error_log("[PHOTO] 조회 오류: " . $e->getMessage());
http_response_code(500);
header('Content-Type: text/plain; charset=utf-8');
echo 'server error';
exit;
}
if (!$row) {
http_response_code(404);
header('Content-Type: text/plain; charset=utf-8');
echo 'not found';
exit;
}
$filename = (string)$row['filename'];
// ── 파일명 다중방어 검증 (서버 생성 패턴만 허용) ────────────
// 예: leak_1_42_a1b2c3d4e5f6a7b8.jpg (sensor_id, log_id, 무작위 hex)
if (!preg_match('/^leak_[0-9]+_[0-9]+_[0-9a-f]+\.jpg$/', $filename)) {
error_log("[PHOTO] 파일명 형식 거부: {$filename}");
http_response_code(404);
header('Content-Type: text/plain; charset=utf-8');
echo 'not found';
exit;
}
// ── 경로 결합 + 탈출 차단 (realpath 가 var/photos/ 내부인지 확인) ──
$photo_dir = realpath(__DIR__ . '/var/photos');
if ($photo_dir === false) {
http_response_code(404);
header('Content-Type: text/plain; charset=utf-8');
echo 'not found';
exit;
}
$path = realpath($photo_dir . '/' . $filename);
// realpath 실패(파일 없음) 또는 결합 경로가 var/photos/ 밖이면 거부(경로 탈출 방어).
if ($path === false || strpos($path, $photo_dir . DIRECTORY_SEPARATOR) !== 0) {
http_response_code(404);
header('Content-Type: text/plain; charset=utf-8');
echo 'not found';
exit;
}
// ── JPEG 전송 (private, no-cache) ───────────────────────────
$size = @filesize($path);
header('Content-Type: image/jpeg');
if ($size !== false) {
header('Content-Length: ' . $size);
}
header('Cache-Control: private, no-store, no-cache, must-revalidate');
header('Pragma: no-cache');
header('X-Content-Type-Options: nosniff');
header('Content-Disposition: inline; filename="' . $filename . '"');
readfile($path);
exit;