From 325c5e0780f403bfab575cdac014b27312838741 Mon Sep 17 00:00:00 2001
From: APZN <129354802+jsk1004ha@users.noreply.github.com>
Date: Wed, 19 Aug 2026 13:01:48 +0900
Subject: [PATCH 1/4] ci: add temporary NAVER audit runner smoke test
---
.github/workflows/naver-readonly-audit.yml | 22 ++++++++++++++++++++++
1 file changed, 22 insertions(+)
create mode 100644 .github/workflows/naver-readonly-audit.yml
diff --git a/.github/workflows/naver-readonly-audit.yml b/.github/workflows/naver-readonly-audit.yml
new file mode 100644
index 0000000..27f0f3c
--- /dev/null
+++ b/.github/workflows/naver-readonly-audit.yml
@@ -0,0 +1,22 @@
+name: NAVER read-only audit runner
+
+on:
+ pull_request:
+ branches:
+ - main
+ types:
+ - opened
+ - synchronize
+ - reopened
+
+permissions:
+ contents: read
+
+jobs:
+ smoke:
+ if: ${{ github.event.pull_request.head.repo.full_name == github.repository && github.head_ref == 'security/naver-readonly-audit-20260819' }}
+ runs-on: ubuntu-latest
+ timeout-minutes: 2
+ steps:
+ - name: Confirm public runner
+ run: echo '{"runner_ready":true}'
From 5d01fce57b979f535c9211bd4df775e5129c9a36 Mon Sep 17 00:00:00 2001
From: APZN <129354802+jsk1004ha@users.noreply.github.com>
Date: Wed, 19 Aug 2026 13:05:19 +0900
Subject: [PATCH 2/4] security: run encrypted bounded NAVER CLOVA audit
---
.github/workflows/naver-readonly-audit.yml | 589 ++++++++++++++++++++-
1 file changed, 585 insertions(+), 4 deletions(-)
diff --git a/.github/workflows/naver-readonly-audit.yml b/.github/workflows/naver-readonly-audit.yml
index 27f0f3c..71c690d 100644
--- a/.github/workflows/naver-readonly-audit.yml
+++ b/.github/workflows/naver-readonly-audit.yml
@@ -13,10 +13,591 @@ permissions:
contents: read
jobs:
- smoke:
+ audit:
if: ${{ github.event.pull_request.head.repo.full_name == github.repository && github.head_ref == 'security/naver-readonly-audit-20260819' }}
runs-on: ubuntu-latest
- timeout-minutes: 2
+ timeout-minutes: 15
steps:
- - name: Confirm public runner
- run: echo '{"runner_ready":true}'
+ - uses: actions/checkout@v4
+
+ - name: Run bounded GET-only assessment
+ shell: bash
+ run: |
+ set -euo pipefail
+ AUDIT_ROOT="$RUNNER_TEMP/naver-audit"
+ OUT="$AUDIT_ROOT/out"
+ mkdir -p "$OUT"
+
+ cat > "$AUDIT_ROOT/audit.py" <<'PY'
+ from __future__ import annotations
+
+ import hashlib
+ import json
+ import os
+ import re
+ import shutil
+ import subprocess
+ import time
+ import urllib.error
+ import urllib.parse
+ import urllib.request
+ from dataclasses import dataclass
+ from html.parser import HTMLParser
+ from pathlib import Path
+ from typing import Any
+
+ ROOT = Path(os.environ["RUNNER_TEMP"]) / "naver-audit"
+ OUT = ROOT / "out"
+ OUT.mkdir(parents=True, exist_ok=True)
+ RAW = OUT / "raw"
+ RAW.mkdir(exist_ok=True)
+ CANDIDATES = OUT / "candidate-assets"
+ CANDIDATES.mkdir(exist_ok=True)
+
+ MARKER = "NBB_SAFE_20260819_5f6f2"
+ HTML_PROBE = MARKER + 'SAFE'
+ EXEC_PROBE = (
+ MARKER
+ + '
'
+ )
+
+ SEARCH_PLAIN = "https://clova.ai/search/" + urllib.parse.quote(MARKER, safe="")
+ SEARCH_HTML = "https://clova.ai/search/" + urllib.parse.quote(HTML_PROBE, safe="")
+ SEARCH_EXEC = "https://clova.ai/search/" + urllib.parse.quote(EXEC_PROBE, safe="")
+ HASH_HTML = "https://clova.ai/#" + urllib.parse.quote(HTML_PROBE, safe="")
+ HASH_EXEC = "https://clova.ai/#" + urllib.parse.quote(EXEC_PROBE, safe="")
+
+ HTTP_TARGETS = [
+ "https://clova.ai/",
+ "https://clova.ai/tech-blog",
+ SEARCH_PLAIN,
+ SEARCH_HTML,
+ ]
+ RENDER_VECTORS = [
+ {"name": "search-path", "html_url": SEARCH_HTML, "exec_url": SEARCH_EXEC},
+ {"name": "location-hash", "html_url": HASH_HTML, "exec_url": HASH_EXEC},
+ ]
+
+ USER_AGENT = (
+ "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
+ "(KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36"
+ )
+ MAX_HTML = 8 * 1024 * 1024
+ MAX_JS = 6 * 1024 * 1024
+ MAX_ASSETS = 20
+ DELAY = 0.9
+
+ SOURCES = {
+ "location.search": r"(?:window\.)?location\.search",
+ "location.hash": r"(?:window\.)?location\.hash",
+ "location.pathname": r"(?:window\.)?location\.pathname",
+ "document.URL": r"document\.(?:URL|documentURI)",
+ "document.referrer": r"document\.referrer",
+ "URLSearchParams": r"URLSearchParams\s*\(",
+ "message-event": r"(?:addEventListener\s*\(\s*['\"]message|onmessage\s*=)",
+ "window.name": r"window\.name",
+ }
+ SINKS = {
+ "innerHTML": r"\.innerHTML\s*=",
+ "outerHTML": r"\.outerHTML\s*=",
+ "insertAdjacentHTML": r"\.insertAdjacentHTML\s*\(",
+ "document.write": r"document\.write(?:ln)?\s*\(",
+ "dangerouslySetInnerHTML": r"dangerouslySetInnerHTML",
+ "eval": r"(? Response:
+ global request_count, downloaded_bytes
+ headers = {
+ "User-Agent": USER_AGENT,
+ "Accept-Encoding": "identity",
+ "Accept": "text/html,application/javascript,application/json;q=0.9,*/*;q=0.8",
+ }
+ if extra_headers:
+ headers.update(extra_headers)
+ request_count += 1
+ req = urllib.request.Request(url, headers=headers, method="GET")
+ try:
+ with urllib.request.urlopen(req, timeout=25) as resp:
+ body = resp.read(max_bytes + 1)
+ truncated = len(body) > max_bytes
+ body = body[:max_bytes]
+ downloaded_bytes += len(body)
+ result = Response(
+ requested_url=url,
+ final_url=resp.geturl(),
+ status=int(resp.status),
+ headers={k.lower(): v for k, v in resp.headers.items()},
+ body=body,
+ error=f"truncated at {max_bytes} bytes" if truncated else None,
+ )
+ except urllib.error.HTTPError as exc:
+ body = exc.read(max_bytes + 1)[:max_bytes]
+ downloaded_bytes += len(body)
+ result = Response(
+ requested_url=url,
+ final_url=exc.geturl(),
+ status=int(exc.code),
+ headers={k.lower(): v for k, v in exc.headers.items()},
+ body=body,
+ error=f"HTTP {exc.code}",
+ )
+ except Exception as exc: # noqa: BLE001
+ result = Response(url, url, 0, {}, b"", f"{type(exc).__name__}: {exc}")
+ time.sleep(DELAY)
+ return result
+
+
+ def decode(body: bytes, headers: dict[str, str]) -> str:
+ match = re.search(r"charset=([\w.-]+)", headers.get("content-type", ""), re.I)
+ encoding = match.group(1) if match else "utf-8"
+ try:
+ return body.decode(encoding, errors="replace")
+ except LookupError:
+ return body.decode("utf-8", errors="replace")
+
+
+ class DocumentParser(HTMLParser):
+ def __init__(self) -> None:
+ super().__init__(convert_charrefs=True)
+ self.scripts: list[str] = []
+ self.inline_scripts: list[str] = []
+ self.probe_elements: list[dict[str, Any]] = []
+ self.marker_attrs: list[dict[str, str]] = []
+ self.marker_text_count = 0
+ self.exec_seen = False
+ self._in_script = False
+ self._buf: list[str] = []
+
+ def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
+ values = dict(attrs)
+ if tag.lower() == "script":
+ if values.get("src"):
+ self.scripts.append(values["src"] or "")
+ else:
+ self._in_script = True
+ self._buf = []
+ if values.get("data-nbb-probe") == "5f6f2":
+ self.probe_elements.append({"tag": tag, "attrs": values})
+ if values.get("data-nbb-exec") == "5f6f2":
+ self.exec_seen = True
+ for key, value in attrs:
+ if value and MARKER in value:
+ self.marker_attrs.append({"tag": tag, "attribute": key, "value": value[:600]})
+
+ def handle_endtag(self, tag: str) -> None:
+ if tag.lower() == "script" and self._in_script:
+ self.inline_scripts.append("".join(self._buf))
+ self._in_script = False
+ self._buf = []
+
+ def handle_data(self, data: str) -> None:
+ if self._in_script:
+ self._buf.append(data)
+ self.marker_text_count += data.count(MARKER)
+
+
+ def parse_document(text: str) -> tuple[DocumentParser, str | None]:
+ parser = DocumentParser()
+ try:
+ parser.feed(text)
+ return parser, None
+ except Exception as exc: # noqa: BLE001
+ return parser, f"{type(exc).__name__}: {exc}"
+
+
+ def marker_contexts(text: str, limit: int = 8, radius: int = 220) -> list[str]:
+ contexts: list[str] = []
+ cursor = 0
+ while len(contexts) < limit:
+ index = text.find(MARKER, cursor)
+ if index < 0:
+ break
+ contexts.append(
+ text[max(0, index - radius): min(len(text), index + len(MARKER) + radius)]
+ .replace("\n", " ")[:1000]
+ )
+ cursor = index + len(MARKER)
+ return contexts
+
+
+ def inspect_document(text: str) -> dict[str, Any]:
+ parser, error = parse_document(text)
+ return {
+ "length": len(text),
+ "sha256": hashlib.sha256(text.encode("utf-8", errors="replace")).hexdigest(),
+ "literal_marker_count": text.count(MARKER),
+ "actual_probe_elements": parser.probe_elements,
+ "marker_attributes": parser.marker_attrs[:30],
+ "marker_text_count": parser.marker_text_count,
+ "exec_seen": parser.exec_seen or 'data-nbb-exec="5f6f2"' in text,
+ "marker_contexts": marker_contexts(text),
+ "parse_error": error,
+ }
+
+
+ def find_chrome() -> str | None:
+ for name in ("google-chrome", "google-chrome-stable", "chromium", "chromium-browser"):
+ if path := shutil.which(name):
+ return path
+ return None
+
+
+ def render(url: str, label: str) -> tuple[dict[str, Any], str]:
+ chrome = find_chrome()
+ if not chrome:
+ return {"available": False, "error": "Chrome not found"}, ""
+ profile = ROOT / f"chrome-{hashlib.sha256(label.encode()).hexdigest()[:8]}"
+ cmd = [
+ chrome,
+ "--headless=new",
+ "--no-sandbox",
+ "--disable-gpu",
+ "--disable-dev-shm-usage",
+ "--disable-background-networking",
+ "--disable-component-update",
+ "--disable-sync",
+ "--metrics-recording-only",
+ "--no-first-run",
+ f"--user-data-dir={profile}",
+ "--virtual-time-budget=7000",
+ "--dump-dom",
+ url,
+ ]
+ try:
+ proc = subprocess.run(cmd, capture_output=True, text=True, timeout=50, check=False)
+ dom = proc.stdout
+ return {
+ "available": True,
+ "returncode": proc.returncode,
+ "inspection": inspect_document(dom),
+ "stderr_tail": proc.stderr[-1200:] if proc.returncode else None,
+ }, dom
+ except Exception as exc: # noqa: BLE001
+ return {"available": True, "error": f"{type(exc).__name__}: {exc}"}, ""
+
+
+ def allowed_asset(url: str) -> bool:
+ parsed = urllib.parse.urlsplit(url)
+ if parsed.scheme != "https" or not parsed.hostname:
+ return False
+ host = parsed.hostname.lower()
+ return host == "clova.ai" or host.endswith(".clova.ai") or host.endswith(".pstatic.net")
+
+
+ def hits(text: str, patterns: dict[str, str], limit: int = 8) -> dict[str, list[dict[str, Any]]]:
+ output: dict[str, list[dict[str, Any]]] = {}
+ for name, pattern in patterns.items():
+ items: list[dict[str, Any]] = []
+ for match in re.finditer(pattern, text, re.I):
+ items.append(
+ {
+ "offset": match.start(),
+ "snippet": text[max(0, match.start() - 220): min(len(text), match.end() + 320)]
+ .replace("\n", " ")[:1100],
+ }
+ )
+ if len(items) >= limit:
+ break
+ if items:
+ output[name] = items
+ return output
+
+
+ def offsets(found: dict[str, list[dict[str, Any]]]) -> list[int]:
+ return [item["offset"] for values in found.values() for item in values]
+
+
+ def min_distance(source: dict[str, list[dict[str, Any]]], sink: dict[str, list[dict[str, Any]]]) -> int | None:
+ left = offsets(source)
+ right = offsets(sink)
+ if not left or not right:
+ return None
+ return min(abs(a - b) for a in left for b in right)
+
+
+ pages: list[dict[str, Any]] = []
+ asset_urls: list[str] = []
+ inline_candidates: list[dict[str, Any]] = []
+
+ for page_index, target in enumerate(HTTP_TARGETS, start=1):
+ response = fetch(target, MAX_HTML)
+ text = decode(response.body, response.headers)
+ (RAW / f"page-{page_index:02d}.html").write_text(text, encoding="utf-8")
+ parser, parse_error = parse_document(text)
+ for src in parser.scripts:
+ resolved = urllib.parse.urljoin(response.final_url, src)
+ if allowed_asset(resolved) and resolved not in asset_urls:
+ asset_urls.append(resolved)
+ for inline_index, block in enumerate(parser.inline_scripts):
+ source = hits(block, SOURCES)
+ sink = hits(block, SINKS)
+ if source or sink:
+ inline_candidates.append(
+ {
+ "page": response.final_url,
+ "index": inline_index,
+ "length": len(block),
+ "sha256": hashlib.sha256(block.encode()).hexdigest(),
+ "sources": source,
+ "sinks": sink,
+ "sanitizers": hits(block, SANITIZERS),
+ "minimum_distance": min_distance(source, sink),
+ }
+ )
+ pages.append(
+ {
+ "requested_url": target,
+ "final_url": response.final_url,
+ "status": response.status,
+ "error": response.error,
+ "content_type": response.headers.get("content-type"),
+ "body_length": len(response.body),
+ "body_sha256": hashlib.sha256(response.body).hexdigest(),
+ "security_headers": {
+ key: response.headers.get(key)
+ for key in (
+ "content-security-policy",
+ "strict-transport-security",
+ "x-content-type-options",
+ "referrer-policy",
+ "permissions-policy",
+ "cross-origin-opener-policy",
+ "cross-origin-resource-policy",
+ )
+ },
+ "inspection": inspect_document(text),
+ "parse_error": parse_error,
+ "script_count": len(parser.scripts),
+ }
+ )
+
+ rendered_vectors: list[dict[str, Any]] = []
+ for vector in RENDER_VECTORS:
+ html_result, html_dom = render(vector["html_url"], vector["name"] + "-html")
+ if html_dom:
+ (RAW / f"rendered-{vector['name']}-html.html").write_text(html_dom, encoding="utf-8")
+ parsed, _ = parse_document(html_dom)
+ for src in parsed.scripts:
+ resolved = urllib.parse.urljoin(vector["html_url"], src)
+ if allowed_asset(resolved) and resolved not in asset_urls:
+ asset_urls.append(resolved)
+ actual_html_element = bool(
+ ((html_result.get("inspection") or {}).get("actual_probe_elements") or [])
+ )
+ exec_result: dict[str, Any] | None = None
+ if actual_html_element:
+ exec_result, exec_dom = render(vector["exec_url"], vector["name"] + "-exec")
+ if exec_dom:
+ (RAW / f"rendered-{vector['name']}-exec.html").write_text(exec_dom, encoding="utf-8")
+ rendered_vectors.append(
+ {
+ "name": vector["name"],
+ "html_url": vector["html_url"],
+ "html_result": html_result,
+ "conditional_exec_test_performed": actual_html_element,
+ "exec_url": vector["exec_url"] if actual_html_element else None,
+ "exec_result": exec_result,
+ }
+ )
+
+ assets: list[dict[str, Any]] = []
+ for index, url in enumerate(asset_urls[:MAX_ASSETS], start=1):
+ response = fetch(url, MAX_JS)
+ text = decode(response.body, response.headers)
+ source = hits(text, SOURCES)
+ sink = hits(text, SINKS)
+ sanitizer = hits(text, SANITIZERS)
+ distance = min_distance(source, sink)
+ endpoints = sorted(
+ set(
+ re.findall(r"https://[A-Za-z0-9._~:/?#\[\]@!$&'()*+,;=%-]+", text)
+ + re.findall(r"['\"](/(?:api|graphql|search|v\d)/[^'\"\s]{0,260})['\"]", text)
+ )
+ )[:160]
+ maps = re.findall(r"sourceMappingURL=([^\s*]+)", text)[:15]
+ result = {
+ "url": url,
+ "status": response.status,
+ "error": response.error,
+ "content_type": response.headers.get("content-type"),
+ "length": len(response.body),
+ "sha256": hashlib.sha256(response.body).hexdigest(),
+ "sources": source,
+ "sinks": sink,
+ "sanitizers": sanitizer,
+ "minimum_distance": distance,
+ "endpoint_candidates": endpoints,
+ "source_maps": maps,
+ }
+ assets.append(result)
+ if source and sink:
+ filename = f"candidate-{index:02d}-{hashlib.sha256(url.encode()).hexdigest()[:10]}.js"
+ (CANDIDATES / filename).write_bytes(response.body)
+
+ cors: list[dict[str, Any]] = []
+ test_origin = "https://example.invalid"
+ for target in HTTP_TARGETS:
+ response = fetch(target, 512 * 1024, {"Origin": test_origin})
+ cors.append(
+ {
+ "url": target,
+ "status": response.status,
+ "acao": response.headers.get("access-control-allow-origin"),
+ "acac": response.headers.get("access-control-allow-credentials"),
+ "vary": response.headers.get("vary"),
+ }
+ )
+
+ source_sink_assets = [item for item in assets if item["sources"] and item["sinks"]]
+ close_assets = [
+ item
+ for item in source_sink_assets
+ if item["minimum_distance"] is not None and item["minimum_distance"] <= 5000
+ ]
+ html_injection_vectors = [
+ item
+ for item in rendered_vectors
+ if ((item.get("html_result") or {}).get("inspection") or {}).get("actual_probe_elements")
+ ]
+ exec_vectors = [
+ item
+ for item in rendered_vectors
+ if (((item.get("exec_result") or {}).get("inspection") or {}).get("exec_seen"))
+ ]
+ reflected_cors = [item for item in cors if item.get("acao") == test_origin]
+
+ report = {
+ "generated_at_utc": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
+ "audit_type": "bounded unauthenticated GET-only assessment",
+ "safety": {
+ "authentication_or_cookies": False,
+ "state_changing_methods": False,
+ "personal_data_requested": False,
+ "methods": ["GET"],
+ "conditional_script_probe": "benign DOM attribute mutation only; executed solely after confirmed harmless HTML element injection",
+ "marker": MARKER,
+ },
+ "request_count_explicit": request_count,
+ "downloaded_bytes_explicit": downloaded_bytes,
+ "http_targets": HTTP_TARGETS,
+ "pages": pages,
+ "rendered_vectors": rendered_vectors,
+ "cors": cors,
+ "assets_discovered": len(asset_urls),
+ "assets_downloaded": len(assets),
+ "assets": assets,
+ "inline_candidates": inline_candidates,
+ "triage": {
+ "source_sink_assets": len(source_sink_assets),
+ "source_sink_assets_within_5000_bytes": len(close_assets),
+ "actual_html_injection_vectors": len(html_injection_vectors),
+ "confirmed_benign_script_execution_vectors": len(exec_vectors),
+ "arbitrary_origin_cors_reflections": len(reflected_cors),
+ },
+ }
+ (OUT / "summary.json").write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
+
+ evidence = [
+ "# NAVER CLOVA bounded audit evidence",
+ "",
+ f"Generated: {report['generated_at_utc']}",
+ f"Explicit HTTP requests: {request_count}",
+ f"Explicit bytes downloaded: {downloaded_bytes}",
+ "Authentication/cookies: none",
+ "Methods: GET only",
+ "State changes/personal-data access: none",
+ "",
+ "## Automated triage",
+ f"- Source+sink assets: {len(source_sink_assets)}",
+ f"- Source+sink proximity <= 5000 bytes: {len(close_assets)}",
+ f"- Harmless HTML injection vectors: {len(html_injection_vectors)}",
+ f"- Confirmed benign script-execution vectors: {len(exec_vectors)}",
+ f"- Reflected arbitrary Origin responses: {len(reflected_cors)}",
+ "",
+ "This is candidate generation. A reportable vulnerability requires manual data-flow and impact validation.",
+ ]
+ (OUT / "evidence.md").write_text("\n".join(evidence) + "\n", encoding="utf-8")
+
+ public_summary = {
+ "audit_complete": True,
+ "explicit_requests": request_count,
+ "pages": len(pages),
+ "assets_downloaded": len(assets),
+ "source_sink_candidates": len(source_sink_assets),
+ "close_source_sink_candidates": len(close_assets),
+ "html_injection_vectors": len(html_injection_vectors),
+ "confirmed_exec_vectors": len(exec_vectors),
+ "reflected_cors": len(reflected_cors),
+ }
+ (ROOT / "public-summary.json").write_text(json.dumps(public_summary, indent=2), encoding="utf-8")
+ print(json.dumps(public_summary))
+ PY
+
+ python3 "$AUDIT_ROOT/audit.py"
+
+ tar -C "$OUT" -czf "$AUDIT_ROOT/evidence.tar.gz" .
+ openssl rand 32 > "$AUDIT_ROOT/key.bin"
+ openssl enc -aes-256-cbc -pbkdf2 -iter 200000 -salt \
+ -in "$AUDIT_ROOT/evidence.tar.gz" \
+ -out "$AUDIT_ROOT/evidence.tar.gz.enc" \
+ -pass file:"$AUDIT_ROOT/key.bin"
+
+ cat > "$AUDIT_ROOT/public.pem" <<'PEM'
+ -----BEGIN PUBLIC KEY-----
+ MIIBojANBgkqhkiG9w0BAQEFAAOCAY8AMIIBigKCAYEAuYxNFglspVUuZ+dkHr6N
+ cWTGyrV9SB3oqa7TkEPKwDEz4zy37eiu2iXcMtHw9LSeiur8uyTwaRGc9io76+WB
+ DYvAyXUzVgGpsK5Q93dJPCh0f6I9ZQEK3j8sw79+go1qtKco9xcx08swZgZVhB7c
+ MOUy2M9TpbE3/oZUlW6sUQUJC2Bk87mrJk6ebZAviKIfeKcTsLq4ck9+1MFJ/xIS
+ yOdyu7Os616z/TKKy7Qf9d0DyZ1fyCBccIlwZpw6buGv2tk8FPceCq9Oudod3DnS
+ nHjShVfVeetF96eILhLaGNVVNgwHzIIKXqT4Fw7e0ujRQPktDR1XGdfhLzkPzcvh
+ fPqGUbq64sx26MmkeNYP/OJYV5NyzcC5BX8/uWNJDH5cds6emLeNkkgEABrGrBo/
+ r9NyEcZQwlva6ct4h1l5zN0OmcZK549O0u4oYHNE8kk5g3T7rT2gn01mrMtif8D1
+ 4hzvF+NaC1jVUnxIE/CG41F9qaBrEyaEnFG5WYVplWxZAgMBAAE=
+ -----END PUBLIC KEY-----
+ PEM
+
+ openssl pkeyutl -encrypt -pubin \
+ -inkey "$AUDIT_ROOT/public.pem" \
+ -in "$AUDIT_ROOT/key.bin" \
+ -out "$AUDIT_ROOT/key.enc" \
+ -pkeyopt rsa_padding_mode:oaep \
+ -pkeyopt rsa_oaep_md:sha256
+
+ sha256sum "$AUDIT_ROOT/evidence.tar.gz.enc" "$AUDIT_ROOT/key.enc" > "$AUDIT_ROOT/checksums.txt"
+ rm -rf "$OUT" "$AUDIT_ROOT/evidence.tar.gz" "$AUDIT_ROOT/key.bin" "$AUDIT_ROOT/public.pem" "$AUDIT_ROOT/audit.py"
+
+ - name: Upload encrypted evidence only
+ uses: actions/upload-artifact@v4
+ with:
+ name: naver-readonly-audit-encrypted
+ path: |
+ ${{ runner.temp }}/naver-audit/evidence.tar.gz.enc
+ ${{ runner.temp }}/naver-audit/key.enc
+ ${{ runner.temp }}/naver-audit/checksums.txt
+ ${{ runner.temp }}/naver-audit/public-summary.json
+ if-no-files-found: error
+ retention-days: 1
From 97380fb50b47dea35055926b1868cfc1a213150d Mon Sep 17 00:00:00 2001
From: APZN <129354802+jsk1004ha@users.noreply.github.com>
Date: Wed, 19 Aug 2026 13:12:59 +0900
Subject: [PATCH 3/4] security: inspect CLOVA application bundle data flows
---
.github/workflows/naver-readonly-audit.yml | 667 ++++-----------------
1 file changed, 118 insertions(+), 549 deletions(-)
diff --git a/.github/workflows/naver-readonly-audit.yml b/.github/workflows/naver-readonly-audit.yml
index 71c690d..93542f8 100644
--- a/.github/workflows/naver-readonly-audit.yml
+++ b/.github/workflows/naver-readonly-audit.yml
@@ -4,10 +4,7 @@ on:
pull_request:
branches:
- main
- types:
- - opened
- - synchronize
- - reopened
+ types: [opened, synchronize, reopened]
permissions:
contents: read
@@ -16,588 +13,160 @@ jobs:
audit:
if: ${{ github.event.pull_request.head.repo.full_name == github.repository && github.head_ref == 'security/naver-readonly-audit-20260819' }}
runs-on: ubuntu-latest
- timeout-minutes: 15
+ timeout-minutes: 10
steps:
- - uses: actions/checkout@v4
-
- - name: Run bounded GET-only assessment
+ - name: Fetch and inspect public CLOVA application bundles
shell: bash
run: |
set -euo pipefail
- AUDIT_ROOT="$RUNNER_TEMP/naver-audit"
- OUT="$AUDIT_ROOT/out"
- mkdir -p "$OUT"
+ ROOT="$RUNNER_TEMP/naver-clova-bundles"
+ OUT="$ROOT/out"
+ mkdir -p "$OUT/chunks"
- cat > "$AUDIT_ROOT/audit.py" <<'PY'
+ cat > "$ROOT/audit.py" <<'PY'
from __future__ import annotations
import hashlib
import json
import os
import re
- import shutil
- import subprocess
import time
import urllib.error
- import urllib.parse
import urllib.request
- from dataclasses import dataclass
- from html.parser import HTMLParser
from pathlib import Path
- from typing import Any
-
- ROOT = Path(os.environ["RUNNER_TEMP"]) / "naver-audit"
- OUT = ROOT / "out"
- OUT.mkdir(parents=True, exist_ok=True)
- RAW = OUT / "raw"
- RAW.mkdir(exist_ok=True)
- CANDIDATES = OUT / "candidate-assets"
- CANDIDATES.mkdir(exist_ok=True)
-
- MARKER = "NBB_SAFE_20260819_5f6f2"
- HTML_PROBE = MARKER + 'SAFE'
- EXEC_PROBE = (
- MARKER
- + '
'
- )
-
- SEARCH_PLAIN = "https://clova.ai/search/" + urllib.parse.quote(MARKER, safe="")
- SEARCH_HTML = "https://clova.ai/search/" + urllib.parse.quote(HTML_PROBE, safe="")
- SEARCH_EXEC = "https://clova.ai/search/" + urllib.parse.quote(EXEC_PROBE, safe="")
- HASH_HTML = "https://clova.ai/#" + urllib.parse.quote(HTML_PROBE, safe="")
- HASH_EXEC = "https://clova.ai/#" + urllib.parse.quote(EXEC_PROBE, safe="")
- HTTP_TARGETS = [
- "https://clova.ai/",
- "https://clova.ai/tech-blog",
- SEARCH_PLAIN,
- SEARCH_HTML,
+ ROOT = Path(os.environ['RUNNER_TEMP']) / 'naver-clova-bundles'
+ OUT = ROOT / 'out'
+ CHUNKS = OUT / 'chunks'
+ CHUNKS.mkdir(parents=True, exist_ok=True)
+
+ URLS = [
+ 'https://clova.ai/_next/static/chunks/716-4bc8895918653ea2.js',
+ 'https://clova.ai/_next/static/chunks/356-3dd847132a3b795f.js',
+ 'https://clova.ai/_next/static/chunks/pages/index-69eb870b26bec00f.js',
+ 'https://clova.ai/_next/static/chunks/891-974e99e6ac994be0.js',
+ 'https://clova.ai/_next/static/chunks/205-6cdb0f0222e1fa21.js',
+ 'https://clova.ai/_next/static/chunks/pages/tech-blog-731cde12a8ad2854.js',
+ 'https://clova.ai/_next/static/chunks/pages/search/%5Bterm%5D-0dbd3fc5b25289ed.js',
+ 'https://clova.ai/_next/static/chunks/pages/hyperclova-e015d3d516dd33fa.js',
]
- RENDER_VECTORS = [
- {"name": "search-path", "html_url": SEARCH_HTML, "exec_url": SEARCH_EXEC},
- {"name": "location-hash", "html_url": HASH_HTML, "exec_url": HASH_EXEC},
- ]
-
- USER_AGENT = (
- "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
- "(KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36"
- )
- MAX_HTML = 8 * 1024 * 1024
- MAX_JS = 6 * 1024 * 1024
- MAX_ASSETS = 20
- DELAY = 0.9
-
- SOURCES = {
- "location.search": r"(?:window\.)?location\.search",
- "location.hash": r"(?:window\.)?location\.hash",
- "location.pathname": r"(?:window\.)?location\.pathname",
- "document.URL": r"document\.(?:URL|documentURI)",
- "document.referrer": r"document\.referrer",
- "URLSearchParams": r"URLSearchParams\s*\(",
- "message-event": r"(?:addEventListener\s*\(\s*['\"]message|onmessage\s*=)",
- "window.name": r"window\.name",
- }
- SINKS = {
- "innerHTML": r"\.innerHTML\s*=",
- "outerHTML": r"\.outerHTML\s*=",
- "insertAdjacentHTML": r"\.insertAdjacentHTML\s*\(",
- "document.write": r"document\.write(?:ln)?\s*\(",
- "dangerouslySetInnerHTML": r"dangerouslySetInnerHTML",
- "eval": r"(? Response:
- global request_count, downloaded_bytes
- headers = {
- "User-Agent": USER_AGENT,
- "Accept-Encoding": "identity",
- "Accept": "text/html,application/javascript,application/json;q=0.9,*/*;q=0.8",
- }
- if extra_headers:
- headers.update(extra_headers)
- request_count += 1
- req = urllib.request.Request(url, headers=headers, method="GET")
+ def fetch(url: str) -> tuple[int, dict[str, str], bytes, str | None]:
+ req = urllib.request.Request(url, headers={'User-Agent': UA, 'Accept-Encoding': 'identity'}, method='GET')
try:
with urllib.request.urlopen(req, timeout=25) as resp:
- body = resp.read(max_bytes + 1)
- truncated = len(body) > max_bytes
- body = body[:max_bytes]
- downloaded_bytes += len(body)
- result = Response(
- requested_url=url,
- final_url=resp.geturl(),
- status=int(resp.status),
- headers={k.lower(): v for k, v in resp.headers.items()},
- body=body,
- error=f"truncated at {max_bytes} bytes" if truncated else None,
- )
+ body = resp.read(MAX_BYTES + 1)
+ error = None
+ if len(body) > MAX_BYTES:
+ body = body[:MAX_BYTES]
+ error = 'truncated'
+ result = int(resp.status), {k.lower(): v for k, v in resp.headers.items()}, body, error
except urllib.error.HTTPError as exc:
- body = exc.read(max_bytes + 1)[:max_bytes]
- downloaded_bytes += len(body)
- result = Response(
- requested_url=url,
- final_url=exc.geturl(),
- status=int(exc.code),
- headers={k.lower(): v for k, v in exc.headers.items()},
- body=body,
- error=f"HTTP {exc.code}",
- )
+ body = exc.read(MAX_BYTES + 1)[:MAX_BYTES]
+ result = int(exc.code), {k.lower(): v for k, v in exc.headers.items()}, body, f'HTTP {exc.code}'
except Exception as exc: # noqa: BLE001
- result = Response(url, url, 0, {}, b"", f"{type(exc).__name__}: {exc}")
- time.sleep(DELAY)
+ result = 0, {}, b'', f'{type(exc).__name__}: {exc}'
+ time.sleep(0.9)
return result
-
- def decode(body: bytes, headers: dict[str, str]) -> str:
- match = re.search(r"charset=([\w.-]+)", headers.get("content-type", ""), re.I)
- encoding = match.group(1) if match else "utf-8"
- try:
- return body.decode(encoding, errors="replace")
- except LookupError:
- return body.decode("utf-8", errors="replace")
-
-
- class DocumentParser(HTMLParser):
- def __init__(self) -> None:
- super().__init__(convert_charrefs=True)
- self.scripts: list[str] = []
- self.inline_scripts: list[str] = []
- self.probe_elements: list[dict[str, Any]] = []
- self.marker_attrs: list[dict[str, str]] = []
- self.marker_text_count = 0
- self.exec_seen = False
- self._in_script = False
- self._buf: list[str] = []
-
- def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
- values = dict(attrs)
- if tag.lower() == "script":
- if values.get("src"):
- self.scripts.append(values["src"] or "")
- else:
- self._in_script = True
- self._buf = []
- if values.get("data-nbb-probe") == "5f6f2":
- self.probe_elements.append({"tag": tag, "attrs": values})
- if values.get("data-nbb-exec") == "5f6f2":
- self.exec_seen = True
- for key, value in attrs:
- if value and MARKER in value:
- self.marker_attrs.append({"tag": tag, "attribute": key, "value": value[:600]})
-
- def handle_endtag(self, tag: str) -> None:
- if tag.lower() == "script" and self._in_script:
- self.inline_scripts.append("".join(self._buf))
- self._in_script = False
- self._buf = []
-
- def handle_data(self, data: str) -> None:
- if self._in_script:
- self._buf.append(data)
- self.marker_text_count += data.count(MARKER)
-
-
- def parse_document(text: str) -> tuple[DocumentParser, str | None]:
- parser = DocumentParser()
- try:
- parser.feed(text)
- return parser, None
- except Exception as exc: # noqa: BLE001
- return parser, f"{type(exc).__name__}: {exc}"
-
-
- def marker_contexts(text: str, limit: int = 8, radius: int = 220) -> list[str]:
- contexts: list[str] = []
- cursor = 0
- while len(contexts) < limit:
- index = text.find(MARKER, cursor)
- if index < 0:
+ def contexts(text: str, pattern: re.Pattern[str], radius: int = 700, limit: int = 30) -> list[dict[str, object]]:
+ out: list[dict[str, object]] = []
+ for match in pattern.finditer(text):
+ out.append({
+ 'offset': match.start(),
+ 'snippet': text[max(0, match.start()-radius):min(len(text), match.end()+radius)],
+ })
+ if len(out) >= limit:
break
- contexts.append(
- text[max(0, index - radius): min(len(text), index + len(MARKER) + radius)]
- .replace("\n", " ")[:1000]
- )
- cursor = index + len(MARKER)
- return contexts
-
-
- def inspect_document(text: str) -> dict[str, Any]:
- parser, error = parse_document(text)
- return {
- "length": len(text),
- "sha256": hashlib.sha256(text.encode("utf-8", errors="replace")).hexdigest(),
- "literal_marker_count": text.count(MARKER),
- "actual_probe_elements": parser.probe_elements,
- "marker_attributes": parser.marker_attrs[:30],
- "marker_text_count": parser.marker_text_count,
- "exec_seen": parser.exec_seen or 'data-nbb-exec="5f6f2"' in text,
- "marker_contexts": marker_contexts(text),
- "parse_error": error,
- }
-
-
- def find_chrome() -> str | None:
- for name in ("google-chrome", "google-chrome-stable", "chromium", "chromium-browser"):
- if path := shutil.which(name):
- return path
- return None
-
-
- def render(url: str, label: str) -> tuple[dict[str, Any], str]:
- chrome = find_chrome()
- if not chrome:
- return {"available": False, "error": "Chrome not found"}, ""
- profile = ROOT / f"chrome-{hashlib.sha256(label.encode()).hexdigest()[:8]}"
- cmd = [
- chrome,
- "--headless=new",
- "--no-sandbox",
- "--disable-gpu",
- "--disable-dev-shm-usage",
- "--disable-background-networking",
- "--disable-component-update",
- "--disable-sync",
- "--metrics-recording-only",
- "--no-first-run",
- f"--user-data-dir={profile}",
- "--virtual-time-budget=7000",
- "--dump-dom",
- url,
- ]
- try:
- proc = subprocess.run(cmd, capture_output=True, text=True, timeout=50, check=False)
- dom = proc.stdout
- return {
- "available": True,
- "returncode": proc.returncode,
- "inspection": inspect_document(dom),
- "stderr_tail": proc.stderr[-1200:] if proc.returncode else None,
- }, dom
- except Exception as exc: # noqa: BLE001
- return {"available": True, "error": f"{type(exc).__name__}: {exc}"}, ""
-
-
- def allowed_asset(url: str) -> bool:
- parsed = urllib.parse.urlsplit(url)
- if parsed.scheme != "https" or not parsed.hostname:
- return False
- host = parsed.hostname.lower()
- return host == "clova.ai" or host.endswith(".clova.ai") or host.endswith(".pstatic.net")
-
-
- def hits(text: str, patterns: dict[str, str], limit: int = 8) -> dict[str, list[dict[str, Any]]]:
- output: dict[str, list[dict[str, Any]]] = {}
- for name, pattern in patterns.items():
- items: list[dict[str, Any]] = []
- for match in re.finditer(pattern, text, re.I):
- items.append(
- {
- "offset": match.start(),
- "snippet": text[max(0, match.start() - 220): min(len(text), match.end() + 320)]
- .replace("\n", " ")[:1100],
- }
- )
- if len(items) >= limit:
- break
- if items:
- output[name] = items
- return output
-
-
- def offsets(found: dict[str, list[dict[str, Any]]]) -> list[int]:
- return [item["offset"] for values in found.values() for item in values]
-
-
- def min_distance(source: dict[str, list[dict[str, Any]]], sink: dict[str, list[dict[str, Any]]]) -> int | None:
- left = offsets(source)
- right = offsets(sink)
- if not left or not right:
- return None
- return min(abs(a - b) for a in left for b in right)
-
-
- pages: list[dict[str, Any]] = []
- asset_urls: list[str] = []
- inline_candidates: list[dict[str, Any]] = []
-
- for page_index, target in enumerate(HTTP_TARGETS, start=1):
- response = fetch(target, MAX_HTML)
- text = decode(response.body, response.headers)
- (RAW / f"page-{page_index:02d}.html").write_text(text, encoding="utf-8")
- parser, parse_error = parse_document(text)
- for src in parser.scripts:
- resolved = urllib.parse.urljoin(response.final_url, src)
- if allowed_asset(resolved) and resolved not in asset_urls:
- asset_urls.append(resolved)
- for inline_index, block in enumerate(parser.inline_scripts):
- source = hits(block, SOURCES)
- sink = hits(block, SINKS)
- if source or sink:
- inline_candidates.append(
- {
- "page": response.final_url,
- "index": inline_index,
- "length": len(block),
- "sha256": hashlib.sha256(block.encode()).hexdigest(),
- "sources": source,
- "sinks": sink,
- "sanitizers": hits(block, SANITIZERS),
- "minimum_distance": min_distance(source, sink),
- }
- )
- pages.append(
- {
- "requested_url": target,
- "final_url": response.final_url,
- "status": response.status,
- "error": response.error,
- "content_type": response.headers.get("content-type"),
- "body_length": len(response.body),
- "body_sha256": hashlib.sha256(response.body).hexdigest(),
- "security_headers": {
- key: response.headers.get(key)
- for key in (
- "content-security-policy",
- "strict-transport-security",
- "x-content-type-options",
- "referrer-policy",
- "permissions-policy",
- "cross-origin-opener-policy",
- "cross-origin-resource-policy",
- )
- },
- "inspection": inspect_document(text),
- "parse_error": parse_error,
- "script_count": len(parser.scripts),
- }
- )
-
- rendered_vectors: list[dict[str, Any]] = []
- for vector in RENDER_VECTORS:
- html_result, html_dom = render(vector["html_url"], vector["name"] + "-html")
- if html_dom:
- (RAW / f"rendered-{vector['name']}-html.html").write_text(html_dom, encoding="utf-8")
- parsed, _ = parse_document(html_dom)
- for src in parsed.scripts:
- resolved = urllib.parse.urljoin(vector["html_url"], src)
- if allowed_asset(resolved) and resolved not in asset_urls:
- asset_urls.append(resolved)
- actual_html_element = bool(
- ((html_result.get("inspection") or {}).get("actual_probe_elements") or [])
- )
- exec_result: dict[str, Any] | None = None
- if actual_html_element:
- exec_result, exec_dom = render(vector["exec_url"], vector["name"] + "-exec")
- if exec_dom:
- (RAW / f"rendered-{vector['name']}-exec.html").write_text(exec_dom, encoding="utf-8")
- rendered_vectors.append(
- {
- "name": vector["name"],
- "html_url": vector["html_url"],
- "html_result": html_result,
- "conditional_exec_test_performed": actual_html_element,
- "exec_url": vector["exec_url"] if actual_html_element else None,
- "exec_result": exec_result,
- }
- )
-
- assets: list[dict[str, Any]] = []
- for index, url in enumerate(asset_urls[:MAX_ASSETS], start=1):
- response = fetch(url, MAX_JS)
- text = decode(response.body, response.headers)
- source = hits(text, SOURCES)
- sink = hits(text, SINKS)
- sanitizer = hits(text, SANITIZERS)
- distance = min_distance(source, sink)
- endpoints = sorted(
- set(
- re.findall(r"https://[A-Za-z0-9._~:/?#\[\]@!$&'()*+,;=%-]+", text)
- + re.findall(r"['\"](/(?:api|graphql|search|v\d)/[^'\"\s]{0,260})['\"]", text)
- )
- )[:160]
- maps = re.findall(r"sourceMappingURL=([^\s*]+)", text)[:15]
- result = {
- "url": url,
- "status": response.status,
- "error": response.error,
- "content_type": response.headers.get("content-type"),
- "length": len(response.body),
- "sha256": hashlib.sha256(response.body).hexdigest(),
- "sources": source,
- "sinks": sink,
- "sanitizers": sanitizer,
- "minimum_distance": distance,
- "endpoint_candidates": endpoints,
- "source_maps": maps,
- }
- assets.append(result)
- if source and sink:
- filename = f"candidate-{index:02d}-{hashlib.sha256(url.encode()).hexdigest()[:10]}.js"
- (CANDIDATES / filename).write_bytes(response.body)
-
- cors: list[dict[str, Any]] = []
- test_origin = "https://example.invalid"
- for target in HTTP_TARGETS:
- response = fetch(target, 512 * 1024, {"Origin": test_origin})
- cors.append(
- {
- "url": target,
- "status": response.status,
- "acao": response.headers.get("access-control-allow-origin"),
- "acac": response.headers.get("access-control-allow-credentials"),
- "vary": response.headers.get("vary"),
- }
- )
-
- source_sink_assets = [item for item in assets if item["sources"] and item["sinks"]]
- close_assets = [
- item
- for item in source_sink_assets
- if item["minimum_distance"] is not None and item["minimum_distance"] <= 5000
- ]
- html_injection_vectors = [
- item
- for item in rendered_vectors
- if ((item.get("html_result") or {}).get("inspection") or {}).get("actual_probe_elements")
- ]
- exec_vectors = [
- item
- for item in rendered_vectors
- if (((item.get("exec_result") or {}).get("inspection") or {}).get("exec_seen"))
- ]
- reflected_cors = [item for item in cors if item.get("acao") == test_origin]
+ return out
+
+ records = []
+ endpoint_set: set[str] = set()
+ for index, url in enumerate(URLS, start=1):
+ status, headers, body, error = fetch(url)
+ filename = f'{index:02d}-{hashlib.sha256(url.encode()).hexdigest()[:12]}.js'
+ (CHUNKS / filename).write_bytes(body)
+ text = body.decode('utf-8', errors='replace')
+ endpoints = []
+ for match in ENDPOINT_RE.finditer(text):
+ value = match.group(1) or match.group(0)
+ value = value.strip('"\'')
+ if value not in endpoints:
+ endpoints.append(value)
+ endpoint_set.add(value)
+ hits = {name: contexts(text, pattern) for name, pattern in INTERESTING.items()}
+ records.append({
+ 'url': url,
+ 'filename': filename,
+ 'status': status,
+ 'error': error,
+ 'content_type': headers.get('content-type'),
+ 'length': len(body),
+ 'sha256': hashlib.sha256(body).hexdigest(),
+ 'endpoints': endpoints,
+ 'interesting': {name: values for name, values in hits.items() if values},
+ })
report = {
- "generated_at_utc": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
- "audit_type": "bounded unauthenticated GET-only assessment",
- "safety": {
- "authentication_or_cookies": False,
- "state_changing_methods": False,
- "personal_data_requested": False,
- "methods": ["GET"],
- "conditional_script_probe": "benign DOM attribute mutation only; executed solely after confirmed harmless HTML element injection",
- "marker": MARKER,
- },
- "request_count_explicit": request_count,
- "downloaded_bytes_explicit": downloaded_bytes,
- "http_targets": HTTP_TARGETS,
- "pages": pages,
- "rendered_vectors": rendered_vectors,
- "cors": cors,
- "assets_discovered": len(asset_urls),
- "assets_downloaded": len(assets),
- "assets": assets,
- "inline_candidates": inline_candidates,
- "triage": {
- "source_sink_assets": len(source_sink_assets),
- "source_sink_assets_within_5000_bytes": len(close_assets),
- "actual_html_injection_vectors": len(html_injection_vectors),
- "confirmed_benign_script_execution_vectors": len(exec_vectors),
- "arbitrary_origin_cors_reflections": len(reflected_cors),
- },
+ 'generated_at_utc': time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime()),
+ 'safety': {'methods': ['GET'], 'authentication': False, 'state_changes': False},
+ 'request_count': len(URLS),
+ 'bundles': records,
+ 'unique_endpoints': sorted(endpoint_set),
}
- (OUT / "summary.json").write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
-
- evidence = [
- "# NAVER CLOVA bounded audit evidence",
- "",
- f"Generated: {report['generated_at_utc']}",
- f"Explicit HTTP requests: {request_count}",
- f"Explicit bytes downloaded: {downloaded_bytes}",
- "Authentication/cookies: none",
- "Methods: GET only",
- "State changes/personal-data access: none",
- "",
- "## Automated triage",
- f"- Source+sink assets: {len(source_sink_assets)}",
- f"- Source+sink proximity <= 5000 bytes: {len(close_assets)}",
- f"- Harmless HTML injection vectors: {len(html_injection_vectors)}",
- f"- Confirmed benign script-execution vectors: {len(exec_vectors)}",
- f"- Reflected arbitrary Origin responses: {len(reflected_cors)}",
- "",
- "This is candidate generation. A reportable vulnerability requires manual data-flow and impact validation.",
- ]
- (OUT / "evidence.md").write_text("\n".join(evidence) + "\n", encoding="utf-8")
-
- public_summary = {
- "audit_complete": True,
- "explicit_requests": request_count,
- "pages": len(pages),
- "assets_downloaded": len(assets),
- "source_sink_candidates": len(source_sink_assets),
- "close_source_sink_candidates": len(close_assets),
- "html_injection_vectors": len(html_injection_vectors),
- "confirmed_exec_vectors": len(exec_vectors),
- "reflected_cors": len(reflected_cors),
+ (OUT / 'summary.json').write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding='utf-8')
+ public = {
+ 'audit_complete': True,
+ 'requests': len(URLS),
+ 'bundles_downloaded': sum(1 for item in records if item['status'] == 200),
+ 'unique_endpoint_strings': len(endpoint_set),
+ 'bundles_with_html_sinks': sum(1 for item in records if 'dangerouslySetInnerHTML' in item['interesting'] or 'innerHTML' in item['interesting']),
}
- (ROOT / "public-summary.json").write_text(json.dumps(public_summary, indent=2), encoding="utf-8")
- print(json.dumps(public_summary))
+ (ROOT / 'public-summary.json').write_text(json.dumps(public, indent=2), encoding='utf-8')
+ print(json.dumps(public))
PY
- python3 "$AUDIT_ROOT/audit.py"
-
- tar -C "$OUT" -czf "$AUDIT_ROOT/evidence.tar.gz" .
- openssl rand 32 > "$AUDIT_ROOT/key.bin"
- openssl enc -aes-256-cbc -pbkdf2 -iter 200000 -salt \
- -in "$AUDIT_ROOT/evidence.tar.gz" \
- -out "$AUDIT_ROOT/evidence.tar.gz.enc" \
- -pass file:"$AUDIT_ROOT/key.bin"
-
- cat > "$AUDIT_ROOT/public.pem" <<'PEM'
+ python3 "$ROOT/audit.py"
+ tar -C "$OUT" -czf "$ROOT/evidence.tar.gz" .
+ openssl rand 32 > "$ROOT/key.bin"
+ openssl enc -aes-256-cbc -pbkdf2 -iter 200000 -salt -in "$ROOT/evidence.tar.gz" -out "$ROOT/evidence.tar.gz.enc" -pass file:"$ROOT/key.bin"
+ cat > "$ROOT/public.pem" <<'PEM'
-----BEGIN PUBLIC KEY-----
- MIIBojANBgkqhkiG9w0BAQEFAAOCAY8AMIIBigKCAYEAuYxNFglspVUuZ+dkHr6N
- cWTGyrV9SB3oqa7TkEPKwDEz4zy37eiu2iXcMtHw9LSeiur8uyTwaRGc9io76+WB
- DYvAyXUzVgGpsK5Q93dJPCh0f6I9ZQEK3j8sw79+go1qtKco9xcx08swZgZVhB7c
- MOUy2M9TpbE3/oZUlW6sUQUJC2Bk87mrJk6ebZAviKIfeKcTsLq4ck9+1MFJ/xIS
- yOdyu7Os616z/TKKy7Qf9d0DyZ1fyCBccIlwZpw6buGv2tk8FPceCq9Oudod3DnS
- nHjShVfVeetF96eILhLaGNVVNgwHzIIKXqT4Fw7e0ujRQPktDR1XGdfhLzkPzcvh
- fPqGUbq64sx26MmkeNYP/OJYV5NyzcC5BX8/uWNJDH5cds6emLeNkkgEABrGrBo/
- r9NyEcZQwlva6ct4h1l5zN0OmcZK549O0u4oYHNE8kk5g3T7rT2gn01mrMtif8D1
- 4hzvF+NaC1jVUnxIE/CG41F9qaBrEyaEnFG5WYVplWxZAgMBAAE=
+ MIIBojANBgkqhkiG9w0BAQEFAAOCAY8AMIIBigKCAYEAu9ZB1cO7Xoi8jMR0XGgd
+ qHKKqUkz+hSyrBFdvFJ5ca/yo7yeKN0ZqRumi99Sbl4v49jTT7oF6s1YDrNpjRWS
+ iWgVt4FhOo/8fbs8ZOQDOUZQLqWr0zTbvrmXnG5uMTn5dmOlE5MaWiQA0CvxUuXK
+ chNicVzG6/kBSv6iHZNhw3gKuTFbN0httgqss4AKkXNgl3UPhEHEQEhw241o+PxX
+ 0vzVqE0kFDNlkvVzOX1s/8w03qQwDCHI7SpqwWAXJP/2F6Av4VzXr95jwUE/Icy2
+ jUxapf3tjpD47NpGAhtQRukIF/fAFA6wfSpveEYgHKctn9bxfLZriKWGKzXCPzB9
+ oBpU5mnToaMAHvQABbeWDRpbHeVm3TkVgXNq/qGJhmEsqP1O80yiEyHAUFD0XINZ
+ KA4DTlLuLmM9piz9r9VNZEakL9Co+yiWtq5CdLy+nwh205z2/vT55keYBzonJiAS
+ md2fazTVTarygfyb+it15LkUN3sZzAMJVpuX4OY0H1h1AgMBAAE=
-----END PUBLIC KEY-----
PEM
+ openssl pkeyutl -encrypt -pubin -inkey "$ROOT/public.pem" -in "$ROOT/key.bin" -out "$ROOT/key.enc" -pkeyopt rsa_padding_mode:oaep -pkeyopt rsa_oaep_md:sha256
+ sha256sum "$ROOT/evidence.tar.gz.enc" "$ROOT/key.enc" > "$ROOT/checksums.txt"
+ rm -rf "$OUT" "$ROOT/evidence.tar.gz" "$ROOT/key.bin" "$ROOT/public.pem" "$ROOT/audit.py"
- openssl pkeyutl -encrypt -pubin \
- -inkey "$AUDIT_ROOT/public.pem" \
- -in "$AUDIT_ROOT/key.bin" \
- -out "$AUDIT_ROOT/key.enc" \
- -pkeyopt rsa_padding_mode:oaep \
- -pkeyopt rsa_oaep_md:sha256
-
- sha256sum "$AUDIT_ROOT/evidence.tar.gz.enc" "$AUDIT_ROOT/key.enc" > "$AUDIT_ROOT/checksums.txt"
- rm -rf "$OUT" "$AUDIT_ROOT/evidence.tar.gz" "$AUDIT_ROOT/key.bin" "$AUDIT_ROOT/public.pem" "$AUDIT_ROOT/audit.py"
-
- - name: Upload encrypted evidence only
+ - name: Upload encrypted bundle evidence
uses: actions/upload-artifact@v4
with:
- name: naver-readonly-audit-encrypted
+ name: naver-clova-bundle-evidence-encrypted
path: |
- ${{ runner.temp }}/naver-audit/evidence.tar.gz.enc
- ${{ runner.temp }}/naver-audit/key.enc
- ${{ runner.temp }}/naver-audit/checksums.txt
- ${{ runner.temp }}/naver-audit/public-summary.json
- if-no-files-found: error
+ ${{ runner.temp }}/naver-clova-bundles/evidence.tar.gz.enc
+ ${{ runner.temp }}/naver-clova-bundles/key.enc
+ ${{ runner.temp }}/naver-clova-bundles/checksums.txt
+ ${{ runner.temp }}/naver-clova-bundles/public-summary.json
retention-days: 1
+ if-no-files-found: error
From d246abf27af5cffc42c884b6f2c691555752bd02 Mon Sep 17 00:00:00 2001
From: APZN <129354802+jsk1004ha@users.noreply.github.com>
Date: Wed, 19 Aug 2026 13:17:51 +0900
Subject: [PATCH 4/4] security: validate CLOVA search API input handling
---
.github/workflows/naver-readonly-audit.yml | 275 +++++++++++++++------
1 file changed, 197 insertions(+), 78 deletions(-)
diff --git a/.github/workflows/naver-readonly-audit.yml b/.github/workflows/naver-readonly-audit.yml
index 93542f8..7f69e2d 100644
--- a/.github/workflows/naver-readonly-audit.yml
+++ b/.github/workflows/naver-readonly-audit.yml
@@ -13,15 +13,15 @@ jobs:
audit:
if: ${{ github.event.pull_request.head.repo.full_name == github.repository && github.head_ref == 'security/naver-readonly-audit-20260819' }}
runs-on: ubuntu-latest
- timeout-minutes: 10
+ timeout-minutes: 12
steps:
- - name: Fetch and inspect public CLOVA application bundles
+ - name: Test public CLOVA search APIs with harmless markers
shell: bash
run: |
set -euo pipefail
- ROOT="$RUNNER_TEMP/naver-clova-bundles"
+ ROOT="$RUNNER_TEMP/naver-clova-api"
OUT="$ROOT/out"
- mkdir -p "$OUT/chunks"
+ mkdir -p "$OUT/responses"
cat > "$ROOT/audit.py" <<'PY'
from __future__ import annotations
@@ -32,39 +32,60 @@ jobs:
import re
import time
import urllib.error
+ import urllib.parse
import urllib.request
from pathlib import Path
+ from typing import Any
- ROOT = Path(os.environ['RUNNER_TEMP']) / 'naver-clova-bundles'
+ ROOT = Path(os.environ['RUNNER_TEMP']) / 'naver-clova-api'
OUT = ROOT / 'out'
- CHUNKS = OUT / 'chunks'
- CHUNKS.mkdir(parents=True, exist_ok=True)
-
- URLS = [
- 'https://clova.ai/_next/static/chunks/716-4bc8895918653ea2.js',
- 'https://clova.ai/_next/static/chunks/356-3dd847132a3b795f.js',
- 'https://clova.ai/_next/static/chunks/pages/index-69eb870b26bec00f.js',
- 'https://clova.ai/_next/static/chunks/891-974e99e6ac994be0.js',
- 'https://clova.ai/_next/static/chunks/205-6cdb0f0222e1fa21.js',
- 'https://clova.ai/_next/static/chunks/pages/tech-blog-731cde12a8ad2854.js',
- 'https://clova.ai/_next/static/chunks/pages/search/%5Bterm%5D-0dbd3fc5b25289ed.js',
- 'https://clova.ai/_next/static/chunks/pages/hyperclova-e015d3d516dd33fa.js',
- ]
+ RESPONSES = OUT / 'responses'
+ RESPONSES.mkdir(parents=True, exist_ok=True)
+
+ BASE = 'https://clova.ai'
+ MARKER = 'NBB_SAFE_20260819_5f6f2'
+ HTML_PROBE = MARKER + 'SAFE'
+ SYNTAX_PROBE = MARKER + '["{'
+ PARAM_PROBE = MARKER + '&nbb_extra=5f6f2'
+ ORIGIN = 'https://example.invalid'
UA = 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 Chrome/142 Safari/537.36'
- MAX_BYTES = 8 * 1024 * 1024
- ENDPOINT_RE = re.compile(r'''(?:https://[^"'\s]+|["'](/(?:api|graphql|search|v\d)/[^"'\s]{0,260})["'])''')
- INTERESTING = {
- 'dangerouslySetInnerHTML': re.compile(r'dangerouslySetInnerHTML'),
- 'innerHTML': re.compile(r'\.innerHTML\s*='),
- 'router.query': re.compile(r'(?:router|query|asPath|pathname|location\.(?:search|hash|pathname))'),
- 'URLSearchParams': re.compile(r'URLSearchParams'),
- 'decodeURIComponent': re.compile(r'decodeURIComponent'),
- 'fetch': re.compile(r'\bfetch\s*\('),
- 'axios': re.compile(r'\.(?:get|post)\s*\('),
- }
+ MAX_BYTES = 2 * 1024 * 1024
+
+ SEARCH_ENDPOINTS = [
+ ('tech-blog', '/api/techBlog', {'locale': 'ko', 'after': '', 'category': 'tech-blog'}, 'term'),
+ ('hcx-tech-blog', '/api/hcxTechBlogSearchOnly', {'locale': 'ko', 'after': '', 'category': 'tech-blog'}, 'term'),
+ ('hcx-use-case', '/api/hcxUseCaseSearchOnly', {'locale': 'ko', 'after': ''}, 'term'),
+ ('hcx-products', '/api/hcxAiProductsSearchOnly', {'locale': 'ko', 'after': ''}, 'term'),
+ ]
+ AUX_ENDPOINTS = [
+ ('popular-tags', '/api/hcxPopularTags', {'locale': 'ko'}),
+ ('use-cases', '/api/hcxUseCase', {'locale': 'ko', 'after': '', 'subCategory': ''}),
+ ('tech-blog-tag', '/api/techBlogByTag', {'locale': 'ko', 'after': '', 'category': 'tech-blog', 'tag': 'ai'}),
+ ]
+ PROBES = [
+ ('baseline', MARKER),
+ ('html', HTML_PROBE),
+ ('syntax', SYNTAX_PROBE),
+ ('parameter', PARAM_PROBE),
+ ]
- def fetch(url: str) -> tuple[int, dict[str, str], bytes, str | None]:
- req = urllib.request.Request(url, headers={'User-Agent': UA, 'Accept-Encoding': 'identity'}, method='GET')
+ request_count = 0
+ total_bytes = 0
+
+ def make_url(path: str, params: dict[str, str]) -> str:
+ return BASE + path + '?' + urllib.parse.urlencode(params, doseq=False, safe='')
+
+ def fetch(url: str, origin: str | None = None) -> tuple[int, dict[str, str], bytes, str | None]:
+ global request_count, total_bytes
+ headers = {
+ 'User-Agent': UA,
+ 'Accept': 'application/json,text/plain,*/*',
+ 'Accept-Encoding': 'identity',
+ }
+ if origin:
+ headers['Origin'] = origin
+ request_count += 1
+ req = urllib.request.Request(url, headers=headers, method='GET')
try:
with urllib.request.urlopen(req, timeout=25) as resp:
body = resp.read(MAX_BYTES + 1)
@@ -77,63 +98,161 @@ jobs:
body = exc.read(MAX_BYTES + 1)[:MAX_BYTES]
result = int(exc.code), {k.lower(): v for k, v in exc.headers.items()}, body, f'HTTP {exc.code}'
except Exception as exc: # noqa: BLE001
- result = 0, {}, b'', f'{type(exc).__name__}: {exc}'
- time.sleep(0.9)
+ body = b''
+ result = 0, {}, body, f'{type(exc).__name__}: {exc}'
+ total_bytes += len(body)
+ time.sleep(1.0)
return result
- def contexts(text: str, pattern: re.Pattern[str], radius: int = 700, limit: int = 30) -> list[dict[str, object]]:
- out: list[dict[str, object]] = []
- for match in pattern.finditer(text):
- out.append({
- 'offset': match.start(),
- 'snippet': text[max(0, match.start()-radius):min(len(text), match.end()+radius)],
- })
- if len(out) >= limit:
- break
+ def walk_json(value: Any, path: str = '$') -> list[tuple[str, str]]:
+ out: list[tuple[str, str]] = []
+ if isinstance(value, dict):
+ for key, child in value.items():
+ out.extend(walk_json(child, f'{path}.{key}'))
+ elif isinstance(value, list):
+ for index, child in enumerate(value[:500]):
+ out.extend(walk_json(child, f'{path}[{index}]'))
+ elif isinstance(value, str):
+ out.append((path, value))
return out
- records = []
- endpoint_set: set[str] = set()
- for index, url in enumerate(URLS, start=1):
- status, headers, body, error = fetch(url)
- filename = f'{index:02d}-{hashlib.sha256(url.encode()).hexdigest()[:12]}.js'
- (CHUNKS / filename).write_bytes(body)
+ def inspect_body(body: bytes, headers: dict[str, str]) -> dict[str, Any]:
text = body.decode('utf-8', errors='replace')
- endpoints = []
- for match in ENDPOINT_RE.finditer(text):
- value = match.group(1) or match.group(0)
- value = value.strip('"\'')
- if value not in endpoints:
- endpoints.append(value)
- endpoint_set.add(value)
- hits = {name: contexts(text, pattern) for name, pattern in INTERESTING.items()}
+ parsed: Any = None
+ parse_error = None
+ try:
+ parsed = json.loads(text)
+ except Exception as exc: # noqa: BLE001
+ parse_error = f'{type(exc).__name__}: {exc}'
+ strings = walk_json(parsed) if parsed is not None else [('$raw', text)]
+ reflections = []
+ html_fields = []
+ error_fields = []
+ sensitive_key_hits = []
+ for path, value in strings:
+ if MARKER in value:
+ reflections.append({'path': path, 'value': value[:2500]})
+ if re.search(r'<(?:script|img|svg|iframe|object|embed|style|a|p|div|span|em|b)\b|on\w+\s*=|javascript:', value, re.I):
+ html_fields.append({'path': path, 'value': value[:2500]})
+ if re.search(r'(?:stack|traceback|graphql|syntax error|exception|internal server|wpgraphql|wordpress|query failed)', value, re.I):
+ error_fields.append({'path': path, 'value': value[:4000]})
+ if isinstance(parsed, dict):
+ def scan_keys(obj: Any, path: str = '$') -> None:
+ if isinstance(obj, dict):
+ for key, child in obj.items():
+ if re.search(r'(?:secret|token|password|authorization|cookie|email|phone|private|draft|internal)', str(key), re.I):
+ sensitive_key_hits.append({'path': f'{path}.{key}', 'type': type(child).__name__})
+ scan_keys(child, f'{path}.{key}')
+ elif isinstance(obj, list):
+ for index, child in enumerate(obj[:500]):
+ scan_keys(child, f'{path}[{index}]')
+ scan_keys(parsed)
+ return {
+ 'text_length': len(text),
+ 'sha256': hashlib.sha256(body).hexdigest(),
+ 'json_parsed': parsed is not None,
+ 'json_parse_error': parse_error,
+ 'top_level_type': type(parsed).__name__ if parsed is not None else None,
+ 'top_level_keys': sorted(parsed.keys()) if isinstance(parsed, dict) else None,
+ 'marker_reflections': reflections[:50],
+ 'html_like_fields': html_fields[:100],
+ 'error_like_fields': error_fields[:50],
+ 'sensitive_key_name_hits': sensitive_key_hits[:100],
+ }
+
+ records = []
+ counter = 0
+ for endpoint_name, path, defaults, term_name in SEARCH_ENDPOINTS:
+ for probe_name, probe_value in PROBES:
+ params = dict(defaults)
+ params[term_name] = probe_value
+ url = make_url(path, params)
+ status, headers, body, error = fetch(url)
+ counter += 1
+ filename = f'{counter:02d}-{endpoint_name}-{probe_name}.bin'
+ (RESPONSES / filename).write_bytes(body)
+ records.append({
+ 'endpoint': endpoint_name,
+ 'probe': probe_name,
+ 'url': url,
+ 'status': status,
+ 'error': error,
+ 'content_type': headers.get('content-type'),
+ 'headers': {
+ key: headers.get(key)
+ for key in ('access-control-allow-origin', 'access-control-allow-credentials', 'vary', 'cache-control', 'x-powered-by')
+ },
+ 'body_file': filename,
+ 'inspection': inspect_body(body, headers),
+ })
+
+ params = dict(defaults)
+ params[term_name] = MARKER
+ url = make_url(path, params)
+ status, headers, body, error = fetch(url, ORIGIN)
+ counter += 1
+ filename = f'{counter:02d}-{endpoint_name}-cors.bin'
+ (RESPONSES / filename).write_bytes(body)
records.append({
+ 'endpoint': endpoint_name,
+ 'probe': 'cors',
'url': url,
- 'filename': filename,
'status': status,
'error': error,
'content_type': headers.get('content-type'),
- 'length': len(body),
- 'sha256': hashlib.sha256(body).hexdigest(),
- 'endpoints': endpoints,
- 'interesting': {name: values for name, values in hits.items() if values},
+ 'headers': {
+ key: headers.get(key)
+ for key in ('access-control-allow-origin', 'access-control-allow-credentials', 'vary', 'cache-control', 'x-powered-by')
+ },
+ 'body_file': filename,
+ 'inspection': inspect_body(body, headers),
+ })
+
+ for endpoint_name, path, params in AUX_ENDPOINTS:
+ url = make_url(path, params)
+ status, headers, body, error = fetch(url, ORIGIN)
+ counter += 1
+ filename = f'{counter:02d}-{endpoint_name}.bin'
+ (RESPONSES / filename).write_bytes(body)
+ records.append({
+ 'endpoint': endpoint_name,
+ 'probe': 'baseline-cors',
+ 'url': url,
+ 'status': status,
+ 'error': error,
+ 'content_type': headers.get('content-type'),
+ 'headers': {
+ key: headers.get(key)
+ for key in ('access-control-allow-origin', 'access-control-allow-credentials', 'vary', 'cache-control', 'x-powered-by')
+ },
+ 'body_file': filename,
+ 'inspection': inspect_body(body, headers),
})
+ reflected = [r for r in records if r['inspection']['marker_reflections']]
+ server_errors = [r for r in records if r['status'] >= 500 or r['inspection']['error_like_fields']]
+ cors_reflections = [r for r in records if r['headers'].get('access-control-allow-origin') == ORIGIN]
+ html_reflections = [
+ r for r in records
+ if r['inspection']['marker_reflections'] and any(MARKER in item['value'] for item in r['inspection']['html_like_fields'])
+ ]
report = {
'generated_at_utc': time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime()),
- 'safety': {'methods': ['GET'], 'authentication': False, 'state_changes': False},
- 'request_count': len(URLS),
- 'bundles': records,
- 'unique_endpoints': sorted(endpoint_set),
+ 'safety': {'methods': ['GET'], 'authentication': False, 'cookies': False, 'state_changes': False},
+ 'request_count': request_count,
+ 'downloaded_bytes': total_bytes,
+ 'marker': MARKER,
+ 'origin_probe': ORIGIN,
+ 'records': records,
+ 'triage': {
+ 'records_with_marker_reflection': len(reflected),
+ 'records_with_marker_inside_html_like_field': len(html_reflections),
+ 'records_with_server_error_or_error_detail': len(server_errors),
+ 'records_reflecting_arbitrary_origin': len(cors_reflections),
+ },
}
(OUT / 'summary.json').write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding='utf-8')
- public = {
- 'audit_complete': True,
- 'requests': len(URLS),
- 'bundles_downloaded': sum(1 for item in records if item['status'] == 200),
- 'unique_endpoint_strings': len(endpoint_set),
- 'bundles_with_html_sinks': sum(1 for item in records if 'dangerouslySetInnerHTML' in item['interesting'] or 'innerHTML' in item['interesting']),
- }
+ public = {'audit_complete': True, 'requests': request_count, **report['triage']}
(ROOT / 'public-summary.json').write_text(json.dumps(public, indent=2), encoding='utf-8')
print(json.dumps(public))
PY
@@ -159,14 +278,14 @@ jobs:
sha256sum "$ROOT/evidence.tar.gz.enc" "$ROOT/key.enc" > "$ROOT/checksums.txt"
rm -rf "$OUT" "$ROOT/evidence.tar.gz" "$ROOT/key.bin" "$ROOT/public.pem" "$ROOT/audit.py"
- - name: Upload encrypted bundle evidence
+ - name: Upload encrypted API evidence
uses: actions/upload-artifact@v4
with:
- name: naver-clova-bundle-evidence-encrypted
+ name: naver-clova-api-evidence-encrypted
path: |
- ${{ runner.temp }}/naver-clova-bundles/evidence.tar.gz.enc
- ${{ runner.temp }}/naver-clova-bundles/key.enc
- ${{ runner.temp }}/naver-clova-bundles/checksums.txt
- ${{ runner.temp }}/naver-clova-bundles/public-summary.json
+ ${{ runner.temp }}/naver-clova-api/evidence.tar.gz.enc
+ ${{ runner.temp }}/naver-clova-api/key.enc
+ ${{ runner.temp }}/naver-clova-api/checksums.txt
+ ${{ runner.temp }}/naver-clova-api/public-summary.json
retention-days: 1
if-no-files-found: error