Skip to content

fix(security): prevent outbound webhook SSRF and DNS rebinding - #588

Open
seonghobae wants to merge 71 commits into
developfrom
fix/webhook-ssrf-551-develop
Open

fix(security): prevent outbound webhook SSRF and DNS rebinding#588
seonghobae wants to merge 71 commits into
developfrom
fix/webhook-ssrf-551-develop

Conversation

@seonghobae

@seonghobae seonghobae commented Aug 23, 2026

Copy link
Copy Markdown
Contributor

Security outcome

Refs #551.

This is the bounded protected-develop repair lane for outbound ScopeWeave webhook SSRF and DNS rebinding only. It intentionally does not own Stripe inbound-webhook trust, OIDC/auth hardening, or rate-limit policy; those remain in their separate owning lanes.

Exact current identity

  • exact contributor head: bc60476829ec98f37d83977a9432b79ba0b3c0d6;
  • exact contributor tree: d6d029a9d9ab31de4df3aa2a51466267d19d7681;
  • branch: fix/webhook-ssrf-551-develop;
  • live protected base: develop@2c328875e00e86537df3e965170be80532571cad;
  • Ready/non-Draft and mechanically mergeable at the latest fresh refetch;
  • no qualifying independent current-head/last-push approval exists.

Any contributor-head, protected-base, review, or required-gate movement invalidates revision-sensitive evidence until freshly reconciled.

Current production boundary

  • server/webhook_transport.mjs owns destination parsing, public-address authorization, A+AAAA validation, IPv4/IPv6 special-use rejection, validated-address pinning through the connection lookup seam, TLS-hostname preservation, redirect non-following, transport timeout/failure semantics, and per-attempt DNS re-resolution.
  • server/app_core.mjs uses the protected webhook transport directly for outbound delivery and applies the same registration policy before persisting a destination. This avoids a process-global fetch override and keeps SSRF protection independent of facade import order.
  • server/app.mjs is the bounded public registration facade. It preserves the core global middleware envelope while canonicalizing the exact registration POST without consuming an unauthenticated caller's body before auth/RBAC/rate-limit decisions.
  • Production registration is public HTTPS only. SCOPEWEAVE_DEV=1 permits only the explicit development-loopback exception; registration, delivery, and historical-row migration share that policy.
  • Startup migration disables active legacy destinations that fail the synchronous current policy and writes a secret-free webhook.security_block audit event. A read-only preflight now avoids taking SQLite's writer reservation when all active rows already satisfy policy; if mutation is required, BEGIN IMMEDIATE is still acquired and active rows are re-read inside the transaction before any mutation.
  • DNS-backed names are re-authorized immediately before each delivery attempt; startup intentionally performs no DNS/network I/O.
  • Redirects are not followed. Each connection is pinned to an address authorized from the immediately preceding DNS resolution, closing the DNS-rebinding/TOCTOU window.

Scope separation and current review truth

The branch does not claim authority for separate inbound billing-trust or unrelated auth controls. Historical review findings that exposed global-fetch mutation, auth-before-body-read ordering, route-replay/static-catch-all ordering, legacy destination migration, transport timeout, rollback error preservation, registration/storage validation, and related security/operability defects have been repaired in this lane or returned to their owning scope.

At the latest fresh refetch every posted review thread is resolved. The remaining historical informational observations describe tested trade-offs such as WHATWG numeric-host normalization, process-start development-mode semantics, deterministic startup migration, Hono route-replay coupling, duplicate idempotent storage-boundary validation, and the TLS-handshake replay boundary; none currently demonstrates a reachable additional outbound-webhook SSRF defect on exact head bc604768....

Regression and buyer traceability

The branch retains realistic regressions for:

  • DNS rebinding and mixed public/private address sets;
  • special-use IPv4/IPv6 and numeric host literals;
  • redirect refusal;
  • development-loopback registration/delivery consistency;
  • transport timeout and retry/replay boundaries;
  • historical incompatible-destination migration, idempotency, no-op read-only preflight, and rollback on audit failure;
  • authentication-before-body-read and bounded registration payload handling;
  • preservation of unrelated fetch/provider-call semantics; and
  • public API/static-route behavior while the bounded facade is present.

docs/doctoring/webhook-destination-security.md records active-PR versus protected-shipped truth, threat model, recovery/rollback semantics, executable acceptance, and primary standards references. CHANGELOG.md records the active Unreleased security work without claiming protected shipment or certification.

