diff --git a/CHANGELOG.md b/CHANGELOG.md index c851e0e..2c1efc7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,24 @@ - `sql/migration_drop_leak.sql`: information_schema 가드 프로시저로 멱등화했습니다(재실행/ 부분 적용 DB 에서도 오류 없이 진행). +### 보안 리뷰 후속 수정 (다중 에이전트 검증에서 발견) +- **(HIGH) 예시 TOTP 비밀키 회귀 수정.** `config.local.example.php` 의 + `ADMIN_TOTP_SECRET` 플레이스홀더(`REPLACE_WITH_UNIQUE_BASE32_SECRET`)가 Base32 로 + 디코드되어(`REPLACEWITHUNIQUEBASE32SECRET` → 18바이트 키) **실제 코드 `275426` 를 + 생성** = 공개된 시드로 MFA 가 활성화되는 문제였습니다. 빈 문자열 `''` 로 교체해 + fail-closed(키 미설정 시 로그인 차단)로 만들고, 회귀 테스트로 고정했습니다. +- **(MEDIUM) 레거시 서명 다운그레이드 경로 차단.** `api/sensor_data.php` 가 + `X-Signature` 헤더가 없으면 약한 키-접두 sha256(`verify_signature`)로 폴백하던 것을, + `ALLOW_LEGACY_BODY_SIGNATURE`(기본 false)로 게이트했습니다. 기본값에서는 HMAC 헤더가 + 없으면 거부합니다(운영 STM32 클라이언트는 HMAC 만 사용). +- **(LOW) 인증 전 본문 크기 상한.** `INGEST_MAX_BODY_BYTES`(기본 8KB) 초과 본문은 + json_decode/HMAC 이전에 413 으로 거부 — 미인증 증폭형 DoS 차단. +- **(LOW) heartbeat throttle 원자화.** `check_offline_sensors_throttled()` 의 비원자 + read-then-write 를 flock 기반 원자 판정으로 교체 — 동시 호출 시 중복 오프라인 스캔 방지. +- **(LOW) `CRON_SECRET` 강화.** `md5('cron_'.API_KEY)` → `hash_hmac('sha256','cron',API_KEY)`. +- 알려진 잔여(문서화): 600초 창 내 재전송 dedup 부재, 운영 키 보유자에 의한 ingest + flooding(호스트 레벨 rate-limit/WAF 권장), `.htaccess` Apache 전용. + ## Unreleased - SHT30 온습도 전용 전환 ### 시스템 범위 diff --git a/php/api/sensor_data.php b/php/api/sensor_data.php index c4c7e0b..d0dd885 100644 --- a/php/api/sensor_data.php +++ b/php/api/sensor_data.php @@ -84,7 +84,15 @@ function api_table_exists(PDO $db, string $table): bool { } // ── 요청 파싱 ───────────────────────────────────────────── -$raw = file_get_contents('php://input'); +$raw = file_get_contents('php://input'); + +// 본문 크기 상한: 인증 전 대용량 본문을 json_decode/HMAC 에 태우는 증폭형 DoS 차단. +if (strlen($raw) > INGEST_MAX_BODY_BYTES) { + error_log("[API] 본문 과대(" . strlen($raw) . "B > " . INGEST_MAX_BODY_BYTES . "B) from " + . ($_SERVER['REMOTE_ADDR'] ?? '?')); + json_response(['status' => 'error', 'message' => '본문이 너무 큽니다'], 413); +} + $data = json_decode($raw, true); if (!$data) { @@ -92,13 +100,22 @@ if (!$data) { } // ── 서명 검증 ───────────────────────────────────────────── -// X-Signature 헤더가 있으면 raw-body 서명(STM32 펌웨어)으로 검증하고, -// 없으면 본문 필드 기반 레거시 서명으로 검증한다. +// X-Signature 헤더가 있으면 raw-body HMAC 서명(STM32 펌웨어)으로 검증한다. +// 레거시 본문 필드 서명(키-접두 sha256)은 기본 비활성(ALLOW_LEGACY_BODY_SIGNATURE). +// 운영 클라이언트(STM32)는 HMAC 만 쓰므로 약한 다운그레이드 경로를 닫아 둔다. $has_raw_sig = isset($_SERVER['HTTP_X_SIGNATURE']) && $_SERVER['HTTP_X_SIGNATURE'] !== ''; -$sig_ok = $has_raw_sig ? verify_signature_raw($raw) : verify_signature($data); +if ($has_raw_sig) { + $sig_ok = verify_signature_raw($raw); + $sig_mode = 'raw'; +} elseif (ALLOW_LEGACY_BODY_SIGNATURE) { + $sig_ok = verify_signature($data); + $sig_mode = 'legacy'; +} else { + $sig_ok = false; // X-Signature 헤더 필수 (레거시 비활성) + $sig_mode = 'none'; +} if (!$sig_ok) { - error_log("[API] 서명 불일치 from " . ($_SERVER['REMOTE_ADDR'] ?? '?') - . " (mode=" . ($has_raw_sig ? 'raw' : 'legacy') . ")"); + error_log("[API] 서명 불일치 from " . ($_SERVER['REMOTE_ADDR'] ?? '?') . " (mode={$sig_mode})"); json_response(['status' => 'error', 'message' => '인증 실패'], 403); } diff --git a/php/config.local.example.php b/php/config.local.example.php index a50cd2d..e928482 100644 --- a/php/config.local.example.php +++ b/php/config.local.example.php @@ -25,12 +25,14 @@ return [ 'ADMIN_USER' => 'admin', 'ADMIN_PASSWORD_HASH' => '$2y$10$replace.with.password_hash.output', - // Base32 TOTP secret for Google Authenticator, Microsoft Authenticator, Authy, etc. - // Generate a UNIQUE secret with `php setup_mfa.php` (or admin_totp_secret()) and store - // the QR/secret only in the operator handover package. The placeholder below is NOT a - // valid Base32 secret on purpose, so MFA stays disabled until a real secret is set — - // never ship a known/example secret here. - 'ADMIN_TOTP_SECRET' => 'REPLACE_WITH_UNIQUE_BASE32_SECRET', + // Base32 TOTP secret. MUST be left EMPTY here and set to a UNIQUE generated value in + // config.local.php. Empty '' => admin_mfa_configured() is false => login is DISABLED + // (fail-closed) until a real secret is set. NEVER put a literal Base32 string in this + // example file: any non-empty Base32-ish value (even "REPLACE_WITH_...") decodes to a + // usable, publicly-known TOTP secret and silently enables MFA on a known seed. + // Generate a unique secret on the server only: + // php -r "require 'php/admin_security.php'; echo admin_totp_secret().PHP_EOL;" + 'ADMIN_TOTP_SECRET' => '', // Temporary bootstrap token for setup_mfa.php. Remove or empty after MFA enrollment. 'MFA_SETUP_TOKEN' => 'replace-with-temporary-random-token', diff --git a/php/config.php b/php/config.php index 631568b..c527c72 100644 --- a/php/config.php +++ b/php/config.php @@ -44,6 +44,15 @@ function api_key_is_default(): bool { // 재전송(replay) 방어를 위해 거부한다. SHT30 보고 주기(5분) 대비 넉넉히 둔다. define('INGEST_MAX_CLOCK_SKEW_SEC', (int)cfg('INGEST_MAX_CLOCK_SKEW_SEC', 600)); +// 수신 본문 최대 크기(바이트). 정상 디바이스 본문은 ~1KB 미만. 인증 전 대용량 +// 본문을 json_decode/HMAC 에 태우는 증폭형 DoS 를 차단한다. +define('INGEST_MAX_BODY_BYTES', (int)cfg('INGEST_MAX_BODY_BYTES', 8192)); + +// 레거시 본문 필드 서명(verify_signature: 키-접두 sha256) 수용 여부. 기본 비활성. +// 운영 클라이언트(STM32)는 HMAC raw-body 서명만 쓰므로, 약한 경로를 닫아 둔다. +// 과거 RPi 클라이언트가 남아 있을 때만 1 로 켠다. +define('ALLOW_LEGACY_BODY_SIGNATURE', (bool)cfg('ALLOW_LEGACY_BODY_SIGNATURE', false)); + // ─── SMS 설정 (Cafe24 SMS API) ────────────────────────────── define('SMS_USER_ID', cfg('SMS_USER_ID', 'your-cafe24-sms-user-id')); define('SMS_SECURE', cfg('SMS_SECURE', 'your-cafe24-sms-secure-key')); @@ -76,7 +85,7 @@ define('METRIC_ALERT_COOLDOWN_SEC', (int)cfg('METRIC_ALERT_COOLDOWN_SEC', 1800)) // 오프라인 판정은 약 4회 누락(20분)으로 둔다. config.local.php 로 조정 가능. define('HEARTBEAT_TIMEOUT_SEC', (int)cfg('HEARTBEAT_TIMEOUT_SEC', 1200)); define('HEARTBEAT_CHECK_INTERVAL_SEC', (int)cfg('HEARTBEAT_CHECK_INTERVAL_SEC', 300)); // 오프라인 체크 실행 주기 -define('CRON_SECRET', md5('cron_' . API_KEY)); // cron HTTP 접근용 시크릿 +define('CRON_SECRET', hash_hmac('sha256', 'cron', API_KEY)); // cron HTTP 접근용 시크릿(API_KEY 파생, HMAC) // ─── 대시보드 세션 인증 ────────────────────────────────── define('ADMIN_USER', cfg('ADMIN_USER', 'admin')); diff --git a/php/cron_heartbeat.php b/php/cron_heartbeat.php index 627be28..42c5857 100644 --- a/php/cron_heartbeat.php +++ b/php/cron_heartbeat.php @@ -80,21 +80,46 @@ function check_offline_sensors(): int { * sensor_data.php, dashboard.php 등에서 호출 */ function check_offline_sensors_throttled(): void { - try { - $lock_file = __DIR__ . '/var/heartbeat_last.txt'; + $lock_file = __DIR__ . '/var/heartbeat_last.txt'; - $last_run = @file_get_contents($lock_file); - if ($last_run !== false && (time() - (int)$last_run) < HEARTBEAT_CHECK_INTERVAL_SEC) { - return; // 아직 체크 주기가 안 됨 + // 주기 판정을 원자적으로: 락을 쥔 채 마지막 실행 시각을 읽고, 주기가 도래했으면 + // 타임스탬프를 먼저 갱신한 뒤 락을 풀고 실행한다. 이렇게 하면 동시 호출(여러 + // 디바이스 POST / 대시보드 로드)이 몰려도 한 주기에 단 한 번만 스캔이 돈다. + // (기존 비원자 read-then-write 는 동시 호출이 모두 stale 로 읽혀 중복 스캔됐다.) + $fh = @fopen($lock_file, 'c+'); + if ($fh === false) { + // 파일을 못 열면 가용성 우선: 잠금 없이 한 번 실행. + try { + check_offline_sensors(); + } catch (\Throwable $e) { + error_log("[Heartbeat] 체크 실패: " . $e->getMessage()); } + return; + } - // 타임스탬프 갱신 (먼저 쓰고 실행 → 동시 접근 시 중복 실행 방지) - @file_put_contents($lock_file, (string)time(), LOCK_EX); + $should_run = false; + if (flock($fh, LOCK_EX)) { + $raw = stream_get_contents($fh); + $last = ($raw !== false && $raw !== '') ? (int)$raw : 0; + if ($last === 0 || (time() - $last) >= HEARTBEAT_CHECK_INTERVAL_SEC) { + ftruncate($fh, 0); + rewind($fh); + fwrite($fh, (string)time()); + fflush($fh); + $should_run = true; + } + flock($fh, LOCK_UN); + } + fclose($fh); - check_offline_sensors(); - } catch (\Throwable $e) { - // heartbeat 실패가 호출자(dashboard, sensor_data)를 죽이면 안 됨 - error_log("[Heartbeat] 체크 실패: " . $e->getMessage()); + if ($should_run) { + try { + // 스캔(DB 작업)은 파일 락을 푼 뒤 실행해 락 보유 시간을 최소화한다. + check_offline_sensors(); + } catch (\Throwable $e) { + // heartbeat 실패가 호출자(dashboard, sensor_data)를 죽이면 안 됨 + error_log("[Heartbeat] 체크 실패: " . $e->getMessage()); + } } } diff --git a/tests/security_hardening_test.php b/tests/security_hardening_test.php index 1af1fa5..bd0eacb 100644 --- a/tests/security_hardening_test.php +++ b/tests/security_hardening_test.php @@ -53,6 +53,16 @@ check(str_contains($configExample, 'ADMIN_TOTP_SECRET'), 'config.local.example.p check(str_contains($config, 'MFA_SETUP_TOKEN'), 'config.php should define MFA_SETUP_TOKEN.'); check(str_contains($configExample, 'MFA_SETUP_TOKEN'), 'config.local.example.php should document MFA_SETUP_TOKEN.'); +// Regression (security review): the example MUST NOT ship a usable/known TOTP secret. +// Any non-empty Base32-decodable value silently enables MFA on a publicly-known seed +// (admin_mfa_configured() = non-empty). Empty '' keeps MFA fail-closed until a real +// secret is set. Lock this so the placeholder can't regress to a working secret. +$exampleArr = require $root . '/php/config.local.example.php'; +check(($exampleArr['ADMIN_TOTP_SECRET'] ?? 'x') === '', 'config.local.example.php ADMIN_TOTP_SECRET must be EMPTY (fail-closed; never ship a known/decodable secret).'); +if (function_exists('admin_base32_decode')) { + check(admin_base32_decode((string)($exampleArr['ADMIN_TOTP_SECRET'] ?? '')) === '', 'example TOTP secret must not decode to a usable key.'); +} + $login = file_text($root . '/php/login.php'); check(str_contains($login, 'admin_totp_verify'), 'login.php should verify MFA codes.'); check(str_contains($login, 'otp_code'), 'login.php should render an OTP input.');