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:
parent
90f121e14c
commit
749b284941
6 changed files with 105 additions and 24 deletions
18
CHANGELOG.md
18
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 온습도 전용 전환
|
||||
|
||||
### 시스템 범위
|
||||
|
|
|
|||
|
|
@ -85,6 +85,14 @@ function api_table_exists(PDO $db, string $table): bool {
|
|||
|
||||
// ── 요청 파싱 ─────────────────────────────────────────────
|
||||
$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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
|
|
|
|||
|
|
@ -80,23 +80,48 @@ function check_offline_sensors(): int {
|
|||
* sensor_data.php, dashboard.php 등에서 호출
|
||||
*/
|
||||
function check_offline_sensors_throttled(): void {
|
||||
try {
|
||||
$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);
|
||||
|
||||
if ($should_run) {
|
||||
try {
|
||||
// 스캔(DB 작업)은 파일 락을 푼 뒤 실행해 락 보유 시간을 최소화한다.
|
||||
check_offline_sensors();
|
||||
} catch (\Throwable $e) {
|
||||
// heartbeat 실패가 호출자(dashboard, sensor_data)를 죽이면 안 됨
|
||||
error_log("[Heartbeat] 체크 실패: " . $e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 독립 실행 시에만 아래 코드 동작 ──────────────────────────
|
||||
// require_once로 불러올 때는 함수 정의만 로드됨
|
||||
|
|
|
|||
|
|
@ -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.');
|
||||
|
|
|
|||
Loading…
Reference in a new issue