Fresh workflow truth

For exact contributor head bc60476829ec98f37d83977a9432b79ba0b3c0d6, the currently associated repository workflow runs are terminal GitHub-success:

  • SAST Semgrep 33115786583;
  • Security Scan 33115786568;
  • Dependency Review 33115786489;
  • Fuzz 33115786575;
  • Server Tests 33115786503;
  • OSV Scanner 33115787008.

These labels are not all promoted to immutable contributor-head merge authority while the known checkout-evidence defects remain unresolved. ScopeWeave #523 owns repository-native exact-head Server Tests/coverage integrity. The organization-required reusable exact-head SAST/Security repair remains owned by the central repository's existing control path. Synthetic/predecessor/status-only/model-only evidence is non-passing.

Merge boundary

Do not merge or enable auto-merge until the unchanged exact contributor head remains reconciled to live protected develop, #523's repository exact-head Server Tests/coverage control and any central reusable exact-head SAST/Security control are protected-shipped and regenerated on this exact head, every applicable functional/browser/owned statement-branch-function-line coverage/docstring/CodeQL/SAST/security/dependency/supply-chain/package/provenance gate is substantively terminal-passing, valid unresolved current-head defects are zero, and a qualifying independent current-head/last-push approval exists under live branch protection/rulesets.

Pending, queued, skipped-required, cancelled, absent, neutral-required, failed, stale, predecessor, synthetic-only, status-only, author-only, model-only, rate-limited, or infrastructure-only evidence is non-passing.

@coderabbitai

coderabbitai Bot commented Aug 23, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 0cff3517-05b6-46b3-9cbb-d2683258f702

📥 Commits

Reviewing files that changed from the base of the PR and between 8087838 and c321333.

📒 Files selected for processing (6)
  • package.json
  • server/app.mjs
  • server/webhook_transport.mjs
  • tests/api/webhook-destination-policy.test.mjs
  • tests/unit/toast-accessibility.test.mjs
  • tests/unit/webhook-transport-timeout.test.mjs

Included review availability: Your plan provides up to 1 included review per hour; 0 remain after this review.


📝 Walkthrough

Walkthrough

웹훅 등록과 전송에 공개 HTTPS, DNS/IP 검증, 주소 고정, 리디렉션 차단, 제한된 재시도를 적용했다. 기존 부적합 대상은 시작 시 비활성화하고 감사 로그를 기록한다. Stripe 웹훅은 원문 서명 검증 후 처리한다. 요청 스트림 정리와 전송 타임아웃 테스트도 추가했다.

Changes

웹훅 보안 및 SaaS API

