diff --git a/CHANGELOG.md b/CHANGELOG.md index 149828cf..c2653a57 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## [Unreleased] +### 보안 강화 +- control plane API 하드닝: (1) 요청 본문을 10MiB로 캡하고 음수 Content-Length를 거부합니다(유효 키 소지자의 OOM/EOF-hang 방지). (2) `limit`/`offset` 쿼리 파라미터를 클램프합니다 — sqlite에서 `LIMIT -1`은 무제한이므로 음수를 그대로 전달하면 페이지네이션 캡이 우회됐습니다(list 1..1000, trend 1..365, offset ≥0). + ### 추가 - `appguardrail fix` 명령 — 안전하고 결정적인 자동 수정을 적용합니다(기본 dry-run diff, `--apply`로 기록). 의미를 바꾸지 않는 순수 additive 변환만 수행하며, 첫 변환으로 외부 `target="_blank"` 링크에 `rel="noopener noreferrer"`를 추가합니다(reverse tabnabbing 방지). 동작을 바꾸는 수정(시크릿→env 등)은 위험하므로 자동 적용하지 않고 fix-pack 프롬프트로 남깁니다. scan→fix→verify 루프를 안전하게 닫습니다. - `appguardrail serve` — 멀티테넌트 **control-plane API**(스캔 인제스트 + 히스토리). 일회성 CLI를 넘어, CI가 매 스캔의 `appguardrail.findings.v1`을 org별 API 키로 영속 저장하고 시간에 따른 추이를 조회할 수 있는 지속형 백본입니다. stdlib(sqlite3 + http.server)만 사용하며 org별 테넌트 격리를 강제합니다. diff --git a/appguardrail_core/controlplane.py b/appguardrail_core/controlplane.py index b0fd6c5d..d7a12803 100644 --- a/appguardrail_core/controlplane.py +++ b/appguardrail_core/controlplane.py @@ -436,11 +436,14 @@ def do_GET(self): path = parsed.path qs = parse_qs(parsed.query) - def _qint(name, default): + def _qint(name, default, lo, hi): + # Clamp: sqlite treats LIMIT -1 as "no limit", so never pass + # negatives through; hi keeps a single request bounded. try: - return int(qs.get(name, [default])[0]) + value = int(qs.get(name, [default])[0]) except (ValueError, TypeError): return default + return max(lo, min(hi, value)) if path in ("/", "/console", "/index.html"): self.send_response(200) @@ -456,18 +459,26 @@ def _qint(name, default): return self._json(401, {"error": "invalid or missing API key"}) org, _role = auth if path == "/api/v1/scans": - return self._json(200, {"scans": list_scans(conn, org, _qint("limit", 100), _qint("offset", 0))}) + return self._json(200, {"scans": list_scans(conn, org, _qint("limit", 100, 1, 1000), _qint("offset", 0, 0, 10**9))}) if path == "/api/v1/scans/trend": - return self._json(200, {"trend": scan_trend(conn, org, _qint("limit", 30))}) + return self._json(200, {"trend": scan_trend(conn, org, _qint("limit", 30, 1, 365))}) m = re.match(r"^/api/v1/scans/(\d+)$", path) if m: scan = get_scan(conn, org, int(m.group(1))) return self._json(200, scan) if scan else self._json(404, {"error": "not found"}) self._json(404, {"error": "not found"}) + _MAX_BODY = 10 * 1024 * 1024 # 10 MiB — plenty for findings, blocks OOM posts + def _body(self): try: length = int(self.headers.get("Content-Length", 0)) + except (ValueError, TypeError): + return None + if length < 0 or length > self._MAX_BODY: + # Negative reads until EOF; oversized bodies exhaust memory. + return None + try: return json.loads(self.rfile.read(length) or b"{}") except (ValueError, TypeError): return None diff --git a/tests/test_controlplane.py b/tests/test_controlplane.py index 0e087ba3..f77b17a0 100644 --- a/tests/test_controlplane.py +++ b/tests/test_controlplane.py @@ -318,3 +318,49 @@ class _R: # minimal stand-in, urlopen result is ignored org_name="Acme", new_findings=findings) is True assert posted["body"] == generic assert "blocks" not in posted["body"] + + +# ---- API hardening: body cap + query clamps ---- + +def test_negative_and_huge_limit_clamped(server): + base, key = server + for _ in range(3): + _req("POST", f"{base}/api/v1/scans", key, {"repo": "r", "findings": []}) + # limit=-1 means UNBOUNDED in sqlite — must be clamped, not passed through + _, page = _req("GET", f"{base}/api/v1/scans?limit=-1", key) + assert 1 <= len(page["scans"]) <= 1000 + _, page2 = _req("GET", f"{base}/api/v1/scans?limit=999999&offset=-5", key) + assert len(page2["scans"]) <= 1000 # hi clamp; negative offset -> 0 + + +def test_oversized_body_rejected(server): + import http.client + from urllib.parse import urlparse as _u + base, key = server + u = _u(base) + conn = http.client.HTTPConnection(u.hostname, u.port, timeout=10) + # Claim an over-cap body; server must 400 without reading it all. + conn.putrequest("POST", "/api/v1/scans") + conn.putheader("Authorization", f"Bearer {key}") + conn.putheader("Content-Type", "application/json") + conn.putheader("Content-Length", str(50 * 1024 * 1024)) + conn.endheaders() + conn.send(b"{") # send a byte so the server can respond + resp = conn.getresponse() + assert resp.status == 400 + conn.close() + + +def test_negative_content_length_rejected(server): + import http.client + from urllib.parse import urlparse as _u + base, key = server + u = _u(base) + conn = http.client.HTTPConnection(u.hostname, u.port, timeout=10) + conn.putrequest("POST", "/api/v1/scans") + conn.putheader("Authorization", f"Bearer {key}") + conn.putheader("Content-Length", "-1") + conn.endheaders() + resp = conn.getresponse() + assert resp.status == 400 + conn.close()