Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 65 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ jobs:
outputs:
demos: ${{ steps.detect.outputs.demos }}
any: ${{ steps.detect.outputs.any }}
node_demos: ${{ steps.detect.outputs.node_demos }}
any_node: ${{ steps.detect.outputs.any_node }}
steps:
- uses: actions/checkout@v7
with:
Expand All @@ -33,8 +35,12 @@ jobs:
run: |
set -euo pipefail

# A "demo" is any top-level directory containing a build.gradle.kts.
# A "demo" is any top-level directory containing a build.gradle.kts
# (JVM) or a package.json (Node). The two build differently, so they
# are detected into separate matrices rather than one list the build
# job would have to sniff.
mapfile -t all_demos < <(find . -mindepth 2 -maxdepth 2 -name 'build.gradle.kts' -not -path './.*' -printf '%h\n' | sed 's|^\./||' | sort -u)
mapfile -t all_node_demos < <(find . -mindepth 2 -maxdepth 2 -name 'package.json' -not -path './.*' -not -path './*/node_modules/*' -printf '%h\n' | sed 's|^\./||' | sort -u)

if [ "${{ github.event_name }}" = "pull_request" ]; then
# Build only demos whose files changed in this PR.
Expand All @@ -57,9 +63,20 @@ jobs:
fi
done
done
node_to_build=()
for d in "${all_node_demos[@]:-}"; do
[ -n "$d" ] || continue
for f in "${changed_files[@]}"; do
if [[ "$f" == "$d/"* ]]; then
node_to_build+=("$d")
break
fi
done
done
else
# Push to main: build everything (catches drift from external bumps).
demos_to_build=("${all_demos[@]}")
node_to_build=("${all_node_demos[@]:-}")
fi