Layer / File(s) Summary
SaaS 코어 API
server/app_core.mjs
인증, 멀티 테넌트 권한, 프로젝트, 협업, 결제, 첨부, 공유, SSO, 계정 수명주기 및 정적 리소스 라우트를 구현했다.
웹훅 전송 정책과 재시도
server/webhook_transport.mjs, tests/unit/webhook-transport.test.mjs, tests/unit/webhook-development-transport.test.mjs, tests/unit/webhook-transport-timeout.test.mjs
공개 주소만 허용하고 DNS 결과를 검증한다. 연결을 검증된 주소에 고정한다. 리디렉션과 연결 후 재전송을 차단한다. 기본 3초 타임아웃을 적용한다.
웹훅 등록과 요청 스트림 정리
server/app.mjs, tests/api/webhook-destination-policy.test.mjs, tests/api/webhook-fetch-contract.test.mjs, tests/unit/toast-accessibility.test.mjs
등록 URL을 검증·정규화한다. 인증 및 본문 크기 제한 오류 후 요청 스트림을 취소한다. 원래 전역 fetch 경계를 유지한다.
레거시 웹훅 대상 마이그레이션
server/db.mjs, server/webhook_legacy_migration.mjs, tests/api/webhook-legacy-migration.test.mjs, tests/unit/webhook-legacy-migration.test.mjs
시작 시 정책에 맞지 않는 활성 대상을 비활성화한다. 테넌트별 보안 감사 로그를 기록한다. 실패 시 변경과 감사 기록을 함께 롤백한다.
Stripe 웹훅 서명 검증
server/stripe_webhook.mjs, tests/api/stripe-webhook-security.test.mjs, tests/unit/stripe-webhook-boundary.test.mjs
원문 본문, HMAC-SHA256 서명, 타임스탬프와 이벤트 구조를 검증한다. 검증된 요청에만 수신 확인을 반환한다.
보안 문서와 검증 설정
docs/doctoring/*, CHANGELOG.md, package.json
SSRF 통제, 레거시 전환, 운영 경계와 참고문헌을 문서화했다. 웹훅 타임아웃 테스트를 실행 목록과 커버리지 대상에 추가했다.

Estimated code review effort: 5 (Critical) | ~90 minutes

Merge Risk: 🟠 High · up to c3213

The PR hardens outbound webhook delivery, but an existing payment-webhook path may still allow unauthorized plan upgrades when authentication and signature verification are absent; this is a high-impact security risk that should be fixed before merge. The legacy migration code also has a known lint error without exact-head clearance.

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant app_mjs
  participant app_core_mjs
  participant postWebhook
  participant WebhookTransport
  participant WebhookEndpoint
  Client->>app_mjs: 웹훅 등록 요청
  app_mjs->>app_core_mjs: 등록 요청 전달
  app_core_mjs->>postWebhook: 서명된 이벤트 전달
  postWebhook->>WebhookTransport: URL과 요청 데이터 전달
  WebhookTransport->>WebhookEndpoint: 검증된 주소로 POST
  WebhookEndpoint-->>WebhookTransport: HTTP 응답 반환
  WebhookTransport-->>app_core_mjs: 전달 결과 반환
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed Docstring check was indeterminate for this PR — some files could not be analyzed in time. Not blocking.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed 제목은 아웃바운드 웹훅의 SSRF 및 DNS 재바인딩 방지라는 변경의 핵심을 정확하고 간결하게 설명합니다.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/webhook-ssrf-551-develop

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@seonghobae
seonghobae marked this pull request as ready for review August 23, 2026 07:19
devin-ai-integration[bot]

This comment was marked as resolved.

devin-ai-integration[bot]

This comment was marked as resolved.

devin-ai-integration[bot]

This comment was marked as resolved.

devin-ai-integration[bot]

This comment was marked as resolved.

devin-ai-integration[bot]

This comment was marked as resolved.

@seonghobae
seonghobae marked this pull request as draft August 24, 2026 04:56
@seonghobae
seonghobae marked this pull request as ready for review August 24, 2026 05:10
devin-ai-integration[bot]

This comment was marked as resolved.

Copy link
Copy Markdown
Contributor Author

@coderabbitai review

Please review the freshly refetched exact contributor head bc60476829ec98f37d83977a9432b79ba0b3c0d6, especially the new read-only preflight in server/webhook_legacy_migration.mjs. Verify that it avoids the no-op SQLite writer reservation without weakening the existing fail-closed transactional migration, startup concurrency assumptions, audit atomicity, or SSRF destination-policy semantics. Treat predecessor-head findings as historical; report only issues reachable on this exact head.

@coderabbitai

coderabbitai Bot commented Aug 28, 2026

Copy link
Copy Markdown

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

expected='bc60476829ec98f37d83977a9432b79ba0b3c0d6'
actual="$(git rev-parse HEAD)"
printf 'HEAD=%s\nEXPECTED=%s\n' "$actual" "$expected"
if [ "$actual" != "$expected" ]; then
  echo 'ERROR: checked-out revision does not match the requested exact head.' >&2
  exit 2
fi

printf '\n== Exact-head commit ==\n'
git show -s --format='%H%n%P%n%s' HEAD

printf '\n== Changed files relative to first parent ==\n'
git diff-tree --no-commit-id --name-status -r HEAD

printf '\n== Module outlines ==\n'
ast-grep outline server/webhook_legacy_migration.mjs --items all
ast-grep outline server/db.mjs --items all
ast-grep outline server/webhook_transport.mjs --items all

printf '\n== Migration diff at exact head ==\n'
git diff HEAD^ HEAD -- server/webhook_legacy_migration.mjs tests/unit/webhook-legacy-migration.test.mjs tests/api/webhook-legacy-migration.test.mjs

printf '\n== Migration source ==\n'
cat -n server/webhook_legacy_migration.mjs

printf '\n== Relevant DB startup and policy call sites ==\n'
rg -n -C 5 -P '\bmigrateLegacyWebhookDestinations\s*\(|\bvalidateWebhookRegistrationUrl\s*\(' \
  server/db.mjs server/webhook_legacy_migration.mjs server/webhook_transport.mjs \
  tests/unit/webhook-legacy-migration.test.mjs tests/api/webhook-legacy-migration.test.mjs

printf '\n== Focused migration tests ==\n'
cat -n tests/unit/webhook-legacy-migration.test.mjs
cat -n tests/api/webhook-legacy-migration.test.mjs

Length of output: 33185


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '== Pull-request range and changed files ==\n'
base="$(git merge-base HEAD origin/develop 2>/dev/null || git merge-base HEAD develop)"
printf 'BASE=%s\nHEAD=%s\n' "$base" "$(git rev-parse HEAD)"
git diff --stat "$base"..HEAD
git diff --name-status "$base"..HEAD

printf '\n== Registration policy implementation ==\n'
sed -n '72,225p' server/webhook_transport.mjs

printf '\n== Migration evolution across this PR ==\n'
git log --format='%H %s' --reverse "$base"..HEAD -- server/webhook_legacy_migration.mjs
git diff "$base"..HEAD -- server/webhook_legacy_migration.mjs

printf '\n== All migration callers and webhook table mutation statements ==\n'
rg -n -C 5 -P \
  '\bmigrateLegacyWebhookDestinations\s*\(|\b(?:INSERT\s+INTO|UPDATE|DELETE\s+FROM)\s+webhooks\b|\.insert\([^)]*webhooks|\.update\([^)]*webhooks' \
  server tests

printf '\n== Preflight-related tests and SQLite transaction use ==\n'
rg -n -C 4 -P \
  '\b(activeWebhookDestinations|hasPolicyIncompatibleDestination|BEGIN IMMEDIATE|busy_timeout|contend|concurr|preflight)\b' \
  server tests

printf '\n== Package test commands that include migration coverage ==\n'
node -e "const p=require('./package.json'); console.log(JSON.stringify({scripts:p.scripts}, null, 2))" 2>/dev/null || \
  sed -n '/\"scripts\"[[:space:]]*:/,/^[[:space:]]*},/p' package.json

Length of output: 36406


🏁 Script executed:

#!/bin/bash
set -euo pipefail

repo='ContextualWisdomLab/scopeweave'
pr='588'
sha='bc60476829ec98f37d83977a9432b79ba0b3c0d6'

printf '== Pull request head and mergeability ==\n'
gh pr view "$pr" --repo "$repo" \
  --json number,headRefOid,baseRefOid,mergeStateStatus,isDraft,reviewDecision,statusCheckRollup \
  --jq '{
    number,
    headRefOid,
    baseRefOid,
    mergeStateStatus,
    isDraft,
    reviewDecision,
    statusCheckRollup: [.statusCheckRollup[] | {
      name,
      status,
      conclusion,
      detailsUrl,
      headSha
    }]
  }'

printf '\n== Check runs attached to the requested exact commit ==\n'
gh api "repos/$repo/commits/$sha/check-runs?per_page=100" \
  --jq '.check_runs[] | {
    name,
    status,
    conclusion,
    started_at,
    completed_at,
    details_url
  }'

printf '\n== Registration route policy boundary ==\n'
sed -n '710,770p' server/app_core.mjs

printf '\n== Database startup context ==\n'
sed -n '145,186p' server/db.mjs

printf '\n== Latest exact-head patch ==\n'
git diff --check HEAD^ HEAD
git diff --word-diff=plain HEAD^ HEAD -- server/webhook_legacy_migration.mjs

Length of output: 25547


🏁 Script executed:

#!/bin/bash
set -euo pipefail

repo='ContextualWisdomLab/scopeweave'
run='33115785070'
job='98670001775'

printf '== Failed job details ==\n'
gh api "repos/$repo/actions/jobs/$job" \
  --jq '{id,name,status,conclusion,started_at,completed_at,html_url,steps:[.steps[]|{name,status,conclusion,number}]}'

printf '\n== Failed job log ==\n'
gh run view "$run" --repo "$repo" --job "$job" --log-failed

printf '\n== Required status checks on develop ==\n'
gh api "repos/$repo/branches/develop/protection/required_status_checks" \
  --jq '{strict,contexts,checks}' || true

printf '\n== Workflow run event and commit association ==\n'
gh api "repos/$repo/actions/runs/$run" \
  --jq '{event,head_sha,head_branch,conclusion,status,workflow_id,html_url}'

Length of output: 4867


🏁 Script executed:

#!/bin/bash
set -euo pipefail

repo='ContextualWisdomLab/scopeweave'
run='33115785070'
job='98670001775'
archive="$(mktemp /tmp/strix-job-log.XXXXXX.zip)"
extract="$(mktemp -d /tmp/strix-job-log.XXXXXX)"
trap 'rm -f "$archive"; rm -rf "$extract"' EXIT

printf '== Download and inspect Strix job log archive ==\n'
gh api --follow "repos/$repo/actions/jobs/$job/logs" > "$archive"
file "$archive"
unzip -q "$archive" -d "$extract"
fd -t f . "$extract" -x sh -c '
  printf "\n--- %s ---\n" "$1"
  rg -n -i -C 8 "(error|fail|exception|traceback|finding|critical|high|medium|low)" "$1" || true
' sh {}

printf '\n== Required status checks on develop ==\n'
gh api "repos/$repo/branches/develop/protection/required_status_checks" \
  --jq '{strict,contexts,checks}' || true

printf '\n== Workflow run event and commit association ==\n'
gh api "repos/$repo/actions/runs/$run" \
  --jq '{event,head_sha,head_branch,conclusion,status,workflow_id,html_url}'

Length of output: 2838


🏁 Script executed:

#!/bin/bash
set -u -o pipefail

repo='ContextualWisdomLab/scopeweave'
run='33115785070'
job='98670001775'
archive="$(mktemp /tmp/strix-job-log.XXXXXX)"
extract="$(mktemp -d /tmp/strix-job-log.XXXXXX)"
trap 'rm -f "$archive"; rm -rf "$extract"' EXIT

printf '== Direct Strix job log request ==\n'
if gh api "repos/$repo/actions/jobs/$job/logs" > "$archive"; then
  file "$archive"
  if unzip -tqq "$archive"; then
    unzip -q "$archive" -d "$extract"
    fd -t f . "$extract" -x sh -c '
      printf "\n--- %s ---\n" "$1"
      rg -n -i -C 8 "(error|fail|exception|traceback|finding|critical|high|medium|low)" "$1" || true
    ' sh {}
  else
    printf '%s\n' 'The log response is not a ZIP archive.'
    head -c 2000 "$archive" || true
  fi
else
  printf '%s\n' 'The job-log endpoint could not be retrieved.'
fi

printf '\n== Required status checks on develop ==\n'
gh api "repos/$repo/branches/develop/protection/required_status_checks" \
  --jq '{strict,contexts,checks}' || true

printf '\n== Workflow run event and commit association ==\n'
gh api "repos/$repo/actions/runs/$run" \
  --jq '{event,head_sha,head_branch,conclusion,status,workflow_id,html_url}' || true

Length of output: 2093


@seonghobae, I completed the review for exact head bc60476829ec98f37d83977a9432b79ba0b3c0d6.

I found no reachable code issue in the read-only preflight.

  • The preflight performs only a SELECT of active webhook id, org_id, and url. It does not reserve the SQLite writer.
  • It uses the same synchronous validateWebhookRegistrationUrl policy as the transactional migration. It does not perform DNS or other network I/O.
  • If the preflight finds an incompatible destination, the migration still acquires BEGIN IMMEDIATE and re-reads active rows before any mutation.
  • The webhook disable and its audit event remain in the same transaction. An audit failure still rolls back the webhook update.
  • A stale preflight result is safe. If another startup process changes the rows before this process acquires the writer reservation, the transactional re-read uses the current state.
  • New webhook registration validates and canonicalizes the URL before the database insert. This preserves the destination-policy boundary.

The exact-head unit-and-api, fuzz, CodeQL, Semgrep, and dependency checks succeeded.

One validation item remains: exact-head strix failed in Run Strix (quick). The job log and protected-branch required-check configuration were unavailable to this integration. Therefore, I cannot confirm that all merge gates are satisfied.

You are interacting with an AI system.

Copy link
Copy Markdown
Contributor Author

@opencode-agent review

Review-only request for exact head bc60476829ec98f37d83977a9432b79ba0b3c0d6 against protected develop@2c328875e00e86537df3e965170be80532571cad. Bind any verdict to that exact head and live base. Re-verify outbound webhook registration/delivery SSRF and DNS-rebinding behavior, the resolved review-thread set, and the intentional separation from Stripe inbound-webhook scope. Do not treat predecessor-head, model-only, or status-only evidence as current approval.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working priority: high High-priority or P1 work

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant