Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
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
4 changes: 3 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
# Changelog

## Unreleased
- `/exchange` distributed rate-limit identity가 없는 요청을 shared `unknown` bucket으로 합치지 않고 `503`으로 실패-폐쇄하도록 강화. Cloudflare의 `CF-Connecting-IP`가 정확히 하나의 유효한 IPv4/IPv6가 아니면 Durable Object lookup과 bearer parsing 전에 중단하고, 유효한 IPv6는 canonical form으로 정규화하여 동일 주소의 표기 차이가 rate-limit bucket을 분할하지 않도록 한다.
- CI 검증 중 공개된 `undici` 취약점 묶음(GHSA-4cwx-7wf7-3272 포함)을 제거하기 위해 Wrangler→Miniflare 경유 transitive dependency를 patched `7.29.0`으로 override하고 lockfile을 재생성했다. `npm audit --audit-level=high`를 0건으로 복구하고 release gate가 취약 버전에서 실패-폐쇄하도록 유지한다.
- EOL 상태인 Node.js 20을 배포 계약에서 제거하고 `engines.node >=22` 및 배포 가이드의 지원 중 LTS 요구사항을 일치시켰다.
- SQLite-backed OIDC replay guard의 alarm cleanup을 current-claim-aware 방식으로 강화. Cloudflare alarm의 at-least-once·지연·재시도 실행이 만료 후 교체된 활성 `jti` claim을 삭제하지 않도록 저장된 현재 expiry를 transactionally 재검증하고, 활성 claim이면 해당 만료 시각과 grace period로 reschedule하며 expired/empty storage만 삭제한다.
- SQLite-backed `/exchange` rate limiter의 alarm cleanup을 current-window-aware 방식으로 강화. Cloudflare alarm의 지연·재시도 실행이 새 60초 window의 활성 bucket을 삭제해 요청 예산을 조기 재개하지 않도록 저장된 window deadline을 transactionally 재검증하고, 아직 활성인 경우 실제 reset 시각으로 reschedule하며 expired/empty storage만 삭제한다.
- GitHub Actions OIDC `jti`를 SQLite-backed Durable Object에서 원자적으로 1회만 소비하도록 `/exchange`를 강화. 기존 RS256/JWKS/issuer/audience/repository/exact-workflow 검증과 GitHub installation-token 생성이 성공한 뒤에만 해시된 `jti`를 claim하고, 동일 bearer 재사용은 `401 ERR_AUTH_REPLAY`, binding·storage·결정 이상이나 필수 `jti`/`exp` 누락은 token 전달 없이 `503`으로 실패-폐쇄한다. claim은 OIDC 만료 직후 alarm으로 삭제하며 raw `jti`는 저장·로그하지 않는다.
Expand Down Expand Up @@ -39,4 +42,3 @@
- `/exchange` 401 응답에 `WWW-Authenticate: Bearer realm="noema"` challenge를 추가하고 인증 누락은 `invalid_request`, 잘못된 토큰은 `invalid_token`으로 구분.
- `x-request-id`/`x-correlation-id` 및 client IP 계열 헤더를 길이/문자 기준으로 제한해 로그 오염과 rate-limit key 폭주를 방지.
- `KRW 2,000,000,000` 매각 가능성 Goal 등록서, buyer due diligence index, library/submodule 경계 판단서를 추가하고 `npm run acquisition:audit`로 ARR/LOI/이전성/saleable evidence를 실패-폐쇄 방식으로 검증.
- 개발 의존성 `postcss`(vite 경유 transitive)를 `overrides`로 `^8.5.18`에 고정해 GHSA-r28c-9q8g-f849(소스맵 자동 로딩 경로 탐색으로 인한 임의 `.map` 파일 노출, High)를 제거하고 `npm run security:scan`(`npm audit --audit-level=high`) 게이트를 다시 green으로 복구.
17 changes: 13 additions & 4 deletions docs/deployment-guide.md
Original file line number Diff line number Diff line change
@@ -1,15 +1,24 @@
# Noema 배포 가이드

## 1. 빌드 환경
- Node.js 20+
- Node.js 22+ LTS 또는 그 이후의 지원 중인 LTS 릴리스
- `npm ci`
- 비밀값 준비

Node.js 20은 2026년 3월 유지보수가 종료되어 사용하지 않습니다. Cloudflare Wrangler는 Node.js의 Current·Active LTS·Maintenance LTS 릴리스만 지원하므로 개발, CI, 릴리스 환경은 `package.json`의 `engines.node >=22` 계약을 따라야 합니다.

