Closes #41 Implemented SSRF prevention - #58
Merged
Conversation
…prevent-ssrf # Conflicts: # src/app/api/purchase/route.js # src/lib/mongodb.js # src/lib/stellar/checkoutService.js
p3ris0n
force-pushed
the
feat/prevent-ssrf
branch
from
July 21, 2026 20:49
cefeeac to
6143515
Compare
Contributor
|
Very nice contribution |
This was referenced Jul 22, 2026
barry01-hash
pushed a commit
that referenced
this pull request
Jul 25, 2026
Prerequisite for #101: required status checks cannot be turned on while the backend suite is red, and it was red in 8 places. Five of those were one bug. `getStorage()` resolved AsyncLocalStorage via `eval('require("async_hooks")')`, which only exists in CommonJS and webpack bundles. Under native ESM `require` throws ReferenceError, so the catch dropped the server into the browser fallback: a no-op store whose `getStore()` always returns null. Every ESM entry point — the indexer runner, the workflow worker, anything under `node --test` — was therefore losing correlation IDs and traceparents silently while still appearing to work. Resolution now tries `process.getBuiltinModule("node:async_hooks")` first, which is synchronous, invisible to webpack's static analysis, and works under both module systems. The import has to stay dynamic because this module is reachable from client bundles via checkoutService -> CheckoutInvoice -> CartDrawer. Degrading to no-op storage on a server is now recorded rather than assumed harmless, and exposed as `isContextDegraded()` so the condition is observable instead of silent. `validateGatewayUrl` returned null instead of throwing for empty input, because `normalizeExternalUrl` returns null rather than throwing there. A missing gateway URL passed straight through the validator and reached callers as null where they expect a verified string. The remaining two were stale tests, not product bugs. The rate limiter became Redis-backed and async in #58; its test still called it synchronously and compared `undefined` to `true`. Rewritten against the current contract, covering the security-relevant half that is testable without Redis: whether an outage fails open or closed. The material validation fixture used `example.com`, which the SSRF hardening in #58 correctly rejects; switched to an allowlisted host. Backend suite: 416 pass, 0 fail (was 405/8).
Obiajulu-gif
added a commit
to Obiajulu-gif/eduvault
that referenced
this pull request
Jul 30, 2026
…d test environment isolation (Eduvault-stellar#103) (#1) * feat: authenticated streaming delivery without exposing permanent IPFS locations (Eduvault-stellar#3) Implements a three-layer authenticated delivery system: 1. Token Service (src/lib/delivery/token.js) - HMAC-SHA256 signed tokens bound to buyer + material + expiry - Optional single-use nonce for replay protection - Optional IP binding for additional security - Timing-safe signature verification 2. Streaming Proxy (src/lib/delivery/stream.js) - ReadableStream-based proxy with backpressure support - RFC 7233 range requests for partial content/resume - Upstream timeout handling (no corrupt partial responses) - Client disconnect detection via AbortSignal - 5GB file size limit enforcement - Private gateway URL support 3. Audit Service (src/lib/delivery/audit.js) - Safe-field filtering (no secrets logged) - MongoDB persistence + stdout logging - Queryable audit records with material/buyer filters - Delivery statistics aggregation API endpoints: - POST /api/delivery/token - Issue short-lived delivery token - GET /api/delivery/stream - Authenticated streaming proxy - GET /api/download - Refactored to return token instead of CID Security improvements: - CIDs never exposed to clients - Cache-Control: private, no-store on all delivery responses - Tokens expire after 15 minutes (configurable, max 1 hour) - Cross-user cache isolation - Deployment guide for private IPFS gateway Tests: 42 passing covering token issuance/verification, range requests, file size validation, audit logging, cache isolation, multi-GB simulation, client disconnect, and replay protection. * Closes Eduvault-stellar#41 Implemented SSRF prevention * chore: fix CI Lint & Build Backend * chore: resolve CI fixes * fix: resolve CI workflow issues across lint, tests, and build Fixes applied: - Add missing closing brace in checkout initiate route - Remove duplicate return statements in upload route from bad merge - Fix missing closing brace in horizonClient getDynamicBaseFee - Fix duplicate imports/variables and malformed JSX in UploadWizard - Remove useEffect calling setState synchronously in CreatorProfileSettings - Reorder validateSplits before useEffect in PayoutSplits - Fix apostrophe escaping in LearningProgress and verification page - Fix Horizon.Server mock to use function constructor for vitest - Replace node:crypto with browser-safe random generator in context.js - Lazy-load AsyncLocalStorage to avoid webpack bundling issues - Add @opentelemetry/api mock alias for vitest - Add webpack fallbacks for node modules in next.config.mjs * fix(accessibility): meet WCAG 2.2 AA across critical wallet and marketplace flows - Add skip-to-content link and proper landmark regions - Fix focus trap with restoration, visible focus, and no traps - Add correct roles, names, relationships, and live announcements to toasts - Add keyboard operability to modals, drawers, and dropdowns with Escape behavior - Add aria-labels, aria-expanded, aria-selected to interactive elements - Add reduced-motion and forced-colors support in globals.css - Add drag-drop keyboard accessibility and upload progress announcements - Add automated axe accessibility tests for critical components Closes Eduvault-stellar#47 * fix: resolve failing tests * chore: fix CI soroban check * chore: reimplemented CI fixes * fix: make indexer projections lossless * feat: add resumable upload sessions * chore: Fix Lint and Build (Back & Frontend) CI Issues * chore: fixed backend telemetry CI fail * chore: Fix Backend: Lint and Build CI2 * Fixed: Backend CI * feat(indexer): decode versioned Soroban events * fix(auth): bind access checks to session wallet * fix(purchases): verify finalized on-chain settlement * test(soroban): exercise purchase flow with real SAC * feat(indexer): add resumable ledger reconciliation * feat(security): use distributed atomic rate limits * feat(security): quarantine uploads before publication * feat: add observability audit logging for issue 63 * fix: bind checkout intents to payment terms (Eduvault-stellar#35) * fix: address ci regressions * fix: stabilize ci test harness * fix: clear remaining ci failures * feat(accounting): immutable double-entry ledger with Stellar reconciliation Introduce an append-only accounting subsystem under src/lib/ledger that proves money-related views reconcile against Stellar settlement and the operational collections, so reprocessing events or changing business rules can no longer silently alter balances, duplicate revenue, or produce creator statements that disagree with the chain. Core: - money.js: asset-aware integer stroop amounts (BigInt), decimal parsing with precision limits; floating-point arithmetic is prohibited. - accounts.js / journal.js: chart of accounts, balanced-per-asset validation, immutable frozen transactions, idempotency keyed by (network, txHash, opIndex, eventType), and reversal/adjustment construction. - postingRules.js: versioned posting rules for purchases (net-fee and platform-funded-discount) and proportional refunds; rule version stored on each transaction so policy changes never re-interpret history. - balances.js: creator earnings, platform revenue, refund liabilities, and available/pending balances derived from the ledger, not mutable counters. Operations: - reconciliation.js: match journal vs finalized Stellar operations and the purchase collection, classify discrepancies, and return idempotent replay candidates. - backfill.js: checkpointed, dry-runnable backfill of legacy purchases with explicit ambiguous-row handling. - period.js: reproducible period-close snapshots (hashed) with late-event detection so closed periods cannot silently change. - repository/{memory,mongo}.js: append-only repositories with a unique idempotency index; service.js exposes record{Purchase,Refund,Reversal}. Tests (39, all passing): property-based invariants via fast-check (balancing, precision, value conservation across arbitrary partial refunds) plus scenario coverage for duplicates/out-of-order, multi-asset, reversal chains, concurrent posting, reconciliation classes, and backfill restart. Docs: docs/accounting/LEDGER.md. Closes Eduvault-stellar#28 * chore: fix conflicts * critical-wallet-and-marketplace-flows * chore: fixing backend CI2 * chore: fixing backend CI2 * ci: add unified quality workflow * Harden browser security and wallet signing * fix(indexer): harden Stellar event indexer for replay and resume (Eduvault-stellar#83) The repository already had an indexer (Eduvault-stellar#6, Eduvault-stellar#7, Eduvault-stellar#122, #384); what it lacked was working failure handling. This is a hardening pass over the existing `src/lib/indexer/` rather than a rewrite. indexer_ledger_lag was a constant. The expression `Math.max(0, latestLedger - previousLedger === 0 ? 0 : 0)` evaluates to 0 on both branches of the ternary, so IndexerLedgerLagHigh (critical, fires above 50) could never fire. Lag is now the real distance from the chain tip, which needs no extra RPC call: getEvents already returns `latestLedger`, and that is now tracked separately from the highest ledger actually applied. A short page reports 0, so a quiet chain does not alert. Recovery could grant entitlements for a material that does not exist. `operationToEvent` never set materialId but still emitted purchase.completed, so the projection upserted purchases and entitlement_cache on `{materialId: undefined, buyerAddress}`. The purchases unique index has a `$type: "string"` partial filter and skipped these, but entitlements_buyer_material_unique does not, so every recovered payment for a buyer collapsed onto one null-material row marked active. Recovery now resolves materialId from the owning purchase and reports unmatchable payments as orphans instead of writing them. Two related fixes: only inbound payments are considered (forAccount returns both directions, so an outbound refund became a purchase with the platform account as buyer), and the "already indexed?" query now checks both `transactionHash` (written by the checkout path) and `chainTxHash` (written by the indexer) rather than only the latter, which had made every app-path purchase look missing. Dead-letter accounting ran backwards. The retry increment sat on the `skipped` branch, which means the event had already applied successfully, and drove healthy replays toward the terminal `failed` state while the cleanup delete was gated on `!skipped` and never ran. Both outcomes are now treated as successes that retire the row. Unidentifiable events get a content-hashed dead-letter id instead of Math.random(), which had written a fresh row per attempt so retryCount never reached the ceiling. reprocessDeadLetters no longer sweeps terminal rows by default and records each attempt. The indexer did not run locally as documented. Each event applies inside a transaction, which Mongo only permits on a replica set, but docker-compose ran a standalone mongod, so the first event threw. Compose now starts a single-node replica set, and the indexer detects an unsupported-transaction error once per process and falls back to non-transactional writes rather than dead-lettering everything on a developer machine. `npm run indexer:stellar` is now an actual service with a poll loop, exponential backoff on RPC failure, and SIGINT/SIGTERM shutdown; `:once` preserves the previous single-batch behaviour for cron and rebuilds. Tests could not observe any of the above. The existing double keys documents as `_id || materialId:buyerAddress` and models no constraints, so E11000 is unrepresentable and a mid-batch crash unsimulatable. Adds a fake Mongo that enforces the real unique indexes from schemaContracts and injects write faults, plus coverage for checkpoint resume and event replay (AC4). Both the lag and dead-letter fixes were verified to fail against the old code. Not addressed here: chain reorganisation handling. It depends on whether entitlement_cache is authoritative or derived, and `createEntitlement` currently writes it straight from the purchase API, which makes it both. Raised on the issue. * feat: add Stellar/Soroban entitlement verification & gated download access - Implement buildHasEntitlementXdr for Soroban simulation checks - Ensure RPC failures fail open/gracefully to cached/DB entitlements - Add integration test suite for Soroban entitlement gate Closes Eduvault-stellar#85 * chore: update package-lock.json * feat: GDPR-compliant data export and staged account deletion - Add personal data inventory with retention rules (retentionPolicy.js) - Build authenticated data export service with versioned JSON manifest, 48h expiry, and capability token for private download URL - Implement 6-state deletion machine: pending_reauth → cooling_off → executing → completed (with cancel and fail/retry paths) - Add obligation checker blocking deletion on in-flight purchases or unsettled ledger credits - Anonymize retained financial/audit records (purchases, entitlement_cache, materials, reviews) preserving referential integrity - Execute deletion in 7 idempotent steps with per-step progress recording - Unpin Pinata/IPFS objects (avatar always; materials only if no active buyers) - Add Privacy & Data section to /dashboard/settings with export and deletion panels covering scope, timing, and irreversible effects - 45 backend tests across 6 suites (all pass) - Document retention rules, backup/analytics coverage in docs/privacy-data-retention.md API routes: POST/GET /api/privacy/export GET /api/privacy/export/download POST/GET /api/privacy/deletion POST /api/privacy/deletion/cancel * Closes Eduvault-stellar#84 Moving from ERC-721 prototype to native Soroban * fix(auth): harden profile/materials API auth, wallet normalization, and validation * chore: fixing failing Lint CI * fix(observability): restore context propagation broken under native ESM Prerequisite for Eduvault-stellar#101: required status checks cannot be turned on while the backend suite is red, and it was red in 8 places. Five of those were one bug. `getStorage()` resolved AsyncLocalStorage via `eval('require("async_hooks")')`, which only exists in CommonJS and webpack bundles. Under native ESM `require` throws ReferenceError, so the catch dropped the server into the browser fallback: a no-op store whose `getStore()` always returns null. Every ESM entry point — the indexer runner, the workflow worker, anything under `node --test` — was therefore losing correlation IDs and traceparents silently while still appearing to work. Resolution now tries `process.getBuiltinModule("node:async_hooks")` first, which is synchronous, invisible to webpack's static analysis, and works under both module systems. The import has to stay dynamic because this module is reachable from client bundles via checkoutService -> CheckoutInvoice -> CartDrawer. Degrading to no-op storage on a server is now recorded rather than assumed harmless, and exposed as `isContextDegraded()` so the condition is observable instead of silent. `validateGatewayUrl` returned null instead of throwing for empty input, because `normalizeExternalUrl` returns null rather than throwing there. A missing gateway URL passed straight through the validator and reached callers as null where they expect a verified string. The remaining two were stale tests, not product bugs. The rate limiter became Redis-backed and async in Eduvault-stellar#58; its test still called it synchronously and compared `undefined` to `true`. Rewritten against the current contract, covering the security-relevant half that is testable without Redis: whether an outage fails open or closed. The material validation fixture used `example.com`, which the SSRF hardening in Eduvault-stellar#58 correctly rejects; switched to an allowlisted host. Backend suite: 416 pass, 0 fail (was 405/8). * chore: fixing failing Lint CI * chore: fixing failing Lint CI * ci(Eduvault-stellar#101): establish enforceable quality gates and preview smoke tests Builds out the CI gates issue Eduvault-stellar#101 asks for. The issue's premise — that there is no workflow enforcing quality — is out of date (ci.yml landed the day before it was filed), so this hardens what exists rather than starting over, and fixes the things that made the existing setup unenforceable. Lockfile integrity. Every workflow ran `npm install`, which silently rewrites package-lock.json rather than failing on drift — and the lockfile WAS drifted: `npm ci` failed from a clean clone with 8 packages missing from the lock. CI now runs `npm ci`, and `scripts/check-lockfile.mjs` fails the build on drift or on a competing lockfile reappearing. Regenerated package-lock.json so it satisfies package.json. Declared npm as the one supported package manager (`packageManager` field) and removed the stale bun.lock and pnpm-lock.yaml, both last touched months ago, which otherwise made the installed tree depend on which tool you happened to run. Least privilege and concurrency. Only ci.yml declared `permissions:` or `concurrency:`; the other six inherited the repo default and could not cancel superseded runs. All eight workflows now declare `contents: read` and a concurrency group. The previous `cancel-in-progress: true` was unconditional and would cancel runs on the default branch too; it is now guarded so main and develop always complete and record a status. The scheduled backup never cancels mid-run. Duplicate triggers. ci.yml triggered on bare `push:` plus `pull_request:`, so every in-repo PR ran the whole suite twice. Push is now scoped to main and develop. Migration and integration coverage. `test:migrations` and `test:integration` both existed and neither ran in CI. A new migrations job runs the migrations forward against a MongoDB service container; integration tests run in the main job. License policy. `scripts/check-licenses.mjs` fails on newly introduced GPL/AGPL/SSPL/unlicensed production dependencies. Two pre-existing transitive offenders (@lobstrco/signer-extension-api GPL-3.0, ua-parser-js AGPL-3.0) are allowlisted with annotations and flagged for maintainer legal review, so the gate does not block on state that predates it while still catching new ones. Type-check. `npm run typecheck` runs checkJs but stays advisory (non-blocking): the JSDoc-annotated JS produces ~3000 findings, almost all missing annotations, so a blocking gate would wall off every merge. Kept visible to be driven down in its own pass. Preview smoke tests. scripts/smoke-preview.mjs plus preview-smoke.yml verify the three required paths against a deployed preview — landing renders, the auth boundary returns 401/403 unauthenticated, /api/health is live — triggered by deployment_status or manually, using no secrets. Also fixed 3 pre-existing lint errors: scripts/backup.mjs, create-backup-manifest.mjs and restore.mjs each had a blank line before their shebang, putting `#!` on line 2 where it is a parse error, so `npm run lint` was already red on main. docs/ci-and-quality-gates.md documents the gates, the fork-safety model, the recommended required checks, and the emergency-bypass procedure. * feat(materials): model and enforce the material lifecycle as an explicit state machine Replaces the ad hoc "publish sets status" write path with a centralized domain service (src/lib/materials/materialLifecycle.js) that governs every material status change: draft -> published -> closed/canceled. - Defines the allowed transition graph and rejects any other (from, to) pair with a typed, non-mutating 409. - Centralizes transition preconditions (publishing checklist, caller ownership/role, no confirmed purchases before canceling a published listing) so every write path shares the same rules. - Generic material updates (PUT /api/materials) now explicitly reject a status field — status can only change through the publish/close/cancel routes. - Persists an immutable material_status_history record (actor, previous status, next status, reason, timestamp) per transition. - Guards every transition with findOneAndUpdate against the expected current status so concurrent requests can't double-apply a transition; proven with a race test against a real in-memory store. - Repeating the current status is idempotent (no-op, no history write). - Backfills status on legacy materials via migration 004 without changing their effective status. - Updates status badges and the creator dashboard to show only the actions valid from the material's current state. - Adds docs/material-lifecycle.md with the state diagram and transition table. Closes Eduvault-stellar#111 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * Closes Eduvault-stellar#108 Implemented a durable Stellar event indexer for escrow projections * server-side-authorization * server-side-authorization * server-side-authorization * fix(test): implement integration test suite fixes for issue Eduvault-stellar#103 --------- Co-authored-by: gelluisaac <isaacgellu6@gmail.com> Co-authored-by: Promise Raji <isaacsamson88@gmail.com> Co-authored-by: Netty-kun <182667336+Netty-kun@users.noreply.github.com> Co-authored-by: bigdella <bigdella@users.noreply.github.com> Co-authored-by: prissca <nwoguvictoriachiamaka@gmail.com> Co-authored-by: Obiajulu-gif <okoyeemmanuel998@gmail.com> Co-authored-by: Henry Ebubechukwu <henryebube62@gmail.com> Co-authored-by: Pri_ss_ca <136065253+prissca@users.noreply.github.com> Co-authored-by: Ayinkx <142127428+Ayinkx@users.noreply.github.com> Co-authored-by: Victor Edeh <edehvictor715@gmail.com> Co-authored-by: spagero763 <afolabiayomide870@gmail.com> Co-authored-by: Bamzy123 <stephenomotos@gmail.com> Co-authored-by: Segun Akinola <105325916+Primex-hub@users.noreply.github.com> Co-authored-by: 0takuc0mrade <junep059@gmail.com> Co-authored-by: storm-beyndtech <khameleonstorm@gmail.com> Co-authored-by: soomtochukwu <onwuajuesesomtochukwu@gmail.com> Co-authored-by: JClark011 <joelclar700@gmail.com> Co-authored-by: DevNetlife <DevNetlife@users.noreply.github.com> Co-authored-by: christopherdominic <chriseze0@gmail.com> Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Harden Webhook Registration and Delivery
Closes #41
Overview
This PR implements critical operational safety, security, and observability features for outbound webhook delivery across the EduVault marketplace. Previously, webhooks were sent in-memory without validation of the destination URL or payload signatures. This PR hardens the egress path to strictly prevent SSRF and DNS rebinding attacks, introduces versioned HMAC payload signatures with overlapping key rotation, and implements a resilient background delivery system with exponential backoff and dead-lettering.
Key Changes
1. SSRF Protection and Strict Egress Controls
File:
src/lib/webhooks/dispatcher.jsfetchwrapper with a highly restrictive customdispatcher.js.https://protocol and standard secure ports (443, 8443).10.x,192.168.x), IPv6 local scopes, loopbacks, and reserved subnets to thwart SSRF.Hostand SNI headers to guarantee the target IP doesn't shift between resolution and connection (TOCTOU attacks).2. Versioned HMAC Signatures & Rotating Secrets
File:
src/lib/webhooks/signature.jsid,type,createdtimestamp, anddata.v1SHA-256 HMAC signature using the raw payload and a timestamp, appended to theEduvault-Signatureheader (e.g.,t=...,v1=...).crypto.timingSafeEqualto avoid timing attacks.3. Resilient Background Delivery & Retries
Files:
src/lib/backend/schemaContracts.js,src/lib/webhooks/sender.js,src/lib/backend/webhookWorker.jswebhooksandwebhook_deliveriescollections to the database.webhook_deliveriescollection aspending.webhookWorker.jsto poll and process pending deliveries.dead_letterafter 5 failed attempts.4. User-Facing Webhook Management APIs
Files:
src/app/api/webhooks/...GET /api/webhooks: Lists registered endpoints with redacted secrets.POST /api/webhooks: Registers a new endpoint, revealing the secret only once.DELETE /api/webhooks/[id]: Soft deletes/disables a webhook.POST /api/webhooks/[id]/rotate: Facilitates overlapping key rotation by expiring the old secret in 24 hours while generating a new primary secret.GET /api/webhooks/[id]/deliveries: Inspect deliveries, attempts, and error reasons for troubleshooting.POST /api/webhooks/[id]/deliveries/[deliveryId]/replay: Manually replay an event without mutating the original payload.Security Considerations
Testing
tests/backend/webhooks.test.mjs.127.0.0.1,169.254.x.x,::1,::ffff:127.0.0.1, etc.).Breaking Changes
webhookUrlsstring array on theuserscollection is deprecated. A seamless JIT migration path was introduced insender.jsto migrate active legacy URLs into the newwebhookscollection on-the-fly when events are broadcast.Closes #<WEBHOOK_ISSUE_NUMBER>