diff --git a/backend/SEP0001_GENERATOR_GUIDE.md b/backend/SEP0001_GENERATOR_GUIDE.md index 42407dd..12fb370 100644 --- a/backend/SEP0001_GENERATOR_GUIDE.md +++ b/backend/SEP0001_GENERATOR_GUIDE.md @@ -36,7 +36,7 @@ curl https://api.example.com/.well-known/stellar.toml?merchant_id=550e8400-e29b- NETWORK_PASSPHRASE = "Test SDF Network ; September 2015" TRANSFER_SERVER = "https://api.example.com/api" DOCUMENTATION = "https://api.example.com/api-docs" -ACCOUNTS = ["GBUQWP3BOUZX34ULNQG23RQ6F4YUSXHTQSXUSMIQSTBE2BRUY4DQAT2B"] +ACCOUNTS = ["GDVEU3DD4KOFECV66VIHWEZOYX4ZKR3WV27L464SIIPOU2IUI3JCZA57"] [ORG] name = "Acme Payment Services" @@ -56,7 +56,7 @@ The stellar.toml is generated from: - `API_BASE_URL`: Base URL of the API (default: "http://localhost:4000") - `FEDERATION_SERVER_URL`: Optional federation server - `DOCS_URL`: Documentation URL (default: `{API_BASE_URL}/api-docs`) - - `SIGNING_KEY`: Optional signing key for transactions + - `SIGNING_KEY`: Optional **public** signing key (`G...`). A secret seed (`S...`) is never published. 2. **Merchant Settings** (from database): - `business_name`: Organization name @@ -79,7 +79,7 @@ API_BASE_URL="https://api.example.com" TRANSFER_SERVER_URL="https://api.example.com/api" FEDERATION_SERVER_URL="https://federation.example.com" DOCS_URL="https://docs.example.com" -SIGNING_KEY="GBUQWP3BOUZX34ULNQG23RQ6F4YUSXHTQSXUSMIQSTBE2BRUY4DQAT2B" +SIGNING_KEY="GDVEU3DD4KOFECV66VIHWEZOYX4ZKR3WV27L464SIIPOU2IUI3JCZA57" ``` ## Merchant Configuration @@ -92,7 +92,7 @@ Merchants can configure their stellar.toml information through: - `notification_email`: Support email 2. **Stellar Account** (set during registration): - - `recipient`: Your Stellar account address (e.g., `GBUQWP3BOUZX34ULNQG23RQ6F4YUSXHTQSXUSMIQSTBE2BRUY4DQAT2B`) + - `recipient`: Your Stellar account address (e.g., `GDVEU3DD4KOFECV66VIHWEZOYX4ZKR3WV27L464SIIPOU2IUI3JCZA57`) 3. **Branding Configuration** (via merchant settings): - `branding_config.homepage`: Your website URL @@ -129,13 +129,13 @@ curl -X POST https://api.example.com/api/merchant-branding \ - Example: `"https://federation.example.com"` - **ACCOUNTS**: List of Stellar accounts operated by the merchant - - Example: `["GBUQWP3BOUZX34ULNQG23RQ6F4YUSXHTQSXUSMIQSTBE2BRUY4DQAT2B"]` + - Example: `["GDVEU3DD4KOFECV66VIHWEZOYX4ZKR3WV27L464SIIPOU2IUI3JCZA57"]` - **DOCUMENTATION**: URL to API documentation - Example: `"https://api.example.com/api-docs"` - **SIGNING_KEY**: Public key for transaction signing - - Example: `"GBUQWP3BOUZX34ULNQG23RQ6F4YUSXHTQSXUSMIQSTBE2BRUY4DQAT2B"` + - Example: `"GDVEU3DD4KOFECV66VIHWEZOYX4ZKR3WV27L464SIIPOU2IUI3JCZA57"` ### Organization Section @@ -151,10 +151,64 @@ The `[ORG]` section contains merchant information: The stellar.toml is validated to ensure: -1. **Required fields present**: NETWORK_PASSPHRASE and TRANSFER_SERVER +1. **Required fields present**: NETWORK_PASSPHRASE and TRANSFER_SERVER, each at the start of a line 2. **Valid TOML syntax**: Proper formatting and escaping 3. **Valid URLs**: Properly formatted URLs 4. **Valid Stellar addresses**: Properly formatted account addresses +5. **Size**: the document is at most 100 KB (the SEP-0001 limit) + +### Payload sanitization and strict validation (issue #1457) + +Every value comes from the merchant record or server environment, and the +generator treats all of it as untrusted. + +| Input | Rule | If invalid | +|---|---|---| +| `merchant_id` query param | Single value, must be a UUID | `400`. The database is not queried. | +| `business_name` | Bidi override characters (U+202A–202E, U+2066–2069, U+200E/F) stripped, trimmed, max 256 characters | Falls back to `Stellar Payment Merchant` when empty or not a string | +| All string values | TOML-escaped, including `\r`, `\t`, and every other control character (`\uXXXX`) | A value can never close its quotes and inject keys or `[TABLES]` | +| `recipient` → `ACCOUNTS` | Valid Stellar public key, StrKey checksum included | `ACCOUNTS` omitted | +| `SIGNING_KEY` | Valid Stellar **public** key | Omitted. A secret seed is never published and is logged as an error (without the value). | +| `email`, `notification_email` | Email syntax, max 254 characters | Field omitted | +| `branding_config.homepage` / `logo_url` | `https` URL, no credentials, no whitespace, max 2048 characters | Field omitted | +| `TRANSFER_SERVER_URL` / `API_BASE_URL` | `https` URL. `http` is allowed only for `localhost`/`127.0.0.1`. No credentials. | `500 Failed to generate valid stellar.toml`. Details are logged, never returned. | +| `FEDERATION_SERVER_URL`, `DOCS_URL` | Same URL rules | Field omitted | +| `STELLAR_NETWORK_PASSPHRASE` | Printable, max 128 characters | `500`, as above | + +Omitted or altered fields are logged (`SEP-0001: fields omitted or altered by +sanitization`) and counted in `sep0001_rejected_fields_total{field,reason}`. + +Responses also send `Access-Control-Allow-Origin: *`, which SEP-0001 requires +so browser wallets can read the file, and `X-Content-Type-Options: nosniff`. + +## Monitoring (issue #1458) + +Metrics (merged into `GET /metrics`): + +| Metric | Labels | Meaning | +|---|---|---| +| `sep0001_requests_total` | `outcome` = `success` / `invalid_request` / `not_found` / `lookup_error` / `generation_error` | Requests by outcome | +| `sep0001_generation_duration_seconds` | `outcome` | Lookup + generation latency | +| `sep0001_document_size_bytes` | — | Served document size | +| `sep0001_rejected_fields_total` | `field`, `reason` | Sanitization rejections | +| `sep0001_health_state` | — | `0` healthy, `1` degraded, `2` unhealthy | +| `sep0001_error_ratio` | — | Rolling-window server-error ratio | +| `sep0001_last_success_timestamp_seconds` | — | Last successful response | + +All label values come from fixed sets. Merchant ids never become labels. + +Health: `GET /health/sep0001` returns the rolling-window snapshot (5 minutes by +default). It responds `503` only when unhealthy. `/health` also reports +`services.sep0001_generator` for information. + +| Status | Condition (with at least `SEP0001_HEALTH_MIN_SAMPLES`, default 10) | +|---|---| +| `unhealthy` | server-error ratio (`lookup_error` + `generation_error`) ≥ `SEP0001_UNHEALTHY_ERROR_RATIO` (default 0.25) | +| `degraded` | server-error ratio ≥ `SEP0001_DEGRADED_ERROR_RATIO` (default 0.05), or a secret seed was refused as `SIGNING_KEY` in the window | + +`400` and `404` responses are client errors and do not affect health. The +window is set by `SEP0001_HEALTH_WINDOW_MS`. Alerting rules are in +`docs/alerts/sep0001-generator.rules.yml`. ## Caching @@ -267,6 +321,9 @@ console.log('Accounts:', toml.ACCOUNTS); 1. **Public Information**: stellar.toml is publicly accessible - Don't include sensitive information - Only include information you want to share publicly + - `SIGNING_KEY` must be the public key. If a secret seed is configured, the + generator refuses to publish it and `Sep0001SecretSigningKeyRefused` fires. + Treat that seed as exposed in config and rotate it. 2. **HTTPS Required**: Always use HTTPS for stellar.toml - Wallets will reject HTTP endpoints diff --git a/backend/docs/IDEMPOTENCY_KEY_MANAGER.md b/backend/docs/IDEMPOTENCY_KEY_MANAGER.md new file mode 100644 index 0000000..10e9e8e --- /dev/null +++ b/backend/docs/IDEMPOTENCY_KEY_MANAGER.md @@ -0,0 +1,60 @@ +# Idempotency Key Manager + +`src/lib/idempotency.js` makes `POST` requests with an `Idempotency-Key` +header safe to retry. The first 2xx response is cached in Redis for 24 hours +under `idempotency::` together with a SHA-256 hash of the +request body. A later request with the same key and the same body replays the +cached response (`201`). The same key with a different body gets a `400`. + +## Retry with exponential backoff (issue #1464) + +Every Redis call the manager makes goes through `withIdempotencyRetry()` +(`src/lib/idempotency-retry.js`): + +| Operation label | Call | On final failure | +|---|---|---| +| `get` | middleware cache read | Request proceeds without idempotency (fail-open, unchanged) | +| `set` | caching the response | Logged. Runs in the background and never delays the response. | +| `lookup` | `lookupIdempotentResponse()` (payment session handler) | Error propagates to the caller, unchanged | + +``` +delay(attempt) = min(baseDelayMs * 2^attempt, maxDelayMs) * (1 ± jitterRatio) +``` + +Retries stop at `maxRetries`, or when the next delay would push total elapsed +time past `totalBudgetMs`, so a Redis outage adds bounded latency. + +Only transient errors are retried: socket closed or reset, connect timeouts, +`ECONNRESET`/`ETIMEDOUT`/..., and the server replies `LOADING`, `BUSY`, +`TRYAGAIN`, `READONLY`, `MASTERDOWN` and `CLUSTERDOWN`. A closed client +(`redis.js` disables reconnects), auth/permission/`WRONGTYPE` errors and +corrupt cache JSON fail immediately. + +| Env var | Default | Bounds | +|---|---|---| +| `IDEMPOTENCY_REDIS_MAX_RETRIES` | 2 | 0–5 | +| `IDEMPOTENCY_REDIS_RETRY_BASE_MS` | 25 | 1–1000 | +| `IDEMPOTENCY_REDIS_RETRY_MAX_MS` | 250 | base–5000 | +| `IDEMPOTENCY_REDIS_RETRY_BUDGET_MS` | 500 | 0–2000 | + +Jitter is fixed at ±20%. + +## Metrics + +Exposed on `GET /metrics`, from their own registry: + +| Metric | Labels | +|---|---| +| `idempotency_redis_operations_total` | `operation` (`get`/`set`/`lookup`), `outcome` (`success`/`recovered`/`failed`/`non_retryable`) | +| `idempotency_redis_retries_total` | `operation` | +| `idempotency_redis_retry_delay_seconds` | `operation` | + +A rising `outcome="failed"` rate for `get` means idempotency is currently +failing open, so duplicate payments are possible. Alert on it together with +Redis availability. + +## Tests + +```bash +npx vitest run src/lib/idempotency-retry.test.js src/lib/idempotency.test.js +``` diff --git a/backend/docs/WEBSOCKET_RELAY_TESTING.md b/backend/docs/WEBSOCKET_RELAY_TESTING.md new file mode 100644 index 0000000..3559474 --- /dev/null +++ b/backend/docs/WEBSOCKET_RELAY_TESTING.md @@ -0,0 +1,29 @@ +# WebSocket Relay Service: integration and stress tests + +Issue #1456. + +| Suite | File | What it proves | +|---|---|---| +| Integration | `src/lib/websocket-relay.integration.test.js` | The real security, query, recovery and metrics modules wired as a relay worker: handshake (origin allow-list, HS256-pinned JWT, `alg: none`/expired/wrong-secret rejection), frame size limit, unknown/prototype-polluting field stripping, parameterised batch insert, FIFO dequeue, delivery through the circuit breaker, `markEventComplete`/`markEventFailed`, dead-lettering, HALF_OPEN recovery and DLQ replay, DB failure wrapping, pagination clamps, reconnect backoff, and audit log integrity | +| Stress | `src/lib/websocket-relay.stress.test.js` | 50 concurrent consumers drain 2,000 events with every event claimed exactly once; interleaved async latency never double-claims; a 1,000-row single-statement insert; 10,000 frames sanitized; oversized-frame floods; byte-accurate size checks for multi-byte text; 1,000 concurrent fast-fails on an open circuit; 10,000 mixed breaker outcomes; a 10,000-entry DLQ in FIFO order; 200 concurrent reconnect loops staying under the delay cap | + +Only Postgres is faked. `tests/helpers/fake-relay-db.js` interprets the exact +SQL emitted by `websocket-relay-queries.js`. Each statement applies +atomically, mirroring `FOR UPDATE SKIP LOCKED` row locking, so the +exactly-once assertions test the claiming logic rather than the fake. + +Timing assertions in the stress suite are deliberately loose. They catch +pathological regressions (quadratic or blocking behaviour), not small slowdowns. + +## Finding fixed + +`auditRelayEvent()` spread caller metadata **after** the fixed fields, so +metadata containing `audit`, `ts` or `event` (for example values echoed from +a client) could overwrite them and forge an audit line. Metadata is now +spread first. + +## Running + +```bash +npx vitest run src/lib/websocket-relay +``` diff --git a/backend/docs/alerts/sep0001-generator.rules.yml b/backend/docs/alerts/sep0001-generator.rules.yml new file mode 100644 index 0000000..3c129f5 --- /dev/null +++ b/backend/docs/alerts/sep0001-generator.rules.yml @@ -0,0 +1,63 @@ +# Prometheus alerting rules for the SEP-0001 stellar.toml generator (issue #1458). +# +# Load with: rule_files: ["docs/alerts/sep0001-generator.rules.yml"] +# Validate: promtool check rules docs/alerts/sep0001-generator.rules.yml +# +# Metric reference: SEP0001_GENERATOR_GUIDE.md ("Monitoring") + +groups: + - name: sep0001-generator + rules: + - alert: Sep0001GeneratorUnhealthy + expr: max(sep0001_health_state) >= 2 + for: 5m + labels: + severity: critical + annotations: + summary: stellar.toml generation is failing + description: > + The rolling-window server error ratio is above the unhealthy + threshold. Wallets cannot discover TRANSFER_SERVER or verify + merchant accounts. See GET /health/sep0001 and logs for + "SEP-0001: failed to fetch merchant" / "invalid server configuration". + + - alert: Sep0001GeneratorDegraded + expr: max(sep0001_health_state) == 1 + for: 15m + labels: + severity: warning + annotations: + summary: stellar.toml generator degraded for 15 minutes + description: Elevated error ratio or a refused secret SIGNING_KEY. See GET /health/sep0001. + + - alert: Sep0001SecretSigningKeyRefused + expr: sum(increase(sep0001_rejected_fields_total{field="SIGNING_KEY",reason="secret_seed"}[15m])) > 0 + for: 0m + labels: + severity: critical + annotations: + summary: SIGNING_KEY is configured with a secret seed + description: > + The generator refused to publish it, but the seed is present in the + environment. Rotate the key and set SIGNING_KEY to the public key. + + - alert: Sep0001InvalidConfiguration + expr: sum(increase(sep0001_requests_total{outcome="generation_error"}[10m])) > 0 + for: 10m + labels: + severity: warning + annotations: + summary: stellar.toml cannot be generated + description: > + Usually TRANSFER_SERVER_URL / API_BASE_URL is not https or + STELLAR_NETWORK_PASSPHRASE is invalid. Check logs for + "SEP-0001: invalid server configuration". + + - alert: Sep0001MerchantDataRejected + expr: sum by (field, reason) (increase(sep0001_rejected_fields_total{field!="SIGNING_KEY"}[1h])) > 20 + for: 0m + labels: + severity: info + annotations: + summary: "Many stellar.toml {{ $labels.field }} values rejected ({{ $labels.reason }})" + description: Merchant records contain values that are being omitted from stellar.toml. diff --git a/backend/src/app.js b/backend/src/app.js index b330d30..973accc 100644 --- a/backend/src/app.js +++ b/backend/src/app.js @@ -49,6 +49,7 @@ import { versionDeprecationMiddleware } from "./lib/version-deprecation.js"; import oracleRouter from "./routes/oracle.js"; import { getPaymentSessionValidatorHealth } from "./lib/payment-session-validator.js"; import { getExchangeRateOracleHealth } from "./lib/exchange-rate-oracle-telemetry.js"; +import { getSep0001Health } from "./lib/sep0001-telemetry.js"; import { configureExchangeRateCoordination } from "./services/exchangeRateService.js"; export async function createApp({ redisClient }) { @@ -262,6 +263,7 @@ export async function createApp({ redisClient }) { // Informational only — does not affect `ok` / the status code. payment_session_validator: getPaymentSessionValidatorHealth().status, exchange_rate_oracle_cache: getExchangeRateOracleHealth().status, + sep0001_generator: getSep0001Health().status, }, }); }); @@ -314,6 +316,30 @@ export async function createApp({ redisClient }) { res.status(health.status === "unhealthy" ? 503 : 200).json(health); }); + /** + * @swagger + * /health/sep0001: + * get: + * summary: SEP-0001 stellar.toml generator health telemetry + * description: > + * Rolling-window request counts, server error ratio and derived status + * for GET /.well-known/stellar.toml (issue #1458). Returns 503 only + * when the generator is unhealthy. A degraded status (elevated error + * ratio, or a secret seed refused as SIGNING_KEY) still returns 200. + * The body contains no merchant ids or key material. + * tags: [Health] + * security: [] + * responses: + * 200: + * description: Generator healthy or degraded + * 503: + * description: Generator unhealthy + */ + app.get("/health/sep0001", (_req, res) => { + const health = getSep0001Health(); + res.status(health.status === "unhealthy" ? 503 : 200).json(health); + }); + const verifyPaymentRateLimit = createVerifyPaymentRateLimit({ store: redisAvailable ? createRedisRateLimitStore({ client: redisClient }) : undefined, }); diff --git a/backend/src/lib/idempotency-retry.js b/backend/src/lib/idempotency-retry.js new file mode 100644 index 0000000..c535a13 --- /dev/null +++ b/backend/src/lib/idempotency-retry.js @@ -0,0 +1,184 @@ +import client from "prom-client"; +import { logger } from "./logger.js"; + +/** + * Automated retry with exponential backoff for the Idempotency Key Manager + * (issue #1464). + * + * The idempotency middleware depends on Redis for two things: reading a + * cached response (so a client retry is replayed instead of creating a + * duplicate payment) and writing the response after the handler succeeds. + * Before this change, a single transient Redis blip (connection reset, + * failover READONLY, LOADING after restart) meant: + * + * - read: the request silently skipped idempotency (possible duplicate) + * - write: the response was never cached (the next retry duplicates) + * + * Every Redis call is now retried with capped exponential backoff and jitter: + * + * delay(attempt) = min(baseDelayMs * 2^attempt, maxDelayMs) * (1 ± jitterRatio) + * + * Retries stop when maxRetries is reached or when the next delay would + * exceed totalBudgetMs, so request latency stays bounded. Only transient + * errors are retried. A closed client, auth/permission errors and corrupt + * cache entries fail immediately, and the caller's existing fail-open + * behaviour applies. + */ + +const register = new client.Registry(); + +register.setDefaultLabels({ + app: "stellar-payment-api", +}); + +/** operation: get | set | lookup — outcome: success | recovered | failed | non_retryable */ +export const idempotencyRedisOperationsTotal = new client.Counter({ + name: "idempotency_redis_operations_total", + help: "Idempotency Key Manager Redis operations, by operation and final outcome", + labelNames: ["operation", "outcome"], +}); + +export const idempotencyRedisRetriesTotal = new client.Counter({ + name: "idempotency_redis_retries_total", + help: "Retries of idempotency Redis operations after a transient error", + labelNames: ["operation"], +}); + +export const idempotencyRedisRetryDelaySeconds = new client.Histogram({ + name: "idempotency_redis_retry_delay_seconds", + help: "Backoff delay applied before each idempotency Redis retry", + labelNames: ["operation"], + buckets: [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1], +}); + +register.registerMetric(idempotencyRedisOperationsTotal); +register.registerMetric(idempotencyRedisRetriesTotal); +register.registerMetric(idempotencyRedisRetryDelaySeconds); + +export { register as idempotencyRegister }; + +const KNOWN_OPERATIONS = new Set(["get", "set", "lookup"]); + +function readIntEnv(env, name, fallback, min, max) { + const raw = Number.parseInt(String(env[name] ?? ""), 10); + if (!Number.isFinite(raw)) return fallback; + return Math.min(Math.max(raw, min), max); +} + +/** + * Resolve retry options from the environment, clamped to safe bounds. + * + * IDEMPOTENCY_REDIS_MAX_RETRIES default 2 (0-5) + * IDEMPOTENCY_REDIS_RETRY_BASE_MS default 25 (1-1000) + * IDEMPOTENCY_REDIS_RETRY_MAX_MS default 250 (base-5000) + * IDEMPOTENCY_REDIS_RETRY_BUDGET_MS default 500 (0-2000) + */ +export function resolveIdempotencyRetryOptions(env = process.env) { + const baseDelayMs = readIntEnv(env, "IDEMPOTENCY_REDIS_RETRY_BASE_MS", 25, 1, 1000); + return { + maxRetries: readIntEnv(env, "IDEMPOTENCY_REDIS_MAX_RETRIES", 2, 0, 5), + baseDelayMs, + maxDelayMs: readIntEnv(env, "IDEMPOTENCY_REDIS_RETRY_MAX_MS", 250, baseDelayMs, 5000), + totalBudgetMs: readIntEnv(env, "IDEMPOTENCY_REDIS_RETRY_BUDGET_MS", 500, 0, 2000), + jitterRatio: 0.2, + }; +} + +export const DEFAULT_IDEMPOTENCY_RETRY_OPTIONS = Object.freeze(resolveIdempotencyRetryOptions()); + +const RETRYABLE_CODES = new Set([ + "ECONNRESET", + "ECONNREFUSED", + "ETIMEDOUT", + "EPIPE", + "EAI_AGAIN", + "EHOSTUNREACH", + "ENETUNREACH", +]); +const RETRYABLE_ERROR_NAMES = new Set([ + "ConnectionTimeoutError", + "SocketClosedUnexpectedlyError", + "TimeoutError", + "AbortError", +]); +// Redis server replies that mean "try again shortly". +const RETRYABLE_REPLY_PREFIXES = ["LOADING", "BUSY", "TRYAGAIN", "READONLY", "MASTERDOWN", "CLUSTERDOWN"]; + +/** + * Transient errors only. A closed client never reopens within a request + * (redis.js disables reconnects), and auth / type / parse errors will not + * succeed on retry. + */ +export function isRetryableRedisError(err) { + if (!err) return false; + if (err.name === "ClientClosedError" || /client is closed/i.test(String(err.message))) return false; + if (err instanceof SyntaxError) return false; + if (RETRYABLE_CODES.has(err.code)) return true; + if (RETRYABLE_ERROR_NAMES.has(err.name)) return true; + const message = String(err.message ?? ""); + if (RETRYABLE_REPLY_PREFIXES.some((prefix) => message.startsWith(prefix))) return true; + return /socket closed unexpectedly|connection (reset|lost)|timed? ?out/i.test(message); +} + +/** + * Backoff delay before retry number `attempt` (0-based). + */ +export function computeBackoffDelay(attempt, options = DEFAULT_IDEMPOTENCY_RETRY_OPTIONS, random = Math.random) { + const { baseDelayMs, maxDelayMs, jitterRatio = 0 } = options; + const exponential = Math.min(baseDelayMs * 2 ** attempt, maxDelayMs); + const jitter = exponential * jitterRatio * (random() * 2 - 1); + return Math.max(0, Math.round(exponential + jitter)); +} + +/** + * Run a Redis operation with retry + exponential backoff. + * + * @template T + * @param {"get"|"set"|"lookup"} operation Metric label + * @param {() => Promise} fn + * @param {object} [options] Overrides for DEFAULT_IDEMPOTENCY_RETRY_OPTIONS, + * plus `sleep`, `now` and `random` for tests. + * @returns {Promise} + * @throws The last error once retries are exhausted or the error is not retryable. + */ +export async function withIdempotencyRetry(operation, fn, options = {}) { + const label = KNOWN_OPERATIONS.has(operation) ? operation : "get"; + const { + sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)), + now = () => Date.now(), + random = Math.random, + ...overrides + } = options; + const config = { ...DEFAULT_IDEMPOTENCY_RETRY_OPTIONS, ...overrides }; + const startedAt = now(); + + for (let attempt = 0; ; attempt += 1) { + try { + const result = await fn(); + idempotencyRedisOperationsTotal.inc({ operation: label, outcome: attempt === 0 ? "success" : "recovered" }); + return result; + } catch (err) { + if (!isRetryableRedisError(err)) { + idempotencyRedisOperationsTotal.inc({ operation: label, outcome: "non_retryable" }); + throw err; + } + const delay = computeBackoffDelay(attempt, config, random); + const elapsed = now() - startedAt; + if (attempt >= config.maxRetries || elapsed + delay > config.totalBudgetMs) { + idempotencyRedisOperationsTotal.inc({ operation: label, outcome: "failed" }); + logger.warn?.( + { operation: label, attempts: attempt + 1, elapsedMs: elapsed, err: err?.message }, + "Idempotency Redis operation failed after retries", + ); + throw err; + } + idempotencyRedisRetriesTotal.inc({ operation: label }); + idempotencyRedisRetryDelaySeconds.observe({ operation: label }, delay / 1000); + logger.warn?.( + { operation: label, attempt: attempt + 1, delayMs: delay, err: err?.message }, + "Idempotency Redis operation failed, retrying with backoff", + ); + await sleep(delay); + } + } +} diff --git a/backend/src/lib/idempotency-retry.test.js b/backend/src/lib/idempotency-retry.test.js new file mode 100644 index 0000000..cc5c09a --- /dev/null +++ b/backend/src/lib/idempotency-retry.test.js @@ -0,0 +1,196 @@ +/** + * Idempotency Key Manager — retry with exponential backoff (#1464). + */ + +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("./logger.js", () => ({ + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, +})); + +import { logger } from "./logger.js"; +import { + computeBackoffDelay, + idempotencyRedisOperationsTotal, + idempotencyRedisRetriesTotal, + idempotencyRegister, + isRetryableRedisError, + resolveIdempotencyRetryOptions, + withIdempotencyRetry, +} from "./idempotency-retry.js"; + +const OPTIONS = { maxRetries: 3, baseDelayMs: 10, maxDelayMs: 40, totalBudgetMs: 1_000, jitterRatio: 0 }; + +function transient(message = "Socket closed unexpectedly") { + const err = new Error(message); + err.name = "SocketClosedUnexpectedlyError"; + return err; +} + +async function value(metric, labels) { + const { values } = await metric.get(); + return values.find((v) => Object.entries(labels).every(([k, val]) => v.labels[k] === val))?.value ?? 0; +} + +function harness(overrides = {}) { + let clock = 0; + const sleeps = []; + return { + sleeps, + options: { + ...OPTIONS, + now: () => clock, + sleep: async (ms) => { + sleeps.push(ms); + clock += ms; + }, + random: () => 0.5, + ...overrides, + }, + }; +} + +beforeEach(() => { + vi.clearAllMocks(); + idempotencyRegister.resetMetrics(); +}); + +describe("computeBackoffDelay", () => { + it("doubles from the base delay and caps at maxDelayMs", () => { + expect([0, 1, 2, 3, 4].map((a) => computeBackoffDelay(a, OPTIONS))).toEqual([10, 20, 40, 40, 40]); + }); + + it("applies symmetric jitter within ±jitterRatio", () => { + const opts = { ...OPTIONS, jitterRatio: 0.2 }; + expect(computeBackoffDelay(1, opts, () => 0)).toBe(16); + expect(computeBackoffDelay(1, opts, () => 1)).toBe(24); + expect(computeBackoffDelay(1, opts, () => 0.5)).toBe(20); + }); + + it("never returns a negative delay", () => { + expect(computeBackoffDelay(0, { baseDelayMs: 1, maxDelayMs: 1, jitterRatio: 5 }, () => 0)).toBe(0); + }); +}); + +describe("isRetryableRedisError", () => { + it.each([ + ["socket closed", transient()], + ["connection timeout", Object.assign(new Error("Connection timeout"), { name: "ConnectionTimeoutError" })], + ["ECONNRESET", Object.assign(new Error("read ECONNRESET"), { code: "ECONNRESET" })], + ["LOADING reply", new Error("LOADING Redis is loading the dataset in memory")], + ["READONLY failover", new Error("READONLY You can't write against a read only replica.")], + ["TRYAGAIN", new Error("TRYAGAIN Multiple keys request during rehashing of slot")], + ])("retries %s", (_label, err) => { + expect(isRetryableRedisError(err)).toBe(true); + }); + + it.each([ + ["closed client", Object.assign(new Error("The client is closed"), { name: "ClientClosedError" })], + ["auth error", new Error("NOAUTH Authentication required.")], + ["wrong type", new Error("WRONGTYPE Operation against a key holding the wrong kind of value")], + ["corrupt cache JSON", new SyntaxError("Unexpected token } in JSON")], + ["null", null], + ])("does not retry %s", (_label, err) => { + expect(isRetryableRedisError(err)).toBe(false); + }); +}); + +describe("resolveIdempotencyRetryOptions", () => { + it("uses defaults and clamps env overrides to safe bounds", () => { + expect(resolveIdempotencyRetryOptions({})).toEqual({ + maxRetries: 2, + baseDelayMs: 25, + maxDelayMs: 250, + totalBudgetMs: 500, + jitterRatio: 0.2, + }); + expect( + resolveIdempotencyRetryOptions({ + IDEMPOTENCY_REDIS_MAX_RETRIES: "99", + IDEMPOTENCY_REDIS_RETRY_BASE_MS: "0", + IDEMPOTENCY_REDIS_RETRY_MAX_MS: "999999", + IDEMPOTENCY_REDIS_RETRY_BUDGET_MS: "-5", + }), + ).toEqual({ maxRetries: 5, baseDelayMs: 1, maxDelayMs: 5000, totalBudgetMs: 0, jitterRatio: 0.2 }); + expect(resolveIdempotencyRetryOptions({ IDEMPOTENCY_REDIS_MAX_RETRIES: "abc" }).maxRetries).toBe(2); + }); + + it("never lets maxDelayMs fall below the base delay", () => { + expect( + resolveIdempotencyRetryOptions({ IDEMPOTENCY_REDIS_RETRY_BASE_MS: "100", IDEMPOTENCY_REDIS_RETRY_MAX_MS: "10" }) + .maxDelayMs, + ).toBe(100); + }); +}); + +describe("withIdempotencyRetry", () => { + it("returns immediately on success without sleeping", async () => { + const { options, sleeps } = harness(); + await expect(withIdempotencyRetry("get", async () => "cached", options)).resolves.toBe("cached"); + expect(sleeps).toEqual([]); + expect(await value(idempotencyRedisOperationsTotal, { operation: "get", outcome: "success" })).toBe(1); + }); + + it("recovers from transient errors with exponential backoff", async () => { + const { options, sleeps } = harness(); + const fn = vi.fn().mockRejectedValueOnce(transient()).mockRejectedValueOnce(transient()).mockResolvedValue("ok"); + + await expect(withIdempotencyRetry("set", fn, options)).resolves.toBe("ok"); + + expect(fn).toHaveBeenCalledTimes(3); + expect(sleeps).toEqual([10, 20]); + expect(await value(idempotencyRedisRetriesTotal, { operation: "set" })).toBe(2); + expect(await value(idempotencyRedisOperationsTotal, { operation: "set", outcome: "recovered" })).toBe(1); + expect(logger.warn).toHaveBeenCalledTimes(2); + }); + + it("gives up after maxRetries and rethrows the last error", async () => { + const { options, sleeps } = harness(); + const fn = vi.fn().mockRejectedValue(transient("still down")); + + await expect(withIdempotencyRetry("lookup", fn, options)).rejects.toThrow("still down"); + + expect(fn).toHaveBeenCalledTimes(4); // 1 + 3 retries + expect(sleeps).toEqual([10, 20, 40]); + expect(await value(idempotencyRedisOperationsTotal, { operation: "lookup", outcome: "failed" })).toBe(1); + expect(logger.warn).toHaveBeenLastCalledWith( + expect.objectContaining({ operation: "lookup", attempts: 4 }), + "Idempotency Redis operation failed after retries", + ); + }); + + it("stops early when the next delay would exceed the latency budget", async () => { + const { options, sleeps } = harness({ totalBudgetMs: 25 }); + const fn = vi.fn().mockRejectedValue(transient()); + + await expect(withIdempotencyRetry("get", fn, options)).rejects.toThrow(); + + expect(sleeps).toEqual([10]); // 10 + 20 > 25 + expect(fn).toHaveBeenCalledTimes(2); + }); + + it("does not retry non-retryable errors", async () => { + const { options, sleeps } = harness(); + const closed = Object.assign(new Error("The client is closed"), { name: "ClientClosedError" }); + const fn = vi.fn().mockRejectedValue(closed); + + await expect(withIdempotencyRetry("get", fn, options)).rejects.toBe(closed); + + expect(fn).toHaveBeenCalledTimes(1); + expect(sleeps).toEqual([]); + expect(await value(idempotencyRedisOperationsTotal, { operation: "get", outcome: "non_retryable" })).toBe(1); + }); + + it("with maxRetries 0 behaves like a single attempt", async () => { + const { options } = harness({ maxRetries: 0 }); + const fn = vi.fn().mockRejectedValue(transient()); + await expect(withIdempotencyRetry("get", fn, options)).rejects.toThrow(); + expect(fn).toHaveBeenCalledTimes(1); + }); + + it("labels unknown operations with a bounded value", async () => { + const { options } = harness(); + await withIdempotencyRetry("merchant-123:key", async () => 1, options); + expect(await value(idempotencyRedisOperationsTotal, { operation: "get", outcome: "success" })).toBe(1); + }); +}); diff --git a/backend/src/lib/idempotency.js b/backend/src/lib/idempotency.js index d75619b..06d2fc5 100644 --- a/backend/src/lib/idempotency.js +++ b/backend/src/lib/idempotency.js @@ -1,5 +1,6 @@ import crypto from "node:crypto"; import { getRedisClient } from "./redis.js"; +import { withIdempotencyRetry } from "./idempotency-retry.js"; /** * TTL for idempotency cache entries in seconds. @@ -29,11 +30,14 @@ export function hashIdempotencyPayload(body) { * (exposed as `req.idempotency.payloadHash`), because downstream Zod schemas * may transform `req.body`. * + * Transient Redis errors are retried with exponential backoff (issue #1464). + * * @returns {Promise} */ -export async function lookupIdempotentResponse({ redisClient, merchantId, idempotencyKey, payloadHash }) { +export async function lookupIdempotentResponse({ redisClient, merchantId, idempotencyKey, payloadHash, retryOptions }) { if (!redisClient || !merchantId || !idempotencyKey || !payloadHash) return null; - const cachedValue = await redisClient.get(buildIdempotencyRedisKey(merchantId, idempotencyKey)); + const redisKey = buildIdempotencyRedisKey(merchantId, idempotencyKey); + const cachedValue = await withIdempotencyRetry("lookup", () => redisClient.get(redisKey), retryOptions); if (!cachedValue) return null; const { hash, response } = JSON.parse(cachedValue); @@ -48,6 +52,10 @@ export async function lookupIdempotentResponse({ redisClient, merchantId, idempo * Tracks the key tied to the payload hash and response. * Returns a cached 201 response if the key matches a previous request. * Stores cached state in Redis for 24h. + * + * Redis reads and writes are retried with exponential backoff on transient + * errors (issue #1464). If Redis is still unavailable after the retry budget, + * the request proceeds without idempotency (fail-safe, unchanged). */ export async function idempotencyMiddleware(req, res, next) { // Only process POST requests as per requirements @@ -84,7 +92,7 @@ export async function idempotencyMiddleware(req, res, next) { req.idempotency = { key: idempotencyKey, payloadHash }; try { - const cachedValue = await redisClient.get(redisKey); + const cachedValue = await withIdempotencyRetry("get", () => redisClient.get(redisKey)); if (cachedValue) { const { hash, response } = JSON.parse(cachedValue); @@ -106,15 +114,15 @@ export async function idempotencyMiddleware(req, res, next) { res.json = function (data) { // Only cache successful creation-like responses (2xx) if (res.statusCode >= 200 && res.statusCode < 300) { - redisClient - .set( - redisKey, - JSON.stringify({ - hash: payloadHash, - response: data, - }), - { EX: IDEMPOTENCY_TTL_SECONDS } - ) + const cacheEntry = JSON.stringify({ + hash: payloadHash, + response: data, + }); + // Fire-and-forget: retries happen in the background and never delay + // the response that is being sent. + withIdempotencyRetry("set", () => + redisClient.set(redisKey, cacheEntry, { EX: IDEMPOTENCY_TTL_SECONDS }) + ) .catch((err) => { console.error("Failed to cache idempotency response:", err.message); }); diff --git a/backend/src/lib/idempotency.test.js b/backend/src/lib/idempotency.test.js new file mode 100644 index 0000000..b0c854a --- /dev/null +++ b/backend/src/lib/idempotency.test.js @@ -0,0 +1,190 @@ +/** + * Idempotency Key Manager middleware with Redis retry/backoff (#1464). + */ + +import express from "express"; +import request from "supertest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { state } = vi.hoisted(() => ({ state: { redis: null } })); + +vi.mock("./redis.js", () => ({ + getRedisClient: vi.fn(() => state.redis), +})); + +vi.mock("./logger.js", () => ({ + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, +})); + +// Make backoff instant so tests do not wait on real timers. +vi.mock("./idempotency-retry.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + withIdempotencyRetry: (operation, fn, options = {}) => + actual.withIdempotencyRetry(operation, fn, { sleep: async () => {}, ...options }), + }; +}); + +import { + buildIdempotencyRedisKey, + hashIdempotencyPayload, + idempotencyMiddleware, + lookupIdempotentResponse, +} from "./idempotency.js"; +import { idempotencyRedisOperationsTotal, idempotencyRegister } from "./idempotency-retry.js"; + +const MERCHANT_ID = "merchant-1"; + +function transient() { + const err = new Error("Socket closed unexpectedly"); + err.name = "SocketClosedUnexpectedlyError"; + return err; +} + +function createRedis() { + const store = new Map(); + return { + store, + get: vi.fn(async (key) => store.get(key) ?? null), + set: vi.fn(async (key, value) => { + store.set(key, value); + return "OK"; + }), + }; +} + +function createApp(handler = vi.fn((req, res) => res.status(201).json({ id: "pay_1", n: Math.random() }))) { + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + req.merchant = { id: MERCHANT_ID }; + next(); + }); + app.post("/api/payments", idempotencyMiddleware, handler); + return { app, handler }; +} + +async function outcome(operation, result) { + const { values } = await idempotencyRedisOperationsTotal.get(); + return values.find((v) => v.labels.operation === operation && v.labels.outcome === result)?.value ?? 0; +} + +const flush = () => new Promise((resolve) => setImmediate(resolve)); + +beforeEach(() => { + vi.clearAllMocks(); + state.redis = createRedis(); + idempotencyRegister.resetMetrics(); +}); + +describe("idempotencyMiddleware with Redis retries", () => { + it("caches the first response and replays it for the same key", async () => { + const { app, handler } = createApp(); + const first = await request(app).post("/api/payments").set("Idempotency-Key", "k1").send({ amount: 1 }); + await flush(); + const second = await request(app).post("/api/payments").set("Idempotency-Key", "k1").send({ amount: 1 }); + + expect(first.status).toBe(201); + expect(second.status).toBe(201); + expect(second.body).toEqual(first.body); + expect(handler).toHaveBeenCalledTimes(1); + }); + + it("retries a transient read failure and still replays the cached response", async () => { + const { app, handler } = createApp(); + await request(app).post("/api/payments").set("Idempotency-Key", "k2").send({ amount: 1 }); + await flush(); + + state.redis.get.mockRejectedValueOnce(transient()); + const replay = await request(app).post("/api/payments").set("Idempotency-Key", "k2").send({ amount: 1 }); + + expect(replay.status).toBe(201); + expect(handler).toHaveBeenCalledTimes(1); // no duplicate payment + expect(await outcome("get", "recovered")).toBe(1); + }); + + it("retries a transient write failure so the response is still cached", async () => { + const { app, handler } = createApp(); + state.redis.set.mockRejectedValueOnce(transient()); + + await request(app).post("/api/payments").set("Idempotency-Key", "k3").send({ amount: 1 }); + await flush(); + await request(app).post("/api/payments").set("Idempotency-Key", "k3").send({ amount: 1 }); + + expect(state.redis.set).toHaveBeenCalledTimes(2); + expect(handler).toHaveBeenCalledTimes(1); + expect(await outcome("set", "recovered")).toBe(1); + }); + + it("fails open (unchanged behaviour) when Redis stays down after retries", async () => { + const { app, handler } = createApp(); + state.redis.get.mockRejectedValue(transient()); + + const res = await request(app).post("/api/payments").set("Idempotency-Key", "k4").send({ amount: 1 }); + + expect(res.status).toBe(201); + expect(handler).toHaveBeenCalledTimes(1); + expect(state.redis.get).toHaveBeenCalledTimes(3); // default: 1 + 2 retries + expect(await outcome("get", "failed")).toBe(1); + }); + + it("does not retry a closed client", async () => { + const { app } = createApp(); + state.redis.get.mockRejectedValue(Object.assign(new Error("The client is closed"), { name: "ClientClosedError" })); + + const res = await request(app).post("/api/payments").set("Idempotency-Key", "k5").send({ amount: 1 }); + + expect(res.status).toBe(201); + expect(state.redis.get).toHaveBeenCalledTimes(1); + }); + + it("still rejects a reused key with a different payload", async () => { + const { app } = createApp(); + await request(app).post("/api/payments").set("Idempotency-Key", "k6").send({ amount: 1 }); + await flush(); + const res = await request(app).post("/api/payments").set("Idempotency-Key", "k6").send({ amount: 2 }); + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/different request payload/); + }); + + it("does not touch Redis for requests without an Idempotency-Key", async () => { + const { app } = createApp(); + await request(app).post("/api/payments").send({ amount: 1 }); + expect(state.redis.get).not.toHaveBeenCalled(); + }); +}); + +describe("lookupIdempotentResponse with retries", () => { + const payloadHash = hashIdempotencyPayload({ amount: 1 }); + const key = buildIdempotencyRedisKey(MERCHANT_ID, "k7"); + + it("recovers from a transient error and returns the hit", async () => { + state.redis.store.set(key, JSON.stringify({ hash: payloadHash, response: { id: "pay_7" } })); + state.redis.get.mockRejectedValueOnce(transient()); + + await expect( + lookupIdempotentResponse({ redisClient: state.redis, merchantId: MERCHANT_ID, idempotencyKey: "k7", payloadHash }), + ).resolves.toEqual({ status: "hit", response: { id: "pay_7" } }); + expect(await outcome("lookup", "recovered")).toBe(1); + }); + + it("propagates the error once retries are exhausted", async () => { + state.redis.get.mockRejectedValue(transient()); + await expect( + lookupIdempotentResponse({ + redisClient: state.redis, + merchantId: MERCHANT_ID, + idempotencyKey: "k7", + payloadHash, + retryOptions: { maxRetries: 1 }, + }), + ).rejects.toThrow("Socket closed unexpectedly"); + expect(state.redis.get).toHaveBeenCalledTimes(2); + }); + + it("returns null without calling Redis when inputs are missing", async () => { + await expect(lookupIdempotentResponse({ redisClient: state.redis, merchantId: MERCHANT_ID })).resolves.toBeNull(); + expect(state.redis.get).not.toHaveBeenCalled(); + }); +}); diff --git a/backend/src/lib/sep0001-generator.integration.test.js b/backend/src/lib/sep0001-generator.integration.test.js index 56b6fa9..581bbc4 100644 --- a/backend/src/lib/sep0001-generator.integration.test.js +++ b/backend/src/lib/sep0001-generator.integration.test.js @@ -6,7 +6,7 @@ const BASE_MERCHANT = { business_name: "Integration Test Merchant", email: "merchant@integration.example.com", notification_email: "notify@integration.example.com", - recipient: "GBUQWP3BOUZX34ULNQG23RQ6F4YUSXHTQSXUSMIQSTBE2BRUY4DQAT2B", + recipient: "GDVEU3DD4KOFECV66VIHWEZOYX4ZKR3WV27L464SIIPOU2IUI3JCZA57", branding_config: { homepage: "https://integration.example.com", logo_url: "https://integration.example.com/logo.png", @@ -66,11 +66,17 @@ describe("SEP-0001 generator — integration: environment variable handling", () expect(toml).not.toContain("FEDERATION_SERVER"); }); - it("includes SIGNING_KEY when env var is set", () => { - process.env.SIGNING_KEY = "SCZANGBA5ARYOEX4DYUJC7GPZESCSXOZQIMONZPH5GKIQKP52PCMHHCA"; + it("includes SIGNING_KEY when env var is set to a public key", () => { + process.env.SIGNING_KEY = "GBTL47RTFR5EKMZSXWOQU735WBK7LRPPDIDK3JTNTCZZ7NUBBRDTVSK2"; const toml = generateStellarToml(BASE_MERCHANT); - expect(toml).toContain("SIGNING_KEY"); - expect(toml).toContain("SCZANGBA5ARYOEX4DYUJC7GPZESCSXOZQIMONZPH5GKIQKP52PCMHHCA"); + expect(toml).toContain('SIGNING_KEY = "GBTL47RTFR5EKMZSXWOQU735WBK7LRPPDIDK3JTNTCZZ7NUBBRDTVSK2"'); + }); + + it("never publishes a secret seed configured as SIGNING_KEY (#1457)", () => { + process.env.SIGNING_KEY = "SAFQWCYLBMFQWCYLBMFQWCYLBMFQWCYLBMFQWCYLBMFQWCYLBMFQX44I"; + const toml = generateStellarToml(BASE_MERCHANT); + expect(toml).not.toContain("SIGNING_KEY"); + expect(toml).not.toContain("SAFQWCYLBMFQWCYLBMFQWCYLBMFQWCYLBMFQWCYLBMFQWCYLBMFQX44I"); }); it("omits SIGNING_KEY when env var is absent", () => { @@ -110,7 +116,7 @@ describe("SEP-0001 generator — integration: merchant field combinations", () = it("generates ACCOUNTS list when recipient is provided", () => { const toml = generateStellarToml(BASE_MERCHANT); expect(toml).toContain( - `ACCOUNTS = ["GBUQWP3BOUZX34ULNQG23RQ6F4YUSXHTQSXUSMIQSTBE2BRUY4DQAT2B"]` + `ACCOUNTS = ["GDVEU3DD4KOFECV66VIHWEZOYX4ZKR3WV27L464SIIPOU2IUI3JCZA57"]` ); }); @@ -118,7 +124,7 @@ describe("SEP-0001 generator — integration: merchant field combinations", () = const variants = [ { business_name: "No Email" }, { business_name: "With Email", email: "a@b.com" }, - { business_name: "Full", email: "a@b.com", notification_email: "n@b.com", recipient: "GCEZWKCA5VLDNRLN3RPRJMRZOX3Z6G5CHCGDUPIU2HRHHZLQQFPSH3BB" }, + { business_name: "Full", email: "a@b.com", notification_email: "n@b.com", recipient: "GD6ROJBYLKQMOW3E7N4M2YBPUHMZD7PL65VRHRMO24BOVSBV5H3BQRSL" }, { ...BASE_MERCHANT }, ]; for (const merchant of variants) { @@ -187,12 +193,13 @@ describe("SEP-0001 generator — stress: throughput and concurrency", () => { results.forEach((toml) => expect(validateStellarToml(toml)).toBe(true)); }); - it("handles very long string fields without crashing", () => { + it("handles very long string fields without crashing (truncated to 256 chars, #1457)", () => { const longName = "A".repeat(10_000); const merchant = { ...BASE_MERCHANT, business_name: longName }; const toml = generateStellarToml(merchant); expect(validateStellarToml(toml)).toBe(true); - expect(toml).toContain(longName); + expect(toml).toContain(`name = "${"A".repeat(256)}"`); + expect(toml).not.toContain("A".repeat(257)); }); it("handles merchants with unicode characters in business_name", () => { diff --git a/backend/src/lib/sep0001-generator.js b/backend/src/lib/sep0001-generator.js index 2340fcc..41862e2 100644 --- a/backend/src/lib/sep0001-generator.js +++ b/backend/src/lib/sep0001-generator.js @@ -1,93 +1,296 @@ /** * SEP-0001 stellar.toml generator * Generates TOML content for merchant's Stellar business information + * + * Payload sanitization and strict validation (issue #1457): + * - Every value is TOML-escaped, including all control characters, so no + * merchant-controlled string can break out of its quoted value and + * inject keys or tables into the published document. + * - Unicode bidi overrides are stripped (visual spoofing of the org name). + * - Stellar accounts and SIGNING_KEY must be valid public keys (StrKey + * checksum). A secret seed configured as SIGNING_KEY is never published. + * - Emails and URLs are validated; merchant URLs must be https and URLs may + * not carry credentials. Invalid optional fields are omitted and reported. + * - Lengths are bounded and the whole document is capped at 100 KB, the + * SEP-0001 size limit. */ +import * as StellarSdk from "stellar-sdk"; + +export const SEP0001_LIMITS = Object.freeze({ + maxNameLength: 256, + maxEmailLength: 254, + maxUrlLength: 2048, + maxPassphraseLength: 128, + maxDocumentBytes: 100 * 1024, +}); + +export const DEFAULT_ORG_NAME = "Stellar Payment Merchant"; +const DEFAULT_NETWORK_PASSPHRASE = "Test SDF Network ; September 2015"; +const DEFAULT_API_BASE_URL = "http://localhost:4000"; + +/** Error code for server configuration that cannot produce a valid stellar.toml. */ +export const SEP0001_INVALID_CONFIG = "SEP0001_INVALID_CONFIG"; + +export class Sep0001ConfigError extends Error { + constructor(field, message) { + super(message); + this.name = "Sep0001ConfigError"; + this.code = SEP0001_INVALID_CONFIG; + this.field = field; + } +} + +const TOML_ESCAPES = { + "\b": "\\b", + "\t": "\\t", + "\n": "\\n", + "\f": "\\f", + "\r": "\\r", +}; +// eslint-disable-next-line no-control-regex +const CONTROL_CHARS = /[\u0000-\u001f\u007f]/g; +// eslint-disable-next-line no-control-regex +const CONTROL_CHARS_TEST = /[\u0000-\u001f\u007f]/; +// eslint-disable-next-line no-control-regex +const RAW_CONTROL_EXCEPT_NEWLINE = /[\u0000-\u0009\u000b-\u001f\u007f]/; +const BIDI_CONTROLS = /[‪-‮⁦-⁩‎‏]/g; +const EMAIL_PATTERN = /^[^\s@"<>\\,;()[\]]+@[^\s@"<>\\,;()[\]]+\.[^\s@"<>\\,;()[\]]+$/; +const LOCAL_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]"]); + /** * Escape TOML string values */ function escapeTomlString(str) { if (!str) return '""'; - return `"${str.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n')}"`; + const escaped = String(str) + .replace(/\\/g, "\\\\") + .replace(/"/g, '\\"') + .replace( + CONTROL_CHARS, + (ch) => TOML_ESCAPES[ch] ?? `\\u${ch.charCodeAt(0).toString(16).padStart(4, "0")}`, + ); + return `"${escaped}"`; +} + +function isPlainObject(value) { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +/** Validate a Stellar account id (G..., 56 chars, valid StrKey checksum). */ +export function isValidStellarAccount(value) { + return typeof value === "string" && StellarSdk.StrKey.isValidEd25519PublicKey(value); +} + +/** Validate an email address for the [ORG] section. */ +export function isValidEmail(value) { + return ( + typeof value === "string" && + value.length <= SEP0001_LIMITS.maxEmailLength && + EMAIL_PATTERN.test(value) + ); } /** - * Generate TOML content from merchant configuration + * Validate a URL. + * + * @param {unknown} value + * @param {{ allowLocalHttp?: boolean }} [options] Allow http:// for localhost + * (development defaults). Everything else must be https. + */ +export function isValidUrl(value, { allowLocalHttp = false } = {}) { + if (typeof value !== "string" || value.length === 0 || value.length > SEP0001_LIMITS.maxUrlLength) { + return false; + } + if (CONTROL_CHARS_TEST.test(value) || /\s/.test(value)) return false; + let parsed; + try { + parsed = new URL(value); + } catch { + return false; + } + if (parsed.username || parsed.password) return false; + if (parsed.protocol === "https:") return true; + return allowLocalHttp && parsed.protocol === "http:" && LOCAL_HOSTS.has(parsed.hostname); +} + +/** + * Normalize the organisation name: strip bidi overrides, trim, cap length. + * @returns {{ value: string, reason: string|null }} + */ +export function sanitizeOrgName(value) { + if (typeof value !== "string") { + return { value: DEFAULT_ORG_NAME, reason: value == null ? null : "invalid_type" }; + } + let reason = null; + let name = value.replace(BIDI_CONTROLS, () => { + reason = "bidi_control"; + return ""; + }); + name = name.trim(); + if (!name) return { value: DEFAULT_ORG_NAME, reason: reason ?? "empty" }; + if (name.length > SEP0001_LIMITS.maxNameLength) { + name = Array.from(name).slice(0, SEP0001_LIMITS.maxNameLength).join(""); + reason = "truncated"; + } + return { value: name, reason }; +} + +function readEnvUrl(env, key, fallback, rejections, { required = false } = {}) { + const raw = env[key]; + const value = raw === undefined || raw === "" ? fallback : String(raw).trim(); + if (value === undefined) return undefined; + if (isValidUrl(value, { allowLocalHttp: true })) return value; + if (required) { + throw new Sep0001ConfigError(key, `${key} must be an https URL without credentials`); + } + rejections.push({ field: key, reason: "invalid_url" }); + return undefined; +} + +/** + * Generate TOML content plus a report of every field that was rejected or + * altered by sanitization. + * + * @param {object} merchant + * @param {{ env?: Record }} [options] + * @returns {{ toml: string, rejections: Array<{ field: string, reason: string }> }} + * @throws {Error} "Merchant configuration required" for a missing/non-object merchant + * @throws {Sep0001ConfigError} when server configuration makes a valid document impossible */ -export function generateStellarToml(merchant) { - if (!merchant) { +export function generateStellarTomlReport(merchant, { env = process.env } = {}) { + if (!isPlainObject(merchant)) { throw new Error("Merchant configuration required"); } + const rejections = []; const lines = []; // NETWORK_PASSPHRASE - required for Stellar - const networkPassphrase = process.env.STELLAR_NETWORK_PASSPHRASE || "Test SDF Network ; September 2015"; + const networkPassphrase = env.STELLAR_NETWORK_PASSPHRASE || DEFAULT_NETWORK_PASSPHRASE; + if ( + typeof networkPassphrase !== "string" || + networkPassphrase.length > SEP0001_LIMITS.maxPassphraseLength || + CONTROL_CHARS_TEST.test(networkPassphrase) + ) { + throw new Sep0001ConfigError( + "STELLAR_NETWORK_PASSPHRASE", + "STELLAR_NETWORK_PASSPHRASE must be a printable string of at most 128 characters", + ); + } lines.push(`NETWORK_PASSPHRASE = ${escapeTomlString(networkPassphrase)}`); lines.push(""); + const apiBaseUrl = String(env.API_BASE_URL || DEFAULT_API_BASE_URL).replace(/\/+$/, ""); + // TRANSFER_SERVER - this API's payment endpoint - const transferServer = process.env.TRANSFER_SERVER_URL || `${process.env.API_BASE_URL || "http://localhost:4000"}/api`; + const transferServer = readEnvUrl(env, "TRANSFER_SERVER_URL", `${apiBaseUrl}/api`, rejections, { + required: true, + }); lines.push(`TRANSFER_SERVER = ${escapeTomlString(transferServer)}`); lines.push(""); // FEDERATION_SERVER - optional, for name resolution - if (process.env.FEDERATION_SERVER_URL) { - lines.push(`FEDERATION_SERVER = ${escapeTomlString(process.env.FEDERATION_SERVER_URL)}`); + const federationServer = readEnvUrl(env, "FEDERATION_SERVER_URL", undefined, rejections); + if (federationServer) { + lines.push(`FEDERATION_SERVER = ${escapeTomlString(federationServer)}`); lines.push(""); } // ACCOUNTS - merchant's Stellar accounts - if (merchant.recipient) { - lines.push(`ACCOUNTS = [${escapeTomlString(merchant.recipient)}]`); - lines.push(""); + if (merchant.recipient != null && merchant.recipient !== "") { + if (isValidStellarAccount(merchant.recipient)) { + lines.push(`ACCOUNTS = [${escapeTomlString(merchant.recipient)}]`); + lines.push(""); + } else { + rejections.push({ field: "recipient", reason: "invalid_account" }); + } } // DOCUMENTATION - link to API documentation - const docsUrl = process.env.DOCS_URL || `${process.env.API_BASE_URL || "http://localhost:4000"}/api-docs`; - lines.push(`DOCUMENTATION = ${escapeTomlString(docsUrl)}`); - lines.push(""); - - // SIGNING_KEY - optional, for transaction signing - if (process.env.SIGNING_KEY) { - lines.push(`SIGNING_KEY = ${escapeTomlString(process.env.SIGNING_KEY)}`); + const docsUrl = readEnvUrl(env, "DOCS_URL", `${apiBaseUrl}/api-docs`, rejections); + if (docsUrl) { + lines.push(`DOCUMENTATION = ${escapeTomlString(docsUrl)}`); lines.push(""); } + // SIGNING_KEY - optional; must be a PUBLIC key. Never publish a secret seed. + if (env.SIGNING_KEY) { + const signingKey = String(env.SIGNING_KEY).trim(); + if (isValidStellarAccount(signingKey)) { + lines.push(`SIGNING_KEY = ${escapeTomlString(signingKey)}`); + lines.push(""); + } else if (StellarSdk.StrKey.isValidEd25519SecretSeed(signingKey)) { + rejections.push({ field: "SIGNING_KEY", reason: "secret_seed" }); + } else { + rejections.push({ field: "SIGNING_KEY", reason: "invalid_account" }); + } + } + // ORG section with merchant information + const orgName = sanitizeOrgName(merchant.business_name); + if (orgName.reason) rejections.push({ field: "business_name", reason: orgName.reason }); lines.push("[ORG]"); - lines.push(`name = ${escapeTomlString(merchant.business_name || "Stellar Payment Merchant")}`); - + lines.push(`name = ${escapeTomlString(orgName.value)}`); + if (merchant.email) { - lines.push(`contact = ${escapeTomlString(merchant.email)}`); + if (isValidEmail(merchant.email)) { + lines.push(`contact = ${escapeTomlString(merchant.email)}`); + } else { + rejections.push({ field: "email", reason: "invalid_email" }); + } } if (merchant.notification_email) { - lines.push(`support = ${escapeTomlString(merchant.notification_email)}`); + if (isValidEmail(merchant.notification_email)) { + lines.push(`support = ${escapeTomlString(merchant.notification_email)}`); + } else { + rejections.push({ field: "notification_email", reason: "invalid_email" }); + } } + const branding = isPlainObject(merchant.branding_config) ? merchant.branding_config : {}; + // Add homepage if available in branding config - if (merchant.branding_config?.homepage) { - lines.push(`homepage = ${escapeTomlString(merchant.branding_config.homepage)}`); + if (branding.homepage) { + if (isValidUrl(branding.homepage)) { + lines.push(`homepage = ${escapeTomlString(branding.homepage)}`); + } else { + rejections.push({ field: "homepage", reason: "invalid_url" }); + } } // Add logo if available in branding config - if (merchant.branding_config?.logo_url) { - lines.push(`logo = ${escapeTomlString(merchant.branding_config.logo_url)}`); + if (branding.logo_url) { + if (isValidUrl(branding.logo_url)) { + lines.push(`logo = ${escapeTomlString(branding.logo_url)}`); + } else { + rejections.push({ field: "logo_url", reason: "invalid_url" }); + } } - return lines.join("\n"); + return { toml: lines.join("\n"), rejections }; } /** - * Validate TOML content (basic validation) + * Generate TOML content from merchant configuration + */ +export function generateStellarToml(merchant, options) { + return generateStellarTomlReport(merchant, options).toml; +} + +/** + * Validate TOML content: required keys at the start of a line, no raw + * control characters, and within the SEP-0001 100 KB size limit. */ export function validateStellarToml(tomlContent) { - if (!tomlContent) return false; - + if (!tomlContent || typeof tomlContent !== "string") return false; + if (Buffer.byteLength(tomlContent, "utf8") > SEP0001_LIMITS.maxDocumentBytes) return false; + if (RAW_CONTROL_EXCEPT_NEWLINE.test(tomlContent)) return false; + // Check for required fields - const hasNetworkPassphrase = tomlContent.includes("NETWORK_PASSPHRASE"); - const hasTransferServer = tomlContent.includes("TRANSFER_SERVER"); - + const hasNetworkPassphrase = /^NETWORK_PASSPHRASE = "/m.test(tomlContent); + const hasTransferServer = /^TRANSFER_SERVER = "/m.test(tomlContent); + return hasNetworkPassphrase && hasTransferServer; } diff --git a/backend/src/lib/sep0001-generator.sanitization.test.js b/backend/src/lib/sep0001-generator.sanitization.test.js new file mode 100644 index 0000000..b0f2a7c --- /dev/null +++ b/backend/src/lib/sep0001-generator.sanitization.test.js @@ -0,0 +1,230 @@ +/** + * SEP-0001 generator — payload sanitization and strict validation (#1457). + */ + +import { describe, expect, it } from "vitest"; +import { + DEFAULT_ORG_NAME, + SEP0001_INVALID_CONFIG, + SEP0001_LIMITS, + Sep0001ConfigError, + generateStellarToml, + generateStellarTomlReport, + isValidEmail, + isValidStellarAccount, + isValidUrl, + sanitizeOrgName, + validateStellarToml, +} from "./sep0001-generator.js"; + +const ACCOUNT = "GDVEU3DD4KOFECV66VIHWEZOYX4ZKR3WV27L464SIIPOU2IUI3JCZA57"; +const SIGNING_PUBLIC = "GBTL47RTFR5EKMZSXWOQU735WBK7LRPPDIDK3JTNTCZZ7NUBBRDTVSK2"; +const SIGNING_SECRET = "SAFQWCYLBMFQWCYLBMFQWCYLBMFQWCYLBMFQWCYLBMFQWCYLBMFQX44I"; +const BAD_CHECKSUM = "GBUQWP3BOUZX34ULNQG23RQ6F4YUSXHTQSXUSMIQSTBE2BRUY4DQAT2B"; + +const ENV = Object.freeze({ + STELLAR_NETWORK_PASSPHRASE: "Test SDF Network ; September 2015", + API_BASE_URL: "https://api.example.com", +}); + +const merchant = (overrides = {}) => ({ + business_name: "Acme", + email: "ops@acme.example", + notification_email: "help@acme.example", + recipient: ACCOUNT, + branding_config: { homepage: "https://acme.example", logo_url: "https://acme.example/logo.png" }, + ...overrides, +}); + +const report = (m, env = {}) => generateStellarTomlReport(m, { env: { ...ENV, ...env } }); + +/** Parse top-level `KEY = "value"` lines and [ORG] keys, the way a strict reader would. */ +function keysOf(toml) { + return toml + .split("\n") + .filter((line) => /^[A-Za-z_]+ = /.test(line) || /^\[/.test(line)) + .map((line) => line.split(" = ")[0]); +} + +describe("TOML injection resistance", () => { + it.each([ + ['quote breakout', 'Acme"\nSIGNING_KEY = "GEVIL'], + ["CRLF breakout", 'Acme\r\n[DOCUMENTATION]\r\nORG_URL = "https://evil"'], + ["table header", "Acme\n[[CURRENCIES]]\ncode = \"FAKE\""], + ["NUL and escape chars", "Acme\u0000\u001b[31m\u007f"], + ["backslash before quote", 'Acme\\"\nX = "1'], + ])("cannot inject keys or tables via %s in business_name", (_label, name) => { + const { toml } = report(merchant({ business_name: name })); + expect(keysOf(toml)).toEqual([ + "NETWORK_PASSPHRASE", + "TRANSFER_SERVER", + "ACCOUNTS", + "DOCUMENTATION", + "[ORG]", + "name", + "contact", + "support", + "homepage", + "logo", + ]); + expect(validateStellarToml(toml)).toBe(true); + }); + + it("escapes control characters using TOML escape sequences", () => { + const { toml } = report(merchant({ business_name: "A\tB\rC\u0001D\u007fE" })); + expect(toml).toContain('name = "A\\tB\\rC\\u0001D\\u007fE"'); + }); +}); + +describe("org name sanitization", () => { + it("strips bidi override characters used for visual spoofing", () => { + expect(sanitizeOrgName("Acme‮ moc.evil‬")).toEqual({ value: "Acme moc.evil", reason: "bidi_control" }); + }); + + it("truncates by code point, not UTF-16 unit", () => { + const { value, reason } = sanitizeOrgName("\u{1F600}".repeat(300)); + expect(reason).toBe("truncated"); + expect(Array.from(value)).toHaveLength(SEP0001_LIMITS.maxNameLength); + expect(value).not.toMatch(/[\ud800-\udbff]$/); // no dangling surrogate + }); + + it.each([ + [undefined, null], + [null, null], + [" ", "empty"], + [42, "invalid_type"], + [{ toString: () => "x" }, "invalid_type"], + ])("falls back to the default name for %p", (input, reason) => { + expect(sanitizeOrgName(input)).toEqual({ value: DEFAULT_ORG_NAME, reason }); + }); +}); + +describe("field validation", () => { + it("validates Stellar accounts including the StrKey checksum", () => { + expect(isValidStellarAccount(ACCOUNT)).toBe(true); + expect(isValidStellarAccount(BAD_CHECKSUM)).toBe(false); + expect(isValidStellarAccount(SIGNING_SECRET)).toBe(false); + expect(isValidStellarAccount(`${ACCOUNT}\n`)).toBe(false); + expect(isValidStellarAccount(123)).toBe(false); + }); + + it("validates emails", () => { + expect(isValidEmail("a@b.co")).toBe(true); + expect(isValidEmail("a b@c.co")).toBe(false); + expect(isValidEmail('x"@y.co')).toBe(false); + expect(isValidEmail("no-at.example")).toBe(false); + expect(isValidEmail(`${"a".repeat(250)}@b.co`)).toBe(false); + }); + + it("requires https merchant URLs without credentials", () => { + expect(isValidUrl("https://acme.example/logo.png")).toBe(true); + expect(isValidUrl("http://acme.example")).toBe(false); + expect(isValidUrl("javascript:alert(1)")).toBe(false); + expect(isValidUrl("data:image/png;base64,AAAA")).toBe(false); + expect(isValidUrl("https://user:pass@acme.example")).toBe(false); + expect(isValidUrl("https://acme.example/a b")).toBe(false); + expect(isValidUrl(`https://acme.example/${"a".repeat(2100)}`)).toBe(false); + }); + + it("allows http only for localhost when explicitly permitted", () => { + expect(isValidUrl("http://localhost:4000/api", { allowLocalHttp: true })).toBe(true); + expect(isValidUrl("http://127.0.0.1/api", { allowLocalHttp: true })).toBe(true); + expect(isValidUrl("http://api.example.com", { allowLocalHttp: true })).toBe(false); + }); +}); + +describe("generateStellarTomlReport", () => { + it("returns no rejections for a clean merchant", () => { + const { toml, rejections } = report(merchant(), { SIGNING_KEY: SIGNING_PUBLIC }); + expect(rejections).toEqual([]); + expect(toml).toContain(`ACCOUNTS = ["${ACCOUNT}"]`); + expect(toml).toContain(`SIGNING_KEY = "${SIGNING_PUBLIC}"`); + }); + + it("never publishes a secret seed as SIGNING_KEY and reports it", () => { + const { toml, rejections } = report(merchant(), { SIGNING_KEY: SIGNING_SECRET }); + expect(toml).not.toContain(SIGNING_SECRET); + expect(toml).not.toContain("SIGNING_KEY"); + expect(rejections).toContainEqual({ field: "SIGNING_KEY", reason: "secret_seed" }); + }); + + it("omits and reports invalid optional fields instead of publishing them", () => { + const { toml, rejections } = report( + merchant({ + recipient: BAD_CHECKSUM, + email: "not-an-email", + notification_email: "x@y", + branding_config: { homepage: "javascript:alert(1)", logo_url: "http://insecure.example/logo.png" }, + }), + { SIGNING_KEY: "GARBAGE", FEDERATION_SERVER_URL: "ftp://fed.example", DOCS_URL: "https://u:p@docs.example" }, + ); + expect(rejections).toEqual([ + { field: "FEDERATION_SERVER_URL", reason: "invalid_url" }, + { field: "recipient", reason: "invalid_account" }, + { field: "DOCS_URL", reason: "invalid_url" }, + { field: "SIGNING_KEY", reason: "invalid_account" }, + { field: "email", reason: "invalid_email" }, + { field: "notification_email", reason: "invalid_email" }, + { field: "homepage", reason: "invalid_url" }, + { field: "logo_url", reason: "invalid_url" }, + ]); + expect(keysOf(toml)).toEqual(["NETWORK_PASSPHRASE", "TRANSFER_SERVER", "[ORG]", "name"]); + expect(validateStellarToml(toml)).toBe(true); + }); + + it("ignores a non-object branding_config", () => { + const { toml } = report(merchant({ branding_config: "https://evil.example" })); + expect(toml).not.toContain("homepage"); + }); + + it.each([null, undefined, "merchant", 42, ["a"]])("rejects a non-object merchant (%p)", (input) => { + expect(() => report(input)).toThrow("Merchant configuration required"); + }); + + it("throws a typed config error for an invalid TRANSFER_SERVER", () => { + expect(() => report(merchant(), { TRANSFER_SERVER_URL: "http://public.example/api" })).toThrow( + Sep0001ConfigError, + ); + expect(() => report(merchant(), { API_BASE_URL: "ftp://broken" })).toThrow( + expect.objectContaining({ code: SEP0001_INVALID_CONFIG, field: "TRANSFER_SERVER_URL" }), + ); + }); + + it.each([ + "x".repeat(SEP0001_LIMITS.maxPassphraseLength + 1), + "Test SDF Network\n[ORG]", + ])("throws a typed config error for an unsafe network passphrase", (passphrase) => { + expect(() => report(merchant(), { STELLAR_NETWORK_PASSPHRASE: passphrase })).toThrow( + expect.objectContaining({ field: "STELLAR_NETWORK_PASSPHRASE" }), + ); + }); + + it("normalises a trailing slash on API_BASE_URL", () => { + const { toml } = report(merchant(), { API_BASE_URL: "https://api.example.com/" }); + expect(toml).toContain('TRANSFER_SERVER = "https://api.example.com/api"'); + expect(toml).toContain('DOCUMENTATION = "https://api.example.com/api-docs"'); + }); + + it("keeps generateStellarToml backwards compatible (string output)", () => { + expect(typeof generateStellarToml(merchant(), { env: ENV })).toBe("string"); + }); +}); + +describe("validateStellarToml strictness", () => { + const base = 'NETWORK_PASSPHRASE = "x"\nTRANSFER_SERVER = "https://a"'; + + it("requires the keys at the start of a line", () => { + expect(validateStellarToml(base)).toBe(true); + expect(validateStellarToml('name = "NETWORK_PASSPHRASE TRANSFER_SERVER"')).toBe(false); + }); + + it("rejects raw control characters and oversized documents", () => { + expect(validateStellarToml(`${base}\r\nX = "1"`)).toBe(false); + expect(validateStellarToml(`${base}\u0000`)).toBe(false); + expect(validateStellarToml(`${base}\n# ${"a".repeat(SEP0001_LIMITS.maxDocumentBytes)}`)).toBe(false); + }); + + it("rejects non-string input", () => { + expect(validateStellarToml({ toString: () => base })).toBe(false); + }); +}); diff --git a/backend/src/lib/sep0001-generator.test.js b/backend/src/lib/sep0001-generator.test.js index 6ca2ba4..62afad0 100644 --- a/backend/src/lib/sep0001-generator.test.js +++ b/backend/src/lib/sep0001-generator.test.js @@ -14,7 +14,7 @@ describe("generateStellarToml", () => { business_name: "Test Merchant", email: "merchant@example.com", notification_email: "notify@example.com", - recipient: "GBUQWP3BOUZX34ULNQG23RQ6F4YUSXHTQSXUSMIQSTBE2BRUY4DQAT2B", + recipient: "GDVEU3DD4KOFECV66VIHWEZOYX4ZKR3WV27L464SIIPOU2IUI3JCZA57", branding_config: {}, }; @@ -32,7 +32,7 @@ describe("generateStellarToml", () => { business_name: "Test Merchant", email: "merchant@example.com", notification_email: "notify@example.com", - recipient: "GBUQWP3BOUZX34ULNQG23RQ6F4YUSXHTQSXUSMIQSTBE2BRUY4DQAT2B", + recipient: "GDVEU3DD4KOFECV66VIHWEZOYX4ZKR3WV27L464SIIPOU2IUI3JCZA57", branding_config: {}, }; @@ -47,7 +47,7 @@ describe("generateStellarToml", () => { business_name: "Test Merchant", email: "merchant@example.com", notification_email: "notify@example.com", - recipient: "GBUQWP3BOUZX34ULNQG23RQ6F4YUSXHTQSXUSMIQSTBE2BRUY4DQAT2B", + recipient: "GDVEU3DD4KOFECV66VIHWEZOYX4ZKR3WV27L464SIIPOU2IUI3JCZA57", branding_config: { homepage: "https://example.com", logo_url: "https://example.com/logo.png", @@ -65,7 +65,7 @@ describe("generateStellarToml", () => { business_name: 'Test "Merchant" with\nnewline', email: "merchant@example.com", notification_email: "notify@example.com", - recipient: "GBUQWP3BOUZX34ULNQG23RQ6F4YUSXHTQSXUSMIQSTBE2BRUY4DQAT2B", + recipient: "GDVEU3DD4KOFECV66VIHWEZOYX4ZKR3WV27L464SIIPOU2IUI3JCZA57", branding_config: {}, }; diff --git a/backend/src/lib/sep0001-telemetry.js b/backend/src/lib/sep0001-telemetry.js new file mode 100644 index 0000000..b3d1dfb --- /dev/null +++ b/backend/src/lib/sep0001-telemetry.js @@ -0,0 +1,279 @@ +import client from "prom-client"; + +/** + * SEP-0001 Stellar Info Generator alert metrics and health telemetry + * (issue #1458). + * + * Tracks every GET /.well-known/stellar.toml request: + * + * - outcome counts and generation latency + * - published document size (SEP-0001 caps stellar.toml at 100 KB) + * - fields rejected by sanitization (issue #1457), e.g. a secret seed + * configured as SIGNING_KEY or an invalid merchant account + * - rolling-window health derived from the server-side error ratio + * + * Label values come from fixed, server-defined sets (never from request data + * such as merchant_id), so clients cannot inflate series cardinality. + * + * The metrics live in their own registry; GET /metrics merges it with the + * main registry. + */ + +const register = new client.Registry(); + +register.setDefaultLabels({ + app: "stellar-payment-api", +}); + +/** success | invalid_request | not_found | lookup_error | generation_error */ +export const SEP0001_OUTCOMES = Object.freeze([ + "success", + "invalid_request", + "not_found", + "lookup_error", + "generation_error", +]); + +const SERVER_ERROR_OUTCOMES = new Set(["lookup_error", "generation_error"]); + +export const SEP0001_REJECTION_FIELDS = Object.freeze([ + "STELLAR_NETWORK_PASSPHRASE", + "TRANSFER_SERVER_URL", + "FEDERATION_SERVER_URL", + "DOCS_URL", + "SIGNING_KEY", + "recipient", + "business_name", + "email", + "notification_email", + "homepage", + "logo_url", +]); + +export const SEP0001_REJECTION_REASONS = Object.freeze([ + "invalid_url", + "invalid_account", + "secret_seed", + "invalid_email", + "invalid_type", + "bidi_control", + "truncated", + "empty", +]); + +export const sep0001RequestsTotal = new client.Counter({ + name: "sep0001_requests_total", + help: "Total stellar.toml requests, by outcome", + labelNames: ["outcome"], +}); + +export const sep0001GenerationDuration = new client.Histogram({ + name: "sep0001_generation_duration_seconds", + help: "Time to serve a stellar.toml request (lookup + generation), by outcome", + labelNames: ["outcome"], + buckets: [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5], +}); + +export const sep0001DocumentSizeBytes = new client.Histogram({ + name: "sep0001_document_size_bytes", + help: "Size of published stellar.toml documents in bytes", + buckets: [256, 512, 1024, 2048, 4096, 16384, 65536, 102400], +}); + +export const sep0001RejectedFieldsTotal = new client.Counter({ + name: "sep0001_rejected_fields_total", + help: "stellar.toml fields omitted or altered by sanitization, by field and reason", + labelNames: ["field", "reason"], +}); + +export const sep0001HealthState = new client.Gauge({ + name: "sep0001_health_state", + help: "SEP-0001 generator health (0=healthy, 1=degraded, 2=unhealthy)", +}); + +export const sep0001ErrorRatio = new client.Gauge({ + name: "sep0001_error_ratio", + help: "Rolling-window ratio of server-side errors to stellar.toml requests", +}); + +export const sep0001LastSuccessTimestamp = new client.Gauge({ + name: "sep0001_last_success_timestamp_seconds", + help: "Unix time of the last successfully served stellar.toml", +}); + +register.registerMetric(sep0001RequestsTotal); +register.registerMetric(sep0001GenerationDuration); +register.registerMetric(sep0001DocumentSizeBytes); +register.registerMetric(sep0001RejectedFieldsTotal); +register.registerMetric(sep0001HealthState); +register.registerMetric(sep0001ErrorRatio); +register.registerMetric(sep0001LastSuccessTimestamp); + +export { register as sep0001Register }; + +const HEALTH_STATE_VALUES = { healthy: 0, degraded: 1, unhealthy: 2 }; + +function readNumberEnv(name, fallback) { + const raw = Number.parseFloat(process.env[name] ?? ""); + return Number.isFinite(raw) && raw >= 0 ? raw : fallback; +} + +export const DEFAULT_SEP0001_HEALTH_OPTIONS = Object.freeze({ + windowMs: readNumberEnv("SEP0001_HEALTH_WINDOW_MS", 300_000), + bucketMs: 5_000, + minSamples: readNumberEnv("SEP0001_HEALTH_MIN_SAMPLES", 10), + degradedErrorRatio: readNumberEnv("SEP0001_DEGRADED_ERROR_RATIO", 0.05), + unhealthyErrorRatio: readNumberEnv("SEP0001_UNHEALTHY_ERROR_RATIO", 0.25), + sensitiveRejectionThreshold: readNumberEnv("SEP0001_SENSITIVE_REJECTION_THRESHOLD", 1), +}); + +/** + * Rolling-window health monitor. Fixed-size bucket ring, so memory is + * constant regardless of traffic. + * + * unhealthy: error ratio >= unhealthyErrorRatio (with >= minSamples) + * degraded: error ratio >= degradedErrorRatio (with >= minSamples), or a + * secret seed was refused as SIGNING_KEY inside the window + */ +export class Sep0001HealthMonitor { + constructor(options = {}, now = () => Date.now()) { + this.options = { ...DEFAULT_SEP0001_HEALTH_OPTIONS, ...options }; + this.now = now; + this.bucketCount = Math.max(1, Math.ceil(this.options.windowMs / this.options.bucketMs)); + this.reset(); + } + + reset() { + this.buckets = Array.from({ length: this.bucketCount }, () => this._emptyBucket(-1)); + this.lastSuccessAt = null; + this.lastErrorAt = null; + } + + _emptyBucket(slot) { + const bucket = { slot, sensitiveRejections: 0 }; + for (const outcome of SEP0001_OUTCOMES) bucket[outcome] = 0; + return bucket; + } + + _bucketFor(time) { + const slot = Math.floor(time / this.options.bucketMs); + const index = slot % this.bucketCount; + if (this.buckets[index].slot !== slot) this.buckets[index] = this._emptyBucket(slot); + return this.buckets[index]; + } + + record(outcome, { sensitiveRejection = false } = {}) { + const time = this.now(); + const bucket = this._bucketFor(time); + if (SEP0001_OUTCOMES.includes(outcome)) bucket[outcome] += 1; + if (sensitiveRejection) bucket.sensitiveRejections += 1; + if (outcome === "success") this.lastSuccessAt = time; + if (SERVER_ERROR_OUTCOMES.has(outcome)) this.lastErrorAt = time; + } + + snapshot() { + const time = this.now(); + const currentSlot = Math.floor(time / this.options.bucketMs); + const oldestSlot = currentSlot - this.bucketCount + 1; + const counts = { sensitive_rejections: 0 }; + for (const outcome of SEP0001_OUTCOMES) counts[outcome] = 0; + + for (const bucket of this.buckets) { + if (bucket.slot < oldestSlot || bucket.slot > currentSlot) continue; + for (const outcome of SEP0001_OUTCOMES) counts[outcome] += bucket[outcome]; + counts.sensitive_rejections += bucket.sensitiveRejections; + } + + const total = SEP0001_OUTCOMES.reduce((sum, outcome) => sum + counts[outcome], 0); + const errors = counts.lookup_error + counts.generation_error; + const errorRatio = total > 0 ? errors / total : 0; + const { + minSamples, + degradedErrorRatio, + unhealthyErrorRatio, + sensitiveRejectionThreshold, + } = this.options; + + const reasons = []; + let status = "healthy"; + if (total >= minSamples && errorRatio >= unhealthyErrorRatio) { + status = "unhealthy"; + reasons.push("error_ratio_exceeded"); + } else if (total >= minSamples && errorRatio >= degradedErrorRatio) { + status = "degraded"; + reasons.push("error_ratio_elevated"); + } + if (sensitiveRejectionThreshold > 0 && counts.sensitive_rejections >= sensitiveRejectionThreshold) { + if (status === "healthy") status = "degraded"; + reasons.push("secret_signing_key_refused"); + } + + return { + status, + reasons, + window_ms: this.bucketCount * this.options.bucketMs, + total, + ...counts, + error_ratio: Number(errorRatio.toFixed(4)), + last_success_at: this.lastSuccessAt === null ? null : new Date(this.lastSuccessAt).toISOString(), + last_error_at: this.lastErrorAt === null ? null : new Date(this.lastErrorAt).toISOString(), + thresholds: { + min_samples: minSamples, + degraded_error_ratio: degradedErrorRatio, + unhealthy_error_ratio: unhealthyErrorRatio, + sensitive_rejections: sensitiveRejectionThreshold, + }, + }; + } +} + +const healthMonitor = new Sep0001HealthMonitor(); + +// Refresh rolling-window gauges at scrape time so they decay when idle. +sep0001HealthState.collect = function collectHealthState() { + const snap = healthMonitor.snapshot(); + this.set(HEALTH_STATE_VALUES[snap.status]); + sep0001ErrorRatio.set(snap.error_ratio); +}; + +/** + * Record one stellar.toml request. + * + * @param {object} params + * @param {string} params.outcome One of SEP0001_OUTCOMES + * @param {number} params.durationSeconds + * @param {number} [params.documentBytes] Size of the served document (success only) + * @param {Array<{field:string, reason:string}>} [params.rejections] + */ +export function recordSep0001Request({ outcome, durationSeconds, documentBytes, rejections = [] }) { + const label = SEP0001_OUTCOMES.includes(outcome) ? outcome : "generation_error"; + sep0001RequestsTotal.inc({ outcome: label }); + if (Number.isFinite(durationSeconds) && durationSeconds >= 0) { + sep0001GenerationDuration.observe({ outcome: label }, durationSeconds); + } + if (label === "success") { + if (Number.isFinite(documentBytes)) sep0001DocumentSizeBytes.observe(documentBytes); + sep0001LastSuccessTimestamp.set(Date.now() / 1000); + } + + let sensitiveRejection = false; + for (const { field, reason } of rejections) { + sep0001RejectedFieldsTotal.inc({ + field: SEP0001_REJECTION_FIELDS.includes(field) ? field : "other", + reason: SEP0001_REJECTION_REASONS.includes(reason) ? reason : "other", + }); + if (field === "SIGNING_KEY" && reason === "secret_seed") sensitiveRejection = true; + } + + healthMonitor.record(label, { sensitiveRejection }); +} + +/** Current generator health, for /health endpoints. */ +export function getSep0001Health() { + return healthMonitor.snapshot(); +} + +/** Test-only: clear the rolling health window. */ +export function resetSep0001Health() { + healthMonitor.reset(); +} diff --git a/backend/src/lib/sep0001-telemetry.test.js b/backend/src/lib/sep0001-telemetry.test.js new file mode 100644 index 0000000..5613ad1 --- /dev/null +++ b/backend/src/lib/sep0001-telemetry.test.js @@ -0,0 +1,153 @@ +/** + * SEP-0001 alert metrics and health telemetry (#1458). + */ + +import { beforeEach, describe, expect, it } from "vitest"; +import { + Sep0001HealthMonitor, + getSep0001Health, + recordSep0001Request, + resetSep0001Health, + sep0001DocumentSizeBytes, + sep0001GenerationDuration, + sep0001LastSuccessTimestamp, + sep0001Register, + sep0001RejectedFieldsTotal, + sep0001RequestsTotal, +} from "./sep0001-telemetry.js"; + +async function value(metric, labels = {}) { + const { values } = await metric.get(); + return values + .filter((v) => Object.entries(labels).every(([k, val]) => v.labels[k] === val)) + .reduce((sum, v) => sum + v.value, 0); +} + +beforeEach(() => { + sep0001Register.resetMetrics(); + resetSep0001Health(); +}); + +describe("recordSep0001Request", () => { + it("counts outcomes, latency, size and last success", async () => { + const before = Date.now() / 1000; + recordSep0001Request({ outcome: "success", durationSeconds: 0.004, documentBytes: 512 }); + recordSep0001Request({ outcome: "not_found", durationSeconds: 0.002 }); + + expect(await value(sep0001RequestsTotal, { outcome: "success" })).toBe(1); + expect(await value(sep0001RequestsTotal, { outcome: "not_found" })).toBe(1); + const { values } = await sep0001GenerationDuration.get(); + expect(values.find((v) => v.metricName.endsWith("_count") && v.labels.outcome === "success").value).toBe(1); + const size = await sep0001DocumentSizeBytes.get(); + expect(size.values.find((v) => v.metricName.endsWith("_sum")).value).toBe(512); + expect(await value(sep0001LastSuccessTimestamp)).toBeGreaterThanOrEqual(before); + }); + + it("maps unknown outcomes, fields and reasons to bounded labels", async () => { + recordSep0001Request({ + outcome: "merchant-1234", + durationSeconds: 0.001, + rejections: [ + { field: "x-injected-label", reason: "anything" }, + { field: "email", reason: "invalid_email" }, + ], + }); + expect(await value(sep0001RequestsTotal, { outcome: "generation_error" })).toBe(1); + expect(await value(sep0001RejectedFieldsTotal, { field: "other", reason: "other" })).toBe(1); + expect(await value(sep0001RejectedFieldsTotal, { field: "email", reason: "invalid_email" })).toBe(1); + }); + + it("ignores invalid durations instead of throwing", () => { + expect(() => recordSep0001Request({ outcome: "success", durationSeconds: Number.NaN })).not.toThrow(); + expect(() => recordSep0001Request({ outcome: "success", durationSeconds: -1 })).not.toThrow(); + }); + + it("feeds the module health monitor", () => { + recordSep0001Request({ outcome: "success", durationSeconds: 0 }); + expect(getSep0001Health()).toMatchObject({ status: "healthy", total: 1, success: 1 }); + }); + + it("degrades health when a secret seed is refused as SIGNING_KEY", () => { + recordSep0001Request({ + outcome: "success", + durationSeconds: 0, + rejections: [{ field: "SIGNING_KEY", reason: "secret_seed" }], + }); + expect(getSep0001Health()).toMatchObject({ + status: "degraded", + reasons: ["secret_signing_key_refused"], + sensitive_rejections: 1, + }); + }); + + it("exposes health state and error ratio through the registry at scrape time", async () => { + for (let i = 0; i < 10; i += 1) { + recordSep0001Request({ outcome: i < 5 ? "lookup_error" : "success", durationSeconds: 0 }); + } + const text = await sep0001Register.metrics(); + expect(text).toMatch(/sep0001_health_state\{[^}]*\} 2/); + expect(text).toMatch(/sep0001_error_ratio\{[^}]*\} 0\.5/); + }); +}); + +describe("Sep0001HealthMonitor", () => { + const options = { + windowMs: 60_000, + bucketMs: 1_000, + minSamples: 10, + degradedErrorRatio: 0.05, + unhealthyErrorRatio: 0.25, + sensitiveRejectionThreshold: 1, + }; + + function monitorAt(start = 1_700_000_000_000) { + let now = start; + const monitor = new Sep0001HealthMonitor(options, () => now); + return { monitor, advance: (ms) => (now += ms) }; + } + + it("stays healthy below minSamples even when every request fails", () => { + const { monitor } = monitorAt(); + for (let i = 0; i < 9; i += 1) monitor.record("generation_error"); + expect(monitor.snapshot()).toMatchObject({ status: "healthy", total: 9, error_ratio: 1 }); + }); + + it("is degraded between the two error thresholds", () => { + const { monitor } = monitorAt(); + for (let i = 0; i < 18; i += 1) monitor.record("success"); + monitor.record("lookup_error"); + monitor.record("lookup_error"); + expect(monitor.snapshot()).toMatchObject({ status: "degraded", reasons: ["error_ratio_elevated"], error_ratio: 0.1 }); + }); + + it("is unhealthy at or above the unhealthy threshold", () => { + const { monitor } = monitorAt(); + for (let i = 0; i < 6; i += 1) monitor.record("success"); + for (let i = 0; i < 4; i += 1) monitor.record("generation_error"); + expect(monitor.snapshot()).toMatchObject({ status: "unhealthy", reasons: ["error_ratio_exceeded"] }); + }); + + it("does not count client errors (400/404) as server errors", () => { + const { monitor } = monitorAt(); + for (let i = 0; i < 20; i += 1) monitor.record(i % 2 ? "invalid_request" : "not_found"); + expect(monitor.snapshot()).toMatchObject({ status: "healthy", error_ratio: 0, invalid_request: 10, not_found: 10 }); + }); + + it("recovers once errors age out of the rolling window", () => { + const { monitor, advance } = monitorAt(); + for (let i = 0; i < 20; i += 1) monitor.record("lookup_error"); + expect(monitor.snapshot().status).toBe("unhealthy"); + advance(61_000); + expect(monitor.snapshot()).toMatchObject({ status: "healthy", total: 0 }); + expect(monitor.snapshot().last_error_at).not.toBeNull(); + }); + + it("uses constant memory regardless of traffic", () => { + const { monitor, advance } = monitorAt(); + for (let i = 0; i < 5_000; i += 1) { + monitor.record("success"); + advance(50); + } + expect(monitor.buckets).toHaveLength(60); + }); +}); diff --git a/backend/src/lib/websocket-relay-security.js b/backend/src/lib/websocket-relay-security.js index e0bd640..1f36e13 100644 --- a/backend/src/lib/websocket-relay-security.js +++ b/backend/src/lib/websocket-relay-security.js @@ -179,11 +179,14 @@ function sanitizeRelayMessage(msg) { function auditRelayEvent(event, metadata = {}, opts = {}) { const emit = opts.emit || console.log; + // Metadata is spread FIRST so caller-supplied context (which may contain + // client-controlled values) can never overwrite the audit marker, the + // timestamp or the event name (issue #1456 integration finding). const entry = { + ...(metadata && typeof metadata === "object" ? metadata : {}), audit: true, ts: new Date().toISOString(), event, - ...metadata, }; emit(JSON.stringify(entry)); diff --git a/backend/src/lib/websocket-relay.integration.test.js b/backend/src/lib/websocket-relay.integration.test.js new file mode 100644 index 0000000..3ca1c03 --- /dev/null +++ b/backend/src/lib/websocket-relay.integration.test.js @@ -0,0 +1,383 @@ +/** + * WebSocket Relay Service — integration suite (issue #1456). + * + * Wires the real relay modules together the way a relay worker does: + * + * inbound frame -> origin/token checks -> size limit -> JSON parse + * -> sanitize -> batchInsertEvents -> dequeueNextEvent + * -> deliver through CircuitBreaker -> markEventComplete / markEventFailed + * -> DeadLetterQueue -> health snapshot + Prometheus metrics + * + * Only the database is faked (tests/helpers/fake-relay-db.js). It interprets + * the exact SQL the query helpers emit. + */ + +import { describe, it, expect, beforeEach, vi } from "vitest"; +import jwt from "jsonwebtoken"; +import { createFakeRelayDb } from "../../tests/helpers/fake-relay-db.js"; +import { + auditRelayEvent, + enforceMessageSizeLimit, + sanitizeRelayMessage, + validateOrigin, + verifyRelayToken, +} from "./websocket-relay-security.js"; +import { + CircuitBreaker, + CircuitState, + DeadLetterQueue, + getRelayHealth, + reconnectWithBackoff, +} from "./websocket-relay-recovery.js"; +import { + batchInsertEvents, + dequeueNextEvent, + getRecentEvents, + markEventComplete, + markEventFailed, +} from "./websocket-relay-queries.js"; +import { + relayDlqMessagesMovedTotal, + relayDlqSize, + relayEventsDequeuedTotal, + relayEventsEnqueuedTotal, + relayEventsFailedTotal, + relayEventsProcessedTotal, + relayMessagesReceivedTotal, + relayMetricsRegister, +} from "./websocket-relay-metrics.js"; + +const SECRET = "relay-integration-secret"; +const ALLOWED_ORIGINS = ["https://dashboard.example.com"]; +const MAX_FRAME_BYTES = 4096; + +async function metricValue(metric, labels = {}) { + const { values } = await metric.get(); + return values + .filter((v) => Object.entries(labels).every(([k, val]) => v.labels[k] === val)) + .reduce((sum, v) => sum + v.value, 0); +} + +/** + * Minimal relay worker composed from the production modules. + */ +function createRelay({ db, deliver, breaker = new CircuitBreaker({ failureThreshold: 3, resetTimeoutMs: 1000 }) }) { + const dlq = new DeadLetterQueue(); + const audit = []; + const emit = (line) => audit.push(JSON.parse(line)); + + function authenticate({ origin, token }) { + const originCheck = validateOrigin(origin, ALLOWED_ORIGINS); + if (!originCheck.valid) { + auditRelayEvent("connection.rejected", { reason: originCheck.reason }, { emit }); + return { ok: false, reason: originCheck.reason }; + } + const tokenCheck = verifyRelayToken(token, SECRET); + if (!tokenCheck.valid) { + auditRelayEvent("connection.rejected", { reason: tokenCheck.reason }, { emit }); + return { ok: false, reason: tokenCheck.reason }; + } + auditRelayEvent("connection.accepted", { sub: tokenCheck.payload.sub }, { emit }); + return { ok: true, claims: tokenCheck.payload }; + } + + async function ingest(frames) { + const accepted = []; + const rejected = []; + for (const frame of frames) { + try { + enforceMessageSizeLimit(frame, MAX_FRAME_BYTES); + const { sanitized } = sanitizeRelayMessage(JSON.parse(frame)); + relayMessagesReceivedTotal.inc({ type: "normal" }); + accepted.push(sanitized); + } catch (err) { + relayMessagesReceivedTotal.inc({ type: "error" }); + rejected.push(err.code ?? err.message); + } + } + const events = accepted.map((m) => ({ + payment_id: m.payment_id, + event_type: m.event_type, + payload: m.payload, + })); + const inserted = await batchInsertEvents(db, events); + for (const e of inserted) relayEventsEnqueuedTotal.inc({ event_type: e.event_type }); + return { inserted, rejected }; + } + + async function processOne() { + const event = await dequeueNextEvent(db); + if (!event) return null; + relayEventsDequeuedTotal.inc(); + try { + await breaker.call(() => deliver(event)); + await markEventComplete(db, event.id); + relayEventsProcessedTotal.inc({ event_type: event.event_type }); + return { id: event.id, status: "completed" }; + } catch (err) { + const reason = err.circuitOpen ? "circuit_open" : "delivery_failed"; + await markEventFailed(db, event.id, err.message); + dlq.push(event, err, 1); + relayEventsFailedTotal.inc({ event_type: event.event_type, reason }); + relayDlqMessagesMovedTotal.inc({ reason }); + relayDlqSize.set(dlq.size); + return { id: event.id, status: "failed", reason }; + } + } + + async function drain() { + const results = []; + for (let r = await processOne(); r; r = await processOne()) results.push(r); + return results; + } + + return { authenticate, ingest, processOne, drain, dlq, breaker, audit }; +} + +/** Hand-built `alg: none` JWT (jsonwebtoken refuses to sign one). */ +function unsignedToken(payload) { + const encode = (obj) => Buffer.from(JSON.stringify(obj)).toString("base64url"); + return `${encode({ alg: "none", typ: "JWT" })}.${encode(payload)}.`; +} + +const frame = (overrides = {}) => + JSON.stringify({ + type: "payment.event", + payment_id: "pay_1", + event_type: "payment.confirmed", + payload: { amount: "10" }, + ...overrides, + }); + +beforeEach(() => { + relayMetricsRegister.resetMetrics(); +}); + +describe("relay connection handshake", () => { + it("accepts a whitelisted origin with a valid HS256 token", () => { + const relay = createRelay({ db: createFakeRelayDb(), deliver: vi.fn() }); + const token = jwt.sign({ sub: "merchant_1" }, SECRET, { algorithm: "HS256", expiresIn: "1m" }); + const result = relay.authenticate({ origin: ALLOWED_ORIGINS[0], token }); + expect(result).toMatchObject({ ok: true, claims: { sub: "merchant_1" } }); + expect(relay.audit.at(-1)).toMatchObject({ audit: true, event: "connection.accepted", sub: "merchant_1" }); + }); + + it.each([ + ["unknown origin", { origin: "https://evil.example.com" }], + ["missing origin", { origin: "" }], + ["origin differing only by case", { origin: "https://Dashboard.example.com" }], + ])("rejects %s before looking at the token", (_label, { origin }) => { + const relay = createRelay({ db: createFakeRelayDb(), deliver: vi.fn() }); + const token = jwt.sign({ sub: "m" }, SECRET); + expect(relay.authenticate({ origin, token }).ok).toBe(false); + expect(relay.audit.at(-1).event).toBe("connection.rejected"); + }); + + it.each([ + ["wrong secret", () => jwt.sign({ sub: "m" }, "other-secret")], + ["expired token", () => jwt.sign({ sub: "m", exp: Math.floor(Date.now() / 1000) - 10 }, SECRET)], + ["alg=none token", () => unsignedToken({ sub: "m" })], + ["HS512 token (algorithm pinned to HS256)", () => jwt.sign({ sub: "m" }, SECRET, { algorithm: "HS512" })], + ["garbage", () => "not.a.jwt"], + ])("rejects a %s", (_label, makeToken) => { + const relay = createRelay({ db: createFakeRelayDb(), deliver: vi.fn() }); + const result = relay.authenticate({ origin: ALLOWED_ORIGINS[0], token: makeToken() }); + expect(result.ok).toBe(false); + }); + + it("audit entries cannot be forged through metadata", () => { + const lines = []; + auditRelayEvent( + "connection.rejected", + { audit: false, event: "connection.accepted", ts: "1970-01-01T00:00:00Z", reason: "x" }, + { emit: (l) => lines.push(JSON.parse(l)) }, + ); + expect(lines[0]).toMatchObject({ audit: true, event: "connection.rejected", reason: "x" }); + expect(lines[0].ts).not.toBe("1970-01-01T00:00:00Z"); + }); +}); + +describe("relay message pipeline", () => { + it("ingests, dequeues, delivers and completes events end to end", async () => { + const db = createFakeRelayDb(); + const deliver = vi.fn().mockResolvedValue(undefined); + const relay = createRelay({ db, deliver }); + + const { inserted, rejected } = await relay.ingest([ + frame({ payment_id: "pay_1" }), + frame({ payment_id: "pay_2", event_type: "payment.failed" }), + ]); + expect(inserted).toHaveLength(2); + expect(rejected).toEqual([]); + + const results = await relay.drain(); + expect(results.map((r) => r.status)).toEqual(["completed", "completed"]); + expect(deliver.mock.calls.map(([e]) => e.payment_id)).toEqual(["pay_1", "pay_2"]); // FIFO + expect(db.countBy("completed")).toBe(2); + expect(await metricValue(relayEventsEnqueuedTotal)).toBe(2); + expect(await metricValue(relayEventsProcessedTotal)).toBe(2); + expect(getRelayHealth({ circuitBreaker: relay.breaker, dlq: relay.dlq }).status).toBe("healthy"); + }); + + it("strips unknown and prototype-polluting fields before persisting", async () => { + const db = createFakeRelayDb(); + const relay = createRelay({ db, deliver: vi.fn() }); + const raw = '{"type":"payment.event","payment_id":"pay_9","event_type":"x","__proto__":{"admin":true},"sql":"DROP TABLE"}'; + + await relay.ingest([raw]); + + const stored = db.rows[0]; + expect(stored.payment_id).toBe("pay_9"); + expect(stored).not.toHaveProperty("sql"); + expect({}.admin).toBeUndefined(); + const insert = db.statements.find((s) => /INSERT/.test(s.sql)); + expect(insert.params).not.toContain("DROP TABLE"); + expect(insert.sql).not.toContain("DROP TABLE"); // values are parameterised + }); + + it("rejects oversized, malformed and typeless frames without touching the database", async () => { + const db = createFakeRelayDb(); + const relay = createRelay({ db, deliver: vi.fn() }); + const { inserted, rejected } = await relay.ingest([ + "x".repeat(MAX_FRAME_BYTES + 1), + "{not json", + JSON.stringify({ payment_id: "p" }), + JSON.stringify([1, 2]), + ]); + expect(inserted).toEqual([]); + expect(rejected[0]).toBe("MESSAGE_TOO_LARGE"); + expect(rejected).toHaveLength(4); + expect(db.statements).toHaveLength(0); + expect(await metricValue(relayMessagesReceivedTotal, { type: "error" })).toBe(4); + }); + + it("rejects the whole batch when a frame lacks payment_id (validated before SQL)", async () => { + const db = createFakeRelayDb(); + const relay = createRelay({ db, deliver: vi.fn() }); + await expect(relay.ingest([frame(), frame({ payment_id: " " })])).rejects.toThrow( + /Invalid events for batch insert/, + ); + expect(db.rows).toHaveLength(0); + }); + + it("marks failed deliveries, dead-letters them and opens the circuit", async () => { + const db = createFakeRelayDb(); + const deliver = vi.fn().mockRejectedValue(new Error("socket hang up")); + const relay = createRelay({ db, deliver }); + await relay.ingest(Array.from({ length: 5 }, (_, i) => frame({ payment_id: `pay_${i}` }))); + + const results = await relay.drain(); + + expect(deliver).toHaveBeenCalledTimes(3); // threshold reached, then fast-fail + expect(results.map((r) => r.reason)).toEqual([ + "delivery_failed", + "delivery_failed", + "delivery_failed", + "circuit_open", + "circuit_open", + ]); + expect(relay.breaker.state).toBe(CircuitState.OPEN); + expect(db.countBy("failed")).toBe(5); + expect(db.rows[0].error_message).toBe("socket hang up"); + expect(relay.dlq.size).toBe(5); + expect(await metricValue(relayDlqSize)).toBe(5); + expect(await metricValue(relayDlqMessagesMovedTotal, { reason: "circuit_open" })).toBe(2); + expect(getRelayHealth({ circuitBreaker: relay.breaker, dlq: relay.dlq })).toMatchObject({ + status: "degraded", + circuitState: "OPEN", + dlqSize: 5, + lastError: "socket hang up", + }); + }); + + it("recovers through HALF_OPEN and replays the dead letter queue", async () => { + let now = 0; + const breaker = new CircuitBreaker({ failureThreshold: 1, resetTimeoutMs: 1000, now: () => now }); + const db = createFakeRelayDb(); + const deliver = vi.fn().mockRejectedValueOnce(new Error("down")).mockResolvedValue(undefined); + const relay = createRelay({ db, deliver, breaker }); + await relay.ingest([frame({ payment_id: "a" }), frame({ payment_id: "b" })]); + + await relay.drain(); + expect(relay.dlq.size).toBe(2); + + now += 1000; + const replayed = []; + for (let entry = relay.dlq.shift(); entry; entry = relay.dlq.shift()) { + await breaker.call(() => deliver(entry.message)); + replayed.push(entry.message.payment_id); + } + expect(replayed).toEqual(["a", "b"]); + expect(breaker.state).toBe(CircuitState.CLOSED); + expect(getRelayHealth({ circuitBreaker: breaker, dlq: relay.dlq }).status).toBe("healthy"); + }); + + it("does not complete an event twice", async () => { + const db = createFakeRelayDb(); + const relay = createRelay({ db, deliver: vi.fn() }); + await relay.ingest([frame()]); + const [{ id }] = await relay.drain(); + + await expect(markEventComplete(db, id)).resolves.toBeNull(); + await expect(markEventFailed(db, id, "late failure")).resolves.toBeNull(); + expect(db.rows[0].status).toBe("completed"); + }); + + it("wraps database failures with context and keeps the event pending", async () => { + const db = createFakeRelayDb(); + const relay = createRelay({ db, deliver: vi.fn() }); + await relay.ingest([frame()]); + db.failNext(1); + + await expect(relay.processOne()).rejects.toThrow(/Failed to dequeue relay event: connection terminated/); + expect(db.countBy("pending")).toBe(1); + await expect(relay.processOne()).resolves.toMatchObject({ status: "completed" }); + }); + + it("pages recent events newest first with clamped limits", async () => { + const db = createFakeRelayDb(); + const relay = createRelay({ db, deliver: vi.fn() }); + await relay.ingest(Array.from({ length: 5 }, (_, i) => frame({ payment_id: `p${i}` }))); + + const page = await getRecentEvents(db, 2, 1); + expect(page.map((r) => r.payment_id)).toEqual(["p3", "p2"]); + expect(await getRecentEvents(db, -5, -1)).toHaveLength(1); // limit clamped to 1, offset to 0 + const last = db.statements.at(-1); + expect(last.params).toEqual([1, 0]); + }); +}); + +describe("relay reconnection", () => { + it("reconnects after transient failures with growing, capped delays", async () => { + const delays = []; + const connect = vi + .fn() + .mockRejectedValueOnce(new Error("ECONNREFUSED")) + .mockRejectedValueOnce(new Error("ECONNREFUSED")) + .mockRejectedValueOnce(new Error("ECONNREFUSED")) + .mockResolvedValue("socket"); + + await expect( + reconnectWithBackoff(connect, { + maxRetries: 5, + baseDelayMs: 100, + maxDelayMs: 250, + sleep: async (ms) => delays.push(ms), + }), + ).resolves.toBe("socket"); + + expect(delays).toHaveLength(3); + expect(delays[0]).toBeGreaterThanOrEqual(90); + expect(delays[0]).toBeLessThanOrEqual(110); + expect(delays[1]).toBeGreaterThanOrEqual(180); + expect(delays[2]).toBeLessThanOrEqual(275); // capped at 250 +10% jitter + }); + + it("surfaces the last error when every attempt fails", async () => { + const connect = vi.fn().mockRejectedValue(new Error("host unreachable")); + await expect( + reconnectWithBackoff(connect, { maxRetries: 2, sleep: async () => {} }), + ).rejects.toThrow("host unreachable"); + expect(connect).toHaveBeenCalledTimes(3); + }); +}); diff --git a/backend/src/lib/websocket-relay.stress.test.js b/backend/src/lib/websocket-relay.stress.test.js new file mode 100644 index 0000000..05908a3 --- /dev/null +++ b/backend/src/lib/websocket-relay.stress.test.js @@ -0,0 +1,237 @@ +/** + * WebSocket Relay Service — stress suite (issue #1456). + * + * Exercises the relay primitives under concurrency and volume. Assertions + * focus on correctness under load (exactly-once claiming, bounded memory, + * fast-fail). Timing budgets are deliberately generous so CI noise does not + * cause flakes; they exist to catch pathological (quadratic or blocking) + * regressions, not to benchmark. + */ + +import { describe, it, expect, vi } from "vitest"; +import { createFakeRelayDb } from "../../tests/helpers/fake-relay-db.js"; +import { enforceMessageSizeLimit, sanitizeRelayMessage } from "./websocket-relay-security.js"; +import { + CircuitBreaker, + CircuitState, + DeadLetterQueue, + reconnectWithBackoff, +} from "./websocket-relay-recovery.js"; +import { + batchInsertEvents, + dequeueNextEvent, + markEventComplete, +} from "./websocket-relay-queries.js"; + +const STRESS_TIMEOUT_MS = 30_000; + +function makeEvents(count, prefix = "pay") { + return Array.from({ length: count }, (_, i) => ({ + payment_id: `${prefix}_${i}`, + event_type: i % 2 ? "payment.confirmed" : "payment.failed", + payload: { seq: i }, + })); +} + +async function consumer(db, deliver) { + const claimed = []; + for (let event = await dequeueNextEvent(db); event; event = await dequeueNextEvent(db)) { + claimed.push(event.id); + await deliver(event); + await markEventComplete(db, event.id); + } + return claimed; +} + +describe("relay queue under concurrent consumers", () => { + it( + "50 consumers drain 2,000 events with every event claimed exactly once", + async () => { + const db = createFakeRelayDb({ latencyMs: 0 }); + for (let i = 0; i < 4; i += 1) { + await batchInsertEvents(db, makeEvents(500, `batch${i}`)); + } + const deliver = vi.fn(async () => { + await new Promise((resolve) => setImmediate(resolve)); + }); + + const perConsumer = await Promise.all(Array.from({ length: 50 }, () => consumer(db, deliver))); + const claimed = perConsumer.flat(); + + expect(claimed).toHaveLength(2000); + expect(new Set(claimed).size).toBe(2000); + expect(deliver).toHaveBeenCalledTimes(2000); + expect(db.countBy("completed")).toBe(2000); + expect(db.countBy("pending") + db.countBy("processing")).toBe(0); + // Work is actually spread across consumers, not serialised on one. + expect(perConsumer.filter((ids) => ids.length > 0).length).toBeGreaterThan(1); + }, + STRESS_TIMEOUT_MS, + ); + + it( + "interleaved async latency still never double-claims", + async () => { + const db = createFakeRelayDb({ latencyMs: 1 }); + await batchInsertEvents(db, makeEvents(200)); + + const claimed = (await Promise.all(Array.from({ length: 20 }, () => consumer(db, async () => {})))).flat(); + + expect(new Set(claimed).size).toBe(claimed.length); + expect(claimed).toHaveLength(200); + }, + STRESS_TIMEOUT_MS, + ); + + it("batch-inserts 1,000 events in a single parameterised statement", async () => { + const db = createFakeRelayDb(); + const inserted = await batchInsertEvents(db, makeEvents(1000)); + expect(inserted).toHaveLength(1000); + expect(db.statements).toHaveLength(1); + expect(db.statements[0].params).toHaveLength(4000); + expect(db.statements[0].sql).toContain("$4000"); + }); +}); + +describe("relay ingress under volume", () => { + it( + "sanitizes and size-checks 10,000 frames quickly and deterministically", + () => { + const frames = Array.from({ length: 10_000 }, (_, i) => + JSON.stringify({ + type: "payment.event", + payment_id: `pay_${i}`, + event_type: "payment.confirmed", + payload: { seq: i }, + injected: "