if [ ${#demos_to_build[@]} -eq 0 ]; then
Expand All @@ -74,6 +91,23 @@ jobs:
echo "any=true" >> "$GITHUB_OUTPUT"
fi

# Same treatment for the Node demos.
node_filtered=()
for d in "${node_to_build[@]:-}"; do
[ -n "$d" ] && node_filtered+=("$d")
done
if [ ${#node_filtered[@]} -eq 0 ]; then
echo "No Node demos to build."
echo "node_demos=[]" >> "$GITHUB_OUTPUT"
echo "any_node=false" >> "$GITHUB_OUTPUT"
else
echo "Node demos to build:"
for d in "${node_filtered[@]}"; do echo " - $d"; done
node_json=$(for d in "${node_filtered[@]}"; do echo "$d"; done | jq -R . | jq -s -c .)
echo "node_demos=$node_json" >> "$GITHUB_OUTPUT"
echo "any_node=true" >> "$GITHUB_OUTPUT"
fi

build:
name: Build ${{ matrix.demo }}
needs: detect
Expand All @@ -96,3 +130,33 @@ jobs:
- name: Build
working-directory: ${{ matrix.demo }}
run: ./gradlew build --no-daemon

build-node:
name: Build ${{ matrix.demo }}
needs: detect
if: needs.detect.outputs.any_node == 'true'
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
demo: ${{ fromJson(needs.detect.outputs.node_demos) }}
steps:
- uses: actions/checkout@v7

- uses: pnpm/action-setup@v4
with:
version: 9

- uses: actions/setup-node@v5
with:
node-version: 22
cache: pnpm
cache-dependency-path: ${{ matrix.demo }}/pnpm-lock.yaml

- name: Install
working-directory: ${{ matrix.demo }}
run: pnpm install --frozen-lockfile

- name: Verify
working-directory: ${{ matrix.demo }}
run: pnpm verify
1 change: 1 addition & 0 deletions README.ko.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ Spring Boot 3.3–3.5 사용 중인 앱용. 스타터의 [`3.x` 브랜치](https
| [`ssrf-guard-okhttp-demo`](ssrf-guard-okhttp-demo/) | OkHttp `Interceptor` + `Dns` — Spring 필요 없음. `OkHttpClient.Builder`에 3줄 wiring | [![Maven Central](https://img.shields.io/maven-central/v/kr.devslab/ssrf-guard-okhttp)](https://central.sonatype.com/artifact/kr.devslab/ssrf-guard-okhttp) |
| [`ssrf-guard-httpclient5-demo`](ssrf-guard-httpclient5-demo/) | Apache HttpClient 5 — **DNS 시점** SSRF 게이트 (`SafeDnsResolver`) + `SafeRedirectStrategy`. Spring에서 wiring 코드 0줄 (모듈이 자체 자동설정 제공); Spring 없으면 5줄. TOCTOU 차단 방식: 동일 `InetAddress[]`로 검증=연결 | [![Maven Central](https://img.shields.io/maven-central/v/kr.devslab/ssrf-guard-httpclient5)](https://central.sonatype.com/artifact/kr.devslab/ssrf-guard-httpclient5) |
| [`ssrf-guard-native-image-demo`](ssrf-guard-native-image-demo/) | ⚡ **GraalVM 네이티브 이미지** 증명. `ssrf-guard:3.1.0` 끌고 `org.graalvm.buildtools.native` plugin 적용, `nativeCompile`이 JVM 빌드와 동일한 12개 공격 패턴을 차단하는 동작하는 네이티브 바이너리를 만든다는 시연. ssrf-guard 3.1.0의 `RuntimeHintsRegistrar` 엔트리가 완전함을 end-to-end 검증 | [![Maven Central](https://img.shields.io/maven-central/v/kr.devslab/ssrf-guard)](https://central.sonatype.com/artifact/kr.devslab/ssrf-guard) |
| [`ssrf-guard-js-workers-demo`](ssrf-guard-js-workers-demo/) | 🌍 **JS/TS 자매를 Cloudflare Workers에서.** 쓸 수 있는 `dns.lookup`이 없는 환경의 `@devslab/ssrf-guard-js` — Hono 미들웨어, 사용자 제출 사이트용 `sameSitePolicy`, 등록 API용 `singleHostPolicy`, 그리고 던지지 않는 `checkUrl`로 답하는 17종 페이로드 매트릭스. 엣지에서 `safeFetch`가 격하되는 대신 실행을 거부하는 이유를 보여줌. Gradle 아니라 Node + pnpm | [![npm](https://img.shields.io/npm/v/%40devslab%2Fssrf-guard-js)](https://www.npmjs.com/package/@devslab/ssrf-guard-js) |

### api-log

Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ For apps still on Spring Boot 3.3–3.5. The starter's [`3.x` branch](https://gi
| [`ssrf-guard-okhttp-demo`](ssrf-guard-okhttp-demo/) | OkHttp `Interceptor` + `Dns` integration — also no Spring needed. Three-line wiring on `OkHttpClient.Builder`. | [![Maven Central](https://img.shields.io/maven-central/v/kr.devslab/ssrf-guard-okhttp)](https://central.sonatype.com/artifact/kr.devslab/ssrf-guard-okhttp) |
| [`ssrf-guard-httpclient5-demo`](ssrf-guard-httpclient5-demo/) | Apache HttpClient 5 — **DNS-time** SSRF gate (`SafeDnsResolver`) + `SafeRedirectStrategy`. Zero wiring code in Spring (module ships its own autoconfig); five-line wiring outside Spring. The TOCTOU-closing approach: validate=connect on the same `InetAddress[]`. | [![Maven Central](https://img.shields.io/maven-central/v/kr.devslab/ssrf-guard-httpclient5)](https://central.sonatype.com/artifact/kr.devslab/ssrf-guard-httpclient5) |
| [`ssrf-guard-native-image-demo`](ssrf-guard-native-image-demo/) | ⚡ **GraalVM native-image** proof. Pulls `ssrf-guard:3.1.0`, applies the `org.graalvm.buildtools.native` plugin, demonstrates `nativeCompile` produces a working native binary that blocks the same 12-pattern attack matrix as the JVM build. End-to-end verification that ssrf-guard 3.1.0's `RuntimeHintsRegistrar` entries are complete. | [![Maven Central](https://img.shields.io/maven-central/v/kr.devslab/ssrf-guard)](https://central.sonatype.com/artifact/kr.devslab/ssrf-guard) |
| [`ssrf-guard-js-workers-demo`](ssrf-guard-js-workers-demo/) | 🌍 **The JS/TS sibling, on Cloudflare Workers.** `@devslab/ssrf-guard-js` where there is no usable `dns.lookup` — Hono middleware, `sameSitePolicy` for user-submitted sites, `singleHostPolicy` for a registered API, and a 17-payload matrix answered with the non-throwing `checkUrl`. Shows why `safeFetch` refuses to run at the edge instead of degrading. Node + pnpm, not Gradle. | [![npm](https://img.shields.io/npm/v/%40devslab%2Fssrf-guard-js)](https://www.npmjs.com/package/@devslab/ssrf-guard-js) |

### api-log

Expand Down
3 changes: 3 additions & 0 deletions ssrf-guard-js-workers-demo/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
node_modules/
.wrangler/
dist/
82 changes: 82 additions & 0 deletions ssrf-guard-js-workers-demo/README.ko.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
# ssrf-guard-js-workers-demo

[English](README.md)

**Cloudflare Workers**에서의 SSRF 방어를
[`@devslab/ssrf-guard-js`](https://github.com/devslab-kr/ssrf-guard-js)로 —
`kr.devslab:ssrf-guard`의 JS/TS 자매 라이브러리입니다.

이 repo의 다른 ssrf-guard 데모는 전부 Spring HTTP 클라이언트를 가드합니다. 이건
엣지 이야기이고, **다른 이야기**입니다: Workers에는 쓸 수 있는 `dns.lookup`이
없고, 설령 있어도 `fetch`가 호스트를 자체적으로 해석하므로 유저랜드 DNS 검사를
실제 연결되는 소켓에 고정할 수 없습니다.

라이브러리의 답은 **아닌 척하지 않는 것**입니다. `safeFetch`는 여기서 실행을
거부하고 `guardedFetch`를 가리키는 타입 있는 에러를 던집니다 — 같은 호출처럼
보이는 더 약한 검사로 슬그머니 격하되는 대신에요. 여전히 유효한 것: URL 시점
검증, 홉별 리다이렉트 재검증, 자격증명 제거, 응답 크기 상한, 툴 입력 스캐너 —
그리고 **허용 목록이 하중을 받습니다.**

## 실행

```bash
pnpm install
pnpm verify # typecheck + 테스트. 네트워크도, Cloudflare 계정도 불필요
pnpm dev # curl 해보고 싶으면 wrangler dev
```

## 엔드포인트

| 엔드포인트 | 보여주는 것 |
| --- | --- |
| `POST /crawl` | `guardedFetch` + `sameSitePolicy` — **사용자가 제출한** 사이트의 페이지 가져오기 |
| `POST /api-call` | `createHonoUrlGuard` 미들웨어 + `singleHostPolicy` — 등록된 엔드포인트 하나, 그 외 아무 데도 |
| `POST /tool-input` | `guardToolInputJson` + `scanEmbedded` — LLM 툴 인자 어디에 숨었든 |
| `GET /attack-matrix` | 알려진 SSRF 페이로드 17종을 `checkUrl`로 — 던지지 않고 **보고** |
| `GET /why-no-safe-fetch` | 위의 거부를 실물로 |

## 정책 두 개, 왜 둘 다 있나

일부러 둘 다 보여줍니다. **잘못 고르는 것**이 이 API 표면이 막으려는 실수라서요.

**`/api-call`은 미들웨어를 씁니다.** 엔드포인트가 미리 정해져 있으니 고정
허용 목록을 **핸들러가 돌기 전에** 강제할 수 있습니다 — request body의 어느
필드에 URL을 숨겨 넣어도 당신 코드에 닿지 않습니다.

**`/crawl`은 미들웨어를 쓸 수 없고**, 그건 빠뜨린 게 아닙니다. 이 정책은
*사용자가 제출한 것에서 파생*됩니다 — `sameSitePolicy(url)`이 리다이렉트를
포함한 fetch 전체를 그 도메인에 잠급니다. 고정 허용 목록이면 모든 제출을
거부하고, 느슨하게 두면 갖지도 않은 안전 속성을 주장하게 됩니다. **가드는 fetch
시점에 있어야 합니다.**

## `singleHostPolicy`는 포트도 잠급니다

이 데모의 등록된 API는 `https://api.example.com:8443/v1`이고, 공격 매트릭스에
읽어볼 값어치가 있는 행이 있습니다:

```
https://api.example.com/v1/ok blocked_port
https://api.example.com:8443/v1/ok allowed
```

같은 호스트, 같은 스킴인데 **포트 하나로 차단**됩니다. 이걸 손으로
`{ exactHosts: [u.hostname] }`라고 쓰면 그 정책은 **자기 base URL을 거부합니다**
— 패키지 기본 `allowedPorts`가 `[-1, 80, 443]`이기 때문입니다. 조용히, 그리고
비표준 포트를 쓰는 배포에서만.

## 테스트가 증명하는 것과 못 하는 것

`pnpm test`는 Hono의 request 헬퍼로 Worker를 구동하고 `fetch`를 주입하므로,
네트워크 없이 가드의 **동작**을 단언합니다: 어떤 페이로드가 거부되는지, 어떤
사유가 발동하는지, 차단된 리다이렉트가 두 번째 홉을 **발행하지 않는지**.

다만 **Node에서 돕니다.** Workers 런타임이 아닙니다. 그래서
`GET /why-no-safe-fetch`는 응답 **모양**만 단언합니다 — Node에는 `node:dns`가
있어서 `safeFetch`가 실제 조회까지 가고, Worker에서와는 **다른 이유로** 실패합니다.
거기서 Workers 전용 메시지를 단언하면 **엉뚱한 이유로 통과하는 테스트**가 됩니다.

## 버전

- `@devslab/ssrf-guard-js` 0.7.1 — `checkUrl`/`isUrlAllowed`(0.6.0),
`maxBytes`(0.6.0), `singleHostPolicy`과 Hono 미들웨어(0.7.0)
- `hono` 4.x
88 changes: 88 additions & 0 deletions ssrf-guard-js-workers-demo/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
# ssrf-guard-js-workers-demo

[한국어](README.ko.md)

SSRF defence on **Cloudflare Workers**, using
[`@devslab/ssrf-guard-js`](https://github.com/devslab-kr/ssrf-guard-js) —
the JS/TS sibling of `kr.devslab:ssrf-guard`.

Every other ssrf-guard demo in this repo guards a Spring HTTP client. This
one is the edge story, and it is a different story: on Workers there is no
usable `dns.lookup`, and even if there were, `fetch` resolves the host
itself — so a userland DNS check cannot be pinned to the socket that
actually connects.

The library's answer is to **not pretend**. `safeFetch` refuses to run
here, with a typed error pointing at `guardedFetch`, rather than degrading
into a weaker check that looks like the same call. What still holds is
URL-time validation, per-hop redirect revalidation, credential stripping,
response-size caps, and the tool-input scanner — with the allowlist doing
the load-bearing work.

## Run it

```bash
pnpm install
pnpm verify # typecheck + tests, no network and no Cloudflare account
pnpm dev # wrangler dev, if you want to curl it
```

## Endpoints

| Endpoint | Shows |
| --- | --- |
| `POST /crawl` | `guardedFetch` + `sameSitePolicy` — fetch a page of the site the *user* submitted |
| `POST /api-call` | `createHonoUrlGuard` middleware + `singleHostPolicy` — one registered endpoint and nowhere else |
| `POST /tool-input` | `guardToolInputJson` with `scanEmbedded` — URLs hidden anywhere in LLM tool arguments |
| `GET /attack-matrix` | `checkUrl` over 17 known SSRF payloads, reported rather than thrown |
| `GET /why-no-safe-fetch` | the refusal above, live |

## The two policies, and why both exist

The demo deliberately shows both, because picking the wrong one is the
mistake this API surface is shaped to prevent.

**`/api-call` uses the middleware.** The endpoint is known ahead of time,
so a fixed allowlist can be enforced *before* the handler runs — a URL
smuggled into any field of the request body never reaches your code.

**`/crawl` cannot use the middleware**, and that is not an oversight. Its
policy is *derived from what the user submitted*: `sameSitePolicy(url)`
locks the whole fetch, redirects included, to that domain. A fixed
allowlist would reject every submission; a permissive one would claim a
safety property it does not have. The guard belongs at the fetch.

## `singleHostPolicy` locks the port too

The registered API in this demo is `https://api.example.com:8443/v1`, and
the attack matrix has a row worth reading:

```
https://api.example.com/v1/ok blocked_port
https://api.example.com:8443/v1/ok allowed
```

Same host, same scheme, blocked on the port alone. Written by hand as
`{ exactHosts: [u.hostname] }`, that policy would **reject its own base
URL**, because the package default `allowedPorts` is `[-1, 80, 443]` —
quietly, and only on non-standard-port deployments.

## What the tests do and do not prove

`pnpm test` drives the Worker through Hono's own request helper with
`fetch` injected, so the guard's behaviour is asserted without network
access: which payloads are refused, which reason fires, and that a blocked
redirect never issues its second hop.

They run on **Node**, not on the Workers runtime. So
`GET /why-no-safe-fetch` is asserted only for its response *shape* — on
Node, `node:dns` exists and `safeFetch` gets as far as a real lookup, which
fails for a different reason than it would on a Worker. Asserting the
Workers-specific message there would be a test that passes for the wrong
reason.

## Versions

- `@devslab/ssrf-guard-js` 0.7.1 — `checkUrl`/`isUrlAllowed` (0.6.0),
`maxBytes` (0.6.0), `singleHostPolicy` and the Hono middleware (0.7.0)
- `hono` 4.x
22 changes: 22 additions & 0 deletions ssrf-guard-js-workers-demo/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
{
"name": "ssrf-guard-js-workers-demo",
"private": true,
"type": "module",
"scripts": {
"dev": "wrangler dev",
"deploy": "wrangler deploy",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"verify": "pnpm typecheck && pnpm test"
},
"dependencies": {
"@devslab/ssrf-guard-js": "^0.7.1",
"hono": "^4.13.1"
},
"devDependencies": {
"@cloudflare/workers-types": "^5.20260801.1",
"typescript": "^5.9.3",
"vitest": "^3.2.4",
"wrangler": "^4.0.0"
}
}
Loading
Loading