로컬·CI·릴리스 환경은 의존성 설치 전에 다음 preflight를 실행해 잘못된 런타임을 즉시 차단합니다.

```bash
node -e 'const major=Number(process.versions.node.split(".")[0]); if (!Number.isInteger(major) || major < 22) { console.error(`Node.js >=22 required; found ${process.versions.node}`); process.exit(1); }'
```

## 2. 배포
1. 브랜치 정합성 확인
2. `npm ci`
3. `npm run release:verify:strict` (프로덕션/CD 기준)
4. `wrangler deploy`
2. Node.js runtime preflight 통과 확인
3. `npm ci`
4. `npm run release:verify:strict` (프로덕션/CD 기준)
5. `wrangler deploy`
- 배포 전/후 상태와 KPI 가드 결과는 `noema-kpi-evidence.json`으로 저장해 보관
- 스모크 검증 증빙은 `NOEMA_SMOKE_EVIDENCE_PATH=noema-smoke-evidence.json`로 저장

Expand Down
34 changes: 22 additions & 12 deletions docs/distributed-rate-limiting.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,22 +9,24 @@ The Worker keeps the original in-isolate fixed-window limiter as defense in dept
## Architecture

1. `src/worker.ts` intercepts only `/exchange` requests.
2. The client bucket is derived exclusively from Cloudflare's `CF-Connecting-IP` header. Caller-controlled forwarding headers are ignored.
3. The trusted client identifier is SHA-256 hashed before it is used as the Durable Object name. Raw client IP addresses are not stored in the object name or bucket record.
2. The client bucket is derived exclusively from Cloudflare's `CF-Connecting-IP` header. Caller-controlled forwarding headers are ignored. The value must contain exactly one valid IPv4 or IPv6 address; malformed, out-of-range, comma-separated, bracketed, zone-qualified, or overlong values return `503` before any shared bucket or Durable Object lookup is used.
3. Valid addresses are canonicalized before hashing, so equivalent IPv6 spellings map to the same bucket. The canonical client identifier is SHA-256 hashed before it is used as the Durable Object name. Raw client IP addresses are not stored in the object name or bucket record.
4. Each client hash maps to one `NoemaRateLimiter` Durable Object instance.
5. The object uses transactional, strongly consistent storage for one 60-second fixed-window bucket.
6. Alarm cleanup re-reads the current bucket atomically. An expired or empty bucket is deleted, while a delayed or retried alarm that observes a newer active window is rescheduled to that window's actual reset time instead of erasing its count.
7. The request proceeds to the existing OIDC and GitHub App exchange only when the distributed decision is `allowed=true`.
8. A denied request returns `429`, `Retry-After`, and `X-Rate-Limit-*` headers without parsing the bearer token.
9. A missing binding, failed object request, non-2xx response, or malformed decision fails closed with `503` and `Retry-After: 1`.
9. A missing trusted client identity, missing binding, failed object request, non-2xx response, or malformed decision fails closed with `503` and `Retry-After: 1`.

Cloudflare documents Durable Objects as globally unique coordination primitives with private, transactional, strongly consistent storage. Durable Object alarms have at-least-once execution and may be delayed or retried, so cleanup must validate the current stored deadline rather than assuming every alarm invocation still belongs to the bucket that originally scheduled it. New namespaces use the SQLite backend and can be declared with Wrangler's `exports` lifecycle configuration:
Cloudflare documents Durable Objects as globally unique coordination primitives with private, transactional, strongly consistent storage. Durable Object alarms have at-least-once execution and may be delayed or retried, so cleanup must validate the current stored deadline rather than assuming every alarm invocation still belongs to the bucket that originally scheduled it. New namespaces use the SQLite backend and can be declared with Wrangler's `exports` lifecycle configuration. Cloudflare also documents that `CF-Connecting-IP` is the edge-provided visitor identity header and that request or managed transforms can remove it; Noema therefore treats absence as a deployment misconfiguration rather than collapsing unrelated callers into one fallback bucket:

- https://developers.cloudflare.com/durable-objects/
- https://developers.cloudflare.com/durable-objects/get-started/
- https://developers.cloudflare.com/durable-objects/api/alarms/
- https://developers.cloudflare.com/durable-objects/best-practices/access-durable-objects-storage/
- https://developers.cloudflare.com/workers/wrangler/configuration/
- https://developers.cloudflare.com/fundamentals/reference/http-headers/#cf-connecting-ip
- https://developers.cloudflare.com/rules/transform/managed-transforms/reference/#remove-visitor-ip-headers

## Configuration

Expand All @@ -42,11 +44,14 @@ storage = "sqlite"

`NOEMA_RATE_LIMIT_PER_MINUTE` controls both the distributed limiter and the existing isolate-local limiter. Invalid or non-positive values fall back to `60`; values above `10000` are clamped to `10000`.

The Cloudflare zone must not enable a request transform or the **Remove visitor IP headers** managed transform for the `/exchange` route. If an upstream Worker calls this Worker cross-zone, review Cloudflare's documented Worker-subrequest behavior before enabling the route because cross-zone requests can share Cloudflare's Worker client address and should not be treated as independent end-user identities.

## Privacy and trust boundary

- Only `CF-Connecting-IP` is treated as the client identity input.
- `X-Forwarded-For` and `X-Real-IP` cannot select a distributed bucket.
- Missing, malformed, or overlong client identity values share a fail-safe `unknown` bucket.
- Exactly one syntactically valid IPv4 or IPv6 address is accepted and canonicalized; ambiguous or multiple-address forms fail closed.
- Missing, malformed, or overlong client identity values fail closed before object lookup; unrelated clients are never merged into a global `unknown` bucket.
- Durable Object names contain only a SHA-256 digest.
- Stored bucket data contains a window start timestamp and count, not a client identifier, bearer token, repository, or workflow reference.
- Logs contain outcome, limit, retry duration, and bounded backend diagnostics; they do not contain the raw client identifier or credentials.
Expand All @@ -65,19 +70,24 @@ npm run release:verify
Before production promotion:

1. Confirm Wrangler reports creation or reconciliation of the `NoemaRateLimiter` SQLite namespace.
2. Verify `/health` does not call the Durable Object.
3. Send repeated unauthenticated `/exchange` requests from one source and confirm the configured threshold returns `429` with `Retry-After`.
4. Confirm accepted `/exchange` responses, including authentication failures, carry `X-Rate-Limit-Limit`, `X-Rate-Limit-Remaining`, and `X-Rate-Limit-Scope: distributed`.
5. Exercise an alarm after its original window has expired and a newer window has already begun; confirm the newer count remains stored and the alarm is moved to the newer reset deadline.
6. Temporarily test a missing or invalid binding in a non-production environment and confirm `/exchange` fails closed with `503` rather than bypassing the limiter.
7. Retain Cloudflare deployment evidence and post-deployment smoke evidence through the existing production workflow.
2. Confirm no request-header Transform Rule or managed transform removes `CF-Connecting-IP` for `/exchange`.
3. Verify `/health` does not call the Durable Object.
4. Send repeated unauthenticated `/exchange` requests from one source and confirm the configured threshold returns `429` with `Retry-After`.
5. Confirm accepted `/exchange` responses, including authentication failures, carry `X-Rate-Limit-Limit`, `X-Rate-Limit-Remaining`, and `X-Rate-Limit-Scope: distributed`.
6. In a non-production test route, remove or corrupt `CF-Connecting-IP` and confirm `/exchange` returns `503` without invoking the Durable Object or parsing the bearer token.
7. Confirm expanded and compressed representations of the same IPv6 address select the same Durable Object bucket.
8. Exercise an alarm after its original window has expired and a newer window has already begun; confirm the newer count remains stored and the alarm is moved to the newer reset deadline.
9. Temporarily test a missing or invalid binding in a non-production environment and confirm `/exchange` fails closed with `503` rather than bypassing the limiter.
10. Retain Cloudflare deployment evidence and post-deployment smoke evidence through the existing production workflow.

## Layered protection

This limiter protects the application-level token exchange budget and coordinates decisions across Worker isolates. It is not a volumetric DDoS substitute. Production should retain Cloudflare WAF/rate-limiting rules as the outer edge layer, with the Durable Object limiter as the authorization-adjacent control.

## Rollback and lifecycle safety

