fix(security): resolve review findings — TOTP example fail-closed, disable legacy sig, body cap

다중 에이전트 보안 리뷰 후속 수정:
- (HIGH) config.local.example.php ADMIN_TOTP_SECRET '' 로 교체: 기존 플레이스홀더가
  Base32 디코드되어 실제 TOTP 코드를 생성(공개 시드로 MFA 활성화)하던 회귀를 차단.
  fail-closed(미설정 시 로그인 차단) + 회귀 테스트 추가.
- (MEDIUM) 레거시 키-접두 sha256 서명 경로를 ALLOW_LEGACY_BODY_SIGNATURE(기본 false)로
  게이트: X-Signature(HMAC) 없으면 거부.
- (LOW) 인증 전 본문 크기 상한 INGEST_MAX_BODY_BYTES(8KB) → 413.
- (LOW) heartbeat throttle 를 flock 기반 원자 판정으로 교체(중복 스캔 방지).
- (LOW) CRON_SECRET: md5 → hash_hmac('sha256','cron',API_KEY).
This commit is contained in:
유창욱 2026-06-20 10:00:52 +09:00
parent 90f121e14c
commit 749b284941
6 changed files with 105 additions and 24 deletions

View file

@ -30,6 +30,24 @@
- `sql/migration_drop_leak.sql`: information_schema 가드 프로시저로 멱등화했습니다(재실행/ - `sql/migration_drop_leak.sql`: information_schema 가드 프로시저로 멱등화했습니다(재실행/
부분 적용 DB 에서도 오류 없이 진행). 부분 적용 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 온습도 전용 전환 ## Unreleased - SHT30 온습도 전용 전환
### 시스템 범위 ### 시스템 범위

View file

@ -85,6 +85,14 @@ 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); $data = json_decode($raw, true);
if (!$data) { 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'] !== ''; $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) { if (!$sig_ok) {
error_log("[API] 서명 불일치 from " . ($_SERVER['REMOTE_ADDR'] ?? '?') error_log("[API] 서명 불일치 from " . ($_SERVER['REMOTE_ADDR'] ?? '?') . " (mode={$sig_mode})");
. " (mode=" . ($has_raw_sig ? 'raw' : 'legacy') . ")");
json_response(['status' => 'error', 'message' => '인증 실패'], 403); json_response(['status' => 'error', 'message' => '인증 실패'], 403);
} }

View file

@ -25,12 +25,14 @@ return [
'ADMIN_USER' => 'admin', 'ADMIN_USER' => 'admin',
'ADMIN_PASSWORD_HASH' => '$2y$10$replace.with.password_hash.output', 'ADMIN_PASSWORD_HASH' => '$2y$10$replace.with.password_hash.output',
// Base32 TOTP secret for Google Authenticator, Microsoft Authenticator, Authy, etc. // Base32 TOTP secret. MUST be left EMPTY here and set to a UNIQUE generated value in
// Generate a UNIQUE secret with `php setup_mfa.php` (or admin_totp_secret()) and store // config.local.php. Empty '' => admin_mfa_configured() is false => login is DISABLED
// the QR/secret only in the operator handover package. The placeholder below is NOT a // (fail-closed) until a real secret is set. NEVER put a literal Base32 string in this
// valid Base32 secret on purpose, so MFA stays disabled until a real secret is set — // example file: any non-empty Base32-ish value (even "REPLACE_WITH_...") decodes to a
// never ship a known/example secret here. // usable, publicly-known TOTP secret and silently enables MFA on a known seed.
'ADMIN_TOTP_SECRET' => 'REPLACE_WITH_UNIQUE_BASE32_SECRET', // 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. // Temporary bootstrap token for setup_mfa.php. Remove or empty after MFA enrollment.
'MFA_SETUP_TOKEN' => 'replace-with-temporary-random-token', 'MFA_SETUP_TOKEN' => 'replace-with-temporary-random-token',

View file

@ -44,6 +44,15 @@ function api_key_is_default(): bool {
// 재전송(replay) 방어를 위해 거부한다. SHT30 보고 주기(5분) 대비 넉넉히 둔다. // 재전송(replay) 방어를 위해 거부한다. SHT30 보고 주기(5분) 대비 넉넉히 둔다.
define('INGEST_MAX_CLOCK_SKEW_SEC', (int)cfg('INGEST_MAX_CLOCK_SKEW_SEC', 600)); 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) ────────────────────────────── // ─── SMS 설정 (Cafe24 SMS API) ──────────────────────────────
define('SMS_USER_ID', cfg('SMS_USER_ID', 'your-cafe24-sms-user-id')); define('SMS_USER_ID', cfg('SMS_USER_ID', 'your-cafe24-sms-user-id'));
define('SMS_SECURE', cfg('SMS_SECURE', 'your-cafe24-sms-secure-key')); 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 로 조정 가능. // 오프라인 판정은 약 4회 누락(20분)으로 둔다. config.local.php 로 조정 가능.
define('HEARTBEAT_TIMEOUT_SEC', (int)cfg('HEARTBEAT_TIMEOUT_SEC', 1200)); define('HEARTBEAT_TIMEOUT_SEC', (int)cfg('HEARTBEAT_TIMEOUT_SEC', 1200));
define('HEARTBEAT_CHECK_INTERVAL_SEC', (int)cfg('HEARTBEAT_CHECK_INTERVAL_SEC', 300)); // 오프라인 체크 실행 주기 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')); define('ADMIN_USER', cfg('ADMIN_USER', 'admin'));

View file

@ -80,22 +80,47 @@ function check_offline_sensors(): int {
* sensor_data.php, dashboard.php 등에서 호출 * sensor_data.php, dashboard.php 등에서 호출
*/ */
function check_offline_sensors_throttled(): void { 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;
} }
// 타임스탬프 갱신 (먼저 쓰고 실행 → 동시 접근 시 중복 실행 방지) $should_run = false;
@file_put_contents($lock_file, (string)time(), LOCK_EX); 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);
if ($should_run) {
try {
// 스캔(DB 작업)은 파일 락을 푼 뒤 실행해 락 보유 시간을 최소화한다.
check_offline_sensors(); check_offline_sensors();
} catch (\Throwable $e) { } catch (\Throwable $e) {
// heartbeat 실패가 호출자(dashboard, sensor_data)를 죽이면 안 됨 // heartbeat 실패가 호출자(dashboard, sensor_data)를 죽이면 안 됨
error_log("[Heartbeat] 체크 실패: " . $e->getMessage()); error_log("[Heartbeat] 체크 실패: " . $e->getMessage());
} }
}
} }
// ── 독립 실행 시에만 아래 코드 동작 ────────────────────────── // ── 독립 실행 시에만 아래 코드 동작 ──────────────────────────

View file

@ -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($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.'); 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'); $login = file_text($root . '/php/login.php');
check(str_contains($login, 'admin_totp_verify'), 'login.php should verify MFA codes.'); 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.'); check(str_contains($login, 'otp_code'), 'login.php should render an OTP input.');