다음과 같은 요청이 귀하의 사이트에 도착했다고 가정해 봅시다.
GET /?id=1%20UNION%20SELECT%20password%20FROM%20users
시그니처 기반의 웹 방화벽(WAF)이라면 UNION SELECT 규칙으로 차단할 수 있습니다.
하지만 공격 방식은 계속해서 고안되기 때문에, 규칙을 추가하더라도 방식을 바꾸면 한 번에 통과하는 공격이 존재합니다.
한편, 도착한 모든 요청을 AI가 판단하게 하는 구성에는 지연 시간(latency)과 비용, 오탐지(false positive), 그리고 요청 내용을 외부 API로 전송해야 하는 대가가 따릅니다.
vigilant-heron은 이 두 가지 사이의 간극을 메우기 위해 만들어진 Rust 기반의 리버스 프록시입니다.
Nginx나 Cloudflare 뒤에 배치하면, 명백한 공격은 고정 규칙으로 즉시 차단하고, 판단이 필요한 요청만 결정 모델(Decision Model) (요청에 공격 점수를 반환하는 모델)로 보내 심사합니다.
위의 SQL Injection에 대해서는 로그가 다음과 같이 응답합니다.
{"action":"allow","reason":"rule","rule_id":"vh-sqli-001","would_have":"block"}
규칙 vh-sqli-001이 공격으로 간주하여, enforce 모드에서는 403으로 거부했지만, shadow 모드에서는 통과시키면서 기록합니다.
여기까지의 과정은 10분 만에 완료되며, 시도하는 측의 부담이 적습니다.
- Linux를 사용한다면 바이너리 하나만 배치하면 됩니다. Docker에서도
cargo build로 가능합니다.
- 설정은 약 10줄 정도로 Nginx와 같은 형태입니다.
- shadow 모드는 아무것도 차단하지 않기 때문에, 운영 중인 사이트 앞에 두고 관찰하는 것만으로도 시작할 수 있습니다.
- 모델은 Jev, Clef, 자체 호스팅(self-hosted)의 Strands Decider, 또는 자체 HTTP 엔드포인트 중에서 선택할 수 있습니다.
- 평가를 일괄적으로 실행했음에도 모델 API에 지불한 비용은 총 6.63엔이었습니다.
본 기사에서는 먼저 구성의 전체적인 개요와 테스트 공격을 차단하기까지의 핸즈온(hands-on) 과정을 보여드리겠습니다.
그 후에 요청 평가 순서, 결정 모델로 전송되는 내용, 그리고 모델이 답변할 수 없을 때의 동작 방식 등 설계상의 판단에 대해 설명하겠습니다.
전체 개요: 엣지와 앱 사이에 들어가는 또 하나의 검사대
구성도는 다음과 같으며, vigilant-heron은 에지(nginx나 Cloudflare)와 애플리케이션 사이에 위치합니다.
client ──► nginx / Cloudflare ──► vigilant-heron ──► 애플리케이션
│
└──► 결정 모델 (Jev, Clef, Strands Decider, 자체)
심사는 2단계 구조입니다.
- 고정 규칙(Fixed Rule): SQL Injection이나 Path Traversal과 같은 알려진 패턴을 모델에 의존하지 않고 차단합니다.
- 결정 모델: 규칙으로 결정되지 않은 요청의 점수를 받아옵니다.
모델은 교체 가능하며, TypeSafe한 Jev, Cloudflare Workers AI의 Clef, 자체 호스팅의 Strands Decider, 또는 자체 HTTP 엔드포인트 중에서 선택할 수 있습니다.
이 순서에는 하나의 원칙이 있습니다.
규칙 결정은 모델을 덮어쓸 수 없다.
차단 규칙에 매치된 요청은 모델을 호출하기 전에 멈추며, 모델의 답변이 어떤 값이든 차단을 허용으로 바꿀 수는 없습니다.
반대로, 이미지나 CSS로 가는 단순한 요청은 모델로 보내지 않고 그대로 전달합니다.
모델 호출은 규칙이나 스킵(skip)으로 결정되지 않은 나머지 경우에만 발생합니다.
설정은 Nginx와 유사한 형식으로 작성됩니다. 리스닝 주소, 사이트별 업스트림, 결정 모델 선택, 레이트 리밋(rate limit), 로그 등을 하나의 파일에 배치할 수 있습니다.
10분 만에 테스트 공격을 차단하기까지
공식 Getting started 가이드에 따라 로컬에서 실행합니다. 테스트용 애플리케이션을 준비하고, 라우터를 shadow 모드로 시작한 다음, SQL Injection을 보내고 enforce로 전환하는 과정을 진행합니다.
설치하기
Linux의 경우 릴리스 바이너리를 다운로드합니다 (x86_64 및 aarch64, glibc 2.39 이상).
ARCH=$(uname -m) # x86_64 또는 aarch64
curl -fsSLO "https://github.com/coo-quack/vigilant-heron/releases/latest/download/vigilant-heron-$ARCH-unknown-linux-gnu.tar.gz"
tar -xzf "vigilant-heron-$ARCH-unknown-linux-gnu.tar.gz"
...
macOS에서는 소스에서 빌드합니다.
rust-toolchain.toml
에 지정된 툴체인이 첫 번째 빌드에 사용됩니다.
git clone https://github.com/coo-quack/vigilant-heron.git
cd vigilant-heron
cargo build --release --locked
Docker 이미지도 공개되어 있습니다.
docker run --rm ghcr.io/coo-quack/vigilant-heron:latest --version
테스트용 업스트림(Upstream) 설정하기
전달 대상이 되는 애플리케이션 대신, Python의 내장 서버를 사용하여 폴더를 공개합니다.
별도의 터미널에서 계속 실행합니다.
mkdir -p site && echo "hello from the upstream" > site/index.html
python3 -m http.server 3000 --bind 127.0.0.1 --directory site
최소 설정 파일 작성하기
vigilant-heron.conf라는 이름으로 저장합니다.
포트 8080에서 대기하며, example.com으로의 요청을 포트 3000으로 전달하도록 설정합니다.
listen 127.0.0.1:8080; # 라우터의 리스닝 주소
pid vigilant-heron.pid; # 프로세스 ID 쓰기 위치
decision {
...
mode shadow; 상태일 때, 라우터는 모든 요청을 전달하고 차단했어야 할 것들만 기록합니다.
설정 검증 및 시작하기
-t 옵션은 파일을 읽어 검증한 후 종료됩니다. 서버는 시작하지 않습니다.
$ TYPESAFE_API_KEY=... vigilant-heron -t -c vigilant-heron.conf
configuration file vigilant-heron.conf test is successful
서버를 시작합니다.
TYPESAFE_API_KEY=... vigilant-heron -c vigilant-heron.conf
일반 요청 및 테스트 공격 보내기
다른 터미널에서 일반적인 요청을 보냅니다.
curl -i http://127.0.0.1:8080/ -H "Host: example.com"
200 OK와 hello from the upstream이 반환됩니다.
다음으로, 쿼리 문자열(query string)에 SQL Injection을 포함하는 요청을 보냅니다.
curl -i "http://127.0.0.1:8080/?id=1%20UNION%20SELECT%20password%20FROM%20users" \
-H "Host: example.com"
Shadow Mode 상태이므로 이 요청 역시 전달되며, 응답은 200 OK를 유지합니다.
라우터의 터미널을 보면, 이 요청에 대한 로그에 다음 필드들이 나열되어 있습니다.
{"status":"200","action":"allow","reason":"rule","rule_id":"vh-sqli-001","would_have":"block",...}
rule_id는 일치한 내장 규칙(built-in rule)의 ID이며, vh-sqli-001은 쿼리 문자열 내의 UNION SELECT에 일치합니다.
would_have가 block이므로, 만약 Enforce Mode 상태였다면 403으로 거부되었음을 의미합니다.
action이 allow인 이유는 Shadow Mode가 요청을 전달했기 때문입니다.
Enforce 모드로 전환하기
shadow의 로그를 확인하여 차단될 예정인 요청이 예상대로인지 검토한다.
문제가 없다면 설정의 mode shadow;을 mode enforce;로 변경하고, 시그널(signal)을 보내 설정을 다시 읽어들인다.
재부팅 없이 반영되며, 연결은 끊기지 않는다.
vigilant-heron -s reload -c vigilant-heron.conf
다시 테스트 공격을 보내면 응답이 403 Forbidden가 되어 업스트림(upstream)에는 도달하지 못한다.
앞서의 일반 요청은 계속해서 200 OK를 반환한다.
Nginx를 전단에 배치하기 (Placing Nginx Upstream)
실제 운영 환경에서는 Nginx가 전단에서 TLS(Transport Layer Security) 종료(termination)를 수행하고, 라우터로 전달한다. 이 과정에서 WebSocket 업그레이드에 필요한 헤더와 사이트 식별에 사용되는 Host, 클라이언트 주소 등을 전달해야 한다.
upstream vigilant_heron {
server 127.0.0.1:8080;
keepalive 16;
...
}
라우터 측 설정에도, 클라이언트 주소를 Nginx의 주소에서만 신뢰한다는 지시를 추가해야 한다.
real_ip_from 127.0.0.1; # X-Real-IP는 Nginx로부터만 신뢰함
real_ip_header X-Real-IP;
각 요청의 평가 순서 (Evaluation Order of Each Request)
여기부터는 설계 설명이다.
요청이 도착하면 다음 순서로 평가된다. 가장 먼저 결정된 단계가 평가를 종료한다.
- Rate Limit 및 연결 수 제한 (
limit_req, limit_conn). 상한을 초과한 클라이언트는 설정된 상태 코드(기본값 503)로 거부된다.
- Host 헤더 검사. 호스트 이름에 사용할 수 없는 문자가 포함된 요청은 400 에러가 발생한다.
- 접근 목록 (Access List). 거부된 클라이언트 주소는 403 에러가 발생한다.
- 차단 규칙 (Block Rule). 내장 규칙, 파일 순서의 최상위 규칙, 사이트 규칙 순으로 확인하며, 첫 번째 매치에서 차단된다.
- 건너뛰기 규칙 (Skip Rule). 매치된 요청은 모델을 호출하지 않고 전달한다.
- 정적 파일 (Static File). 쿼리나 본문이 없는 이미지, CSS, JavaScript로의 순수 GET 또는 HEAD 요청은 모델에게 문의하지 않고 전달된다.
- 결정 모델 (Decision Model). 여기까지 결정되지 않은 요청에 대해 모델에게 점수를 받아 임계값과 비교한다.
주목할 만한 속성이 두 가지 있다.
첫째는, 차단 규칙이 항상 건너뛰기 규칙보다 먼저 평가된다는 것이다. 따라서 건너뛰기 규칙은 차단 규칙에 매치되는 요청을 통과시키기 위해 사용될 수 없다.
둘째는, 규칙 매칭에 사용되는 경로(path)를 읽는 방식이 하나가 아니라는 점이다. 같은 바이트열이라도 상위 애플리케이션들은 각각 다른 해석으로 경로를 해결한다. Tomcat, Jetty, Spring은 세미콜론 이후의 파라미터를 버리고, IIS와 ASP.NET은 을 /로 읽으며, Express는 원시(raw) 경로를 그대로 매칭에 사용한다.
따라서 vigilant-heron은 경로를 여섯 가지 방식(디코드됨, 세미콜론 분리, 을 /, 이들의 조합, 원시 경로 유지)으로 읽어보고, 그중 하나라도 매치되면 차단한다. 정적 파일에 대한 건너뛰기는 반대로 모든 해석이 정적임을 요구한다.
/index.php/x.css와 같은 경로는 반드시 심사를 받는다.
전달되는 요청 자체는 클라이언트가 보낸 것이 변경되지 않은 상태로 도착한다.
결정 모델로 전송하는 내용 (Content Sent to the Decision Model)
모델에는 요청의 사본을 가공하여 전송된다. 전송하기 전에 자격 증명(credential) 관련 정보들은 제거된다.
cookie, authorization, proxy-authorization, x-api-key, x-auth-token 등은 항상 삭제된다. 이름이 자격 증명을 나타내는 다른 헤더(x-csrf-token이나 x-session-id 같은)도 동일한 규칙으로 삭제된다.
- 폼, JSON, multipart, XML 본문 중 이름에
password, token, secret, credential, card_number 등의 단어를 포함하는 필드나, pass, pin, otp, session 등의 단어가 이름에 포함된 필드는 값을 [REDACTED]로 처리한다.
으로 대체한다. author
또는 shipping, passenger와 같이 무관한 이름은 그대로 전송된다. 본문(body)은 기본적으로 앞의 8 KiB만 전송하며, UTF-8 경계에서 잘리고 내용이 잘린 경우 플래그를 설정한다.
클라이언트로부터 받은 데이터는 모두 untrusted 아래에 배치되며, 모델에게 "데이터로 읽으라"고 전달하는 형태가 된다.
{"event": {
"untrusted": {"method": "POST", "path": "/login", "query": null,
"headers": [["user-agent", "…"]], "body": "user=alice&password=[REDACTED]"}},
...
모델은 0에서 1 사이의 두 가지 점수를 반환한다.
attack은 요청이 공격인지, ai_instruction은 요청이 AI에게 영향을 주어 판단을 바꾸려 하는지 여부이다.
{"attack": 0.02, "ai_instruction": 0.01}
점수는 사이트별로 설정한 두 가지 임계값과 비교된다.
attack은 block_threshold(기본값 0.87)이며, ai_instruction은 instruction_threshold(기본값 0.8)이다. 이 임계값에 도달한 요청은 차단된다.
사용 가능한 백엔드는 네 가지가 있다.
- Jev (기본값): TypeSafe의 Jev를 사용하며,
api.typesafe.ai로 전송하고 키는 TYPESAFE_API_KEY 환경 변수에서 읽는다.
- Clef: Cloudflare Workers AI의
clef-flash 또는 clef이다. 계정 ID가 포함된 URL과 토큰 변수의 지정이 필요하다.
- Strands Decider: Apache-2.0 라이선스의 결정 모델을 자체 서버에서 구동한다.
POST /v1/systemone으로 전송하므로 설정은 URL만으로 충분하다.
- http: 자체 엔드포인트에 위의 JSON을 POST하는 경우,
{"attack": 숫자, "ai_instruction": 숫자}를 반환하는 것이면 무엇이든 사용할 수 있다.
토큰을 설정 파일에 작성할 필요가 없다. api_token_env로 환경 변수 이름을 지정하고 시작 시 읽는다.
참고로, 제3자 API로 전송하는 구성(Jev, Clef)에서는 마스킹이 이름 기반의 최선 노력이다. 크리덴셜을 나타내는 단어가 없는 이름은 그대로 전송된다.
전송지는 공개해도 되는 상대방 중에서 선택하거나 Strands Decider를 자체적으로 운영할 수 있다.
이 판단 자료는 Security에 작성되어 있다.
접근 로그에는 클라이언트 주소, 메서드, 경로, 상태, 판정 필드가 포함되며, 본문(body), 쿼리 문자열, 크리덴셜은 포함되지 않는다.
크리덴셜 이름을 가리키는 $http_* 변수를 로그 형식으로 작성하면 설정 로딩 시 거부된다.
모델이 답변할 수 없을 때
결정 모델은 실패한다.
타임아웃, 연결 오류, 429, 파싱 불가능한 응답 등 실패 유형이 여러 가지 있다.
vigilant-heron은 실패를 세 그룹으로 나누고, 그룹별로 동작을 설정할 수 있다.
- Rate Limit: 모델의 HTTP 429 및 동시 실행 슬롯이
on_rate_limit 내에서 부족하여 포화된 경우. 기본값은 허용이다.
- Client Busy: 클라이언트가 이미 자신의 할당량(
on_client_busy, max_concurrency_per_client, 기본값 8)의 모델 호출을 보유하고 있는 경우. 기본값은 429로 거부한다.
- Decision Error: 타임아웃, 연결 오류, 파싱 불가능, 범위를 벗어난 응답 등. 기본값은 허용이다.
어떤 설정에서도 차단 규칙 결정에는 영향을 미치지 않는다.
모델이 다운되었거나 속도 제한이 걸려 있어도, 규칙에 의한 차단은 유지된다.
기본값이 '허용'인 이유는 모델 제공 측의 장애가 정상 트래픽을 막지 않도록 하기 위함이다. 대신, 공격자가 의도적으로 모델을 포화시키면 그 사이의 요청은 모델을 통과한다.
따라서 반드시 보호하고 싶은 경로에는 fail_closed_path를 설정해야 한다.
이 접두사로 판별에 실패한 요청은 설정과 관계없이 차단됩니다.
site example.com {
upstream http://app:8080;
on_decision_error block;
...
}
이 예시에서는 판별 오류는 일반적으로 차단하고, 속도 제한(rate limit) 시에는 허용하지만, 로그인 경로만은 어떤 실패에도 상관없이 차단합니다.
내장 규칙 (Built-in Rules)
바이너리에는 24개의 내장 규칙이 포함되어 있으며 기본적으로 활성화되어 있습니다.
카테고리는 SQL Injection, XSS, Path Traversal, Command Injection, File Include, SSRF, Template and Expression Injection, XXE, 알려진 스캐너의 User-Agent입니다.
예를 들어 vh-sqli-001은 쿼리 문자열(query string) 내의 UNION SELECT에,
vh-ssrf-001은 클라우드 메타데이터 주소인 169.254.169.254에,
vh-expr-001은 Log4Shell 형식의 ${jndi:에 매치됩니다.
규칙은 '일반 트래픽을 떨어뜨릴 정도가 아니라면 공격을 놓친다'는 방침으로 작성되었습니다.
코드 붙여넣기나 SQL 또는 HTML과 관련된 게시물에서 나올 법한 패턴은 의도적으로 포함하지 않았으며, 그러한 요청은 결정 모델(decision model)로 전달됩니다.
이러한 방침의 귀결로서, 규칙의 매치 대상은 쿼리 문자열에 치우쳐 있습니다.
내장 규칙 측정에서는 허용된 요청을 하나도 차단하지 않았습니다.
알려진 취약점에 대한 가상 패치도 준비되어 있지만 기본값은 꺼져 있습니다.
예를 들어 IBM Langflow OSS의 CVE-2026-9103과 CVE-2026-8481에 대한 vh-cve-2026-9103와 vh-cve-2026-8481은 대상 제품을 구동하는 사이트에서 enable_rule로 활성화합니다.
측정 결과 (Measurement Results)
모델과 규칙을 레이블이 지정된 데이터셋으로 측정했습니다.
데이터셋은 합성(synthetic)이며, 분할(split)마다 공격 85건, 허용 요청 약 110건입니다.
임계값(threshold)은 dev 분할에서 선택했으며 holdout 분할만 샘플 외부입니다.
숫자는 출발점으로 간주하고, 자신의 트래픽에서의 shadow 모드 결과와 해석해야 합니다.
attack 점수의 임계값을 dev 분할에서 선택했을 때 (허용 측면의 오탐률(FPR) 2% 이하에서 재현율(recall)이 최대인 임계값), 결과는 다음과 같았습니다.
| 백엔드 | 임계값 | dev recall / FPR | holdout recall / FPR |
|---|
| jev-latest | 0.87 | 0.871 / 0.018 | 0.892 / 0.009 |
| clef-flash | 0.88 | 0.694 / 0.018 | 0.723 / 0.000 |
ai_instruction 측은 임계값 0.8에서 허용 요청을 하나도 차단하지 않았으며, 프롬프트 인젝션의 15줄 전체를 두 분할 모두에서 차단했습니다.
내장 규칙과의 조합에서는 Jev와 조합했을 때 오탐률이 0이 되었고, 모든 분할에서 재현율이 상승했습니다.
모델의 약점도 보입니다.
두 모델 모두 /.env나 /.git/config로 향하는 스캐너 프로브(scanner probe)를 많이 놓치므로, 이러한 종류는 deny_path로 결정적으로 차단하는 것이 더 적합합니다.
지연 시간(latency)은 일본 자택 회선에서 측정했으며, 라우터가 거쳐가는 결정의 p50이 Jev에서 약 146ms, clef-flash에서 약 375ms였습니다.
clef-flash는 기본 timeout 300ms를 호출의 82%가 초과하므로, Clef를 사용할 경우 1000ms 정도를 권장합니다.
Strands Decider는 NVIDIA L4 한 장에서 측정했으며, 2B 체크포인트가 p50 99ms, p95 436ms였습니다[1].
부하 테스트는 20ms 지연의 로컬 스텁을 모델로 가정하여 수행했습니다 (Apple M2, 8 논리 코어).
병렬 1에서 40.9 req/s, 병렬 16에서 567.9 req/s, 병렬 64에서 2,525.8 req/s, 병렬 64의 p95는 27.7ms였습니다.
이 라우터로 할 수 없는 것 (What this router cannot do)
할 수 있는 만큼, 할 수 없는 것도 명시합니다.
크리덴셜 스터핑 (Credential Stuffing): 동일한 클라이언트가 비밀번호를 100번 시도하더라도, 단일 요청 단위의 판정에서는 나타나지 않습니다. limit_req를 사용하여 경로별로 제한하는 것이 올바릅니다.
세 단계 이상의 인코딩 (Triple Encoding): 경로와 쿼리는 최대 두 단계까지만 디코드하여 읽습니다. 세 단계로 인코딩된 페이로드(payload)는 그 안에 포함된 공격으로 인식되지 않습니다.
검사 대상 이후의 바디 (Body After Inspection Target): 규칙과 모델 모두, 바디는 시작 부분의 inspect_body 바이트만 볼 수 있습니다.
WebSocket 핸드셰이크 이후: 업그레이드(upgrade) 핸드셰이크는 다른 요청과 동일한 심사를 받지만, 101 이후의 프레임은 검사 없이 통과시킵니다.
multipart 내부의 모든 기밀 정보 (All Secrets in Multipart): multipart 바디의 마스킹은 어휘(token) 기반이며, 레이아웃에 따라 기밀 값(secret value)이 모델에 도달할 수 있습니다.
요약
vigilant-heron은 고정 규칙으로 확실하게 차단할 수 있는 것은 확실히 차단하고, 나머지는 결정 모델의 점수(score)로 심사하는 리버스 프록시입니다.
규칙이 모델의 결정을 덮어쓸 수는 없으며, 모델의 실패는 설정으로 제어하고, 정적 파일에는 모델을 사용하지 않습니다.
도입은 shadow 모드에서 로그를 읽는 것부터 시작하며, -s reload로 한 번에 enforce(강제 적용)로 전환됩니다.
흥미가 있다면 다음 자료를 참조해 주십시오.
Strands Decider의 수치는 JevBench 태스크에서의 측정이며, 이 라우터의 질문에 대한 정확도는 미측정입니다. 서버는 인증을 가지고 있지 않으므로 localhost 또는 private network에 배치해야 하며, 공개 체크포인트의 컨텍스트 윈도우가 4,096 토큰이라는 점(긴 프롬프트는 조용히 끝부분이 잘림)에도 주의가 필요합니다. ↩︎
Discussion