A code rollback may restore the previous Worker entrypoint, but the `NoemaRateLimiter` export must not be silently removed from Wrangler configuration because class lifecycle changes can delete or orphan a Durable Object namespace. Use an explicit reviewed `exports` lifecycle state for any future rename, transfer, or deletion, and preserve the existing namespace until operational evidence confirms it is no longer required.
A code rollback may target only a release that preserves fail-closed behavior for missing or invalid `CF-Connecting-IP` identities. Never roll back to a release that uses a shared `unknown` fallback bucket; if no compliant rollback release exists, retain the current entrypoint and repair the Cloudflare route or transform configuration first.

The `NoemaRateLimiter` export must not be silently removed from Wrangler configuration because class lifecycle changes can delete or orphan a Durable Object namespace. Use an explicit reviewed `exports` lifecycle state for any future rename, transfer, or deletion, and preserve the existing namespace until operational evidence confirms it is no longer required.

Do not restore unconditional `deleteAll()` alarm handling. Cloudflare alarms are at-least-once and can be delayed or retried; unconditional cleanup can erase a renewed active bucket and temporarily reopen the request budget.
9 changes: 6 additions & 3 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 5 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@
"version": "0.1.0",
"private": true,
"type": "module",
"engines": {
"node": ">=22"
},
"scripts": {
"deploy": "wrangler deploy",
"dev": "wrangler dev",
Expand Down Expand Up @@ -39,6 +42,7 @@
},
"overrides": {
"sharp": "0.35.3",
"postcss": "^8.5.18"
"postcss": "^8.5.18",
"undici": "7.29.0"
Comment thread
seonghobae marked this conversation as resolved.
}
}
49 changes: 40 additions & 9 deletions src/rate-limit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@ const RATE_LIMIT_WINDOW_MS = 60_000;
const DEFAULT_RATE_LIMIT_PER_MINUTE = 60;
const MAX_RATE_LIMIT_PER_MINUTE = 10_000;
const MAX_CLIENT_IDENTIFIER_LENGTH = 128;
const trustedClientIdentifierPattern = /^[A-Za-z0-9.:%_,-]+$/;
const strictIpv4SegmentPattern = /^(0|[1-9][0-9]{0,2})$/;
const strictIpv6CharacterPattern = /^[0-9A-Fa-f:.]+$/;
const BUCKET_KEY = "exchange-rate-limit";

export interface DistributedRateLimitEnv {
Expand Down Expand Up @@ -57,16 +58,41 @@ export function configuredDistributedRateLimit(raw: string | undefined): number
return Math.min(Math.floor(parsed), MAX_RATE_LIMIT_PER_MINUTE);
}

export function trustedClientIdentifier(request: Request): string {
function canonicalIpv4(candidate: string): string | undefined {
const segments = candidate.split(".");
if (segments.length !== 4) return undefined;

const normalized: string[] = [];
for (const segment of segments) {
if (!strictIpv4SegmentPattern.test(segment)) return undefined;
const value = Number(segment);
if (value > 255) return undefined;
normalized.push(String(value));
}
return normalized.join(".");
}

function canonicalIpv6(candidate: string): string | undefined {
if (!candidate.includes(":") || !strictIpv6CharacterPattern.test(candidate)) {
return undefined;
}

try {
const hostname = new URL(`http://[${candidate}]/`).hostname;
if (!hostname.startsWith("[") || !hostname.endsWith("]")) return undefined;
const normalized = hostname.slice(1, -1).toLowerCase();
return normalized.includes(":") ? normalized : undefined;
} catch {
return undefined;
}
}

export function trustedClientIdentifier(request: Request): string | undefined {
const candidate = request.headers.get("cf-connecting-ip")?.trim() ?? "";
if (
!candidate
|| candidate.length > MAX_CLIENT_IDENTIFIER_LENGTH
|| !trustedClientIdentifierPattern.test(candidate)
) {
return "unknown";
if (!candidate || candidate.length > MAX_CLIENT_IDENTIFIER_LENGTH) {
return undefined;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
return candidate;
return canonicalIpv4(candidate) ?? canonicalIpv6(candidate);
}

async function sha256Hex(value: string): Promise<string> {
Expand All @@ -76,6 +102,11 @@ async function sha256Hex(value: string): Promise<string> {

export async function distributedRateLimitObjectName(request: Request): Promise<string> {
const identifier = trustedClientIdentifier(request);
if (!identifier) {
throw new DistributedRateLimitUnavailable(
"CF-Connecting-IP is missing or invalid; refusing to collapse requests into a shared fallback bucket",
);
}
return `exchange:${await sha256Hex(identifier)}`;
}

Expand Down
Loading
Loading