Skip to content

Commit d73b20d

Browse files
authored
Merge pull request #1603 from Y33t-dev/feature/be-sep0001-hardening-relay-test-suite-idempotency-retry
feat(backend): harden and instrument SEP-0001 generator, add relay integration/stress suite, retry idempotency Redis calls with backoff
2 parents db7d02b + bd5fb51 commit d73b20d

18 files changed

Lines changed: 2546 additions & 69 deletions

‎backend/SEP0001_GENERATOR_GUIDE.md‎

Lines changed: 64 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ curl https://api.example.com/.well-known/stellar.toml?merchant_id=550e8400-e29b-
3636
NETWORK_PASSPHRASE = "Test SDF Network ; September 2015"
3737
TRANSFER_SERVER = "https://api.example.com/api"
3838
DOCUMENTATION = "https://api.example.com/api-docs"
39-
ACCOUNTS = ["GBUQWP3BOUZX34ULNQG23RQ6F4YUSXHTQSXUSMIQSTBE2BRUY4DQAT2B"]
39+
ACCOUNTS = ["GDVEU3DD4KOFECV66VIHWEZOYX4ZKR3WV27L464SIIPOU2IUI3JCZA57"]
4040

4141
[ORG]
4242
name = "Acme Payment Services"
@@ -56,7 +56,7 @@ The stellar.toml is generated from:
5656
- `API_BASE_URL`: Base URL of the API (default: "http://localhost:4000")
5757
- `FEDERATION_SERVER_URL`: Optional federation server
5858
- `DOCS_URL`: Documentation URL (default: `{API_BASE_URL}/api-docs`)
59-
- `SIGNING_KEY`: Optional signing key for transactions
59+
- `SIGNING_KEY`: Optional **public** signing key (`G...`). A secret seed (`S...`) is never published.
6060

6161
2. **Merchant Settings** (from database):
6262
- `business_name`: Organization name
@@ -79,7 +79,7 @@ API_BASE_URL="https://api.example.com"
7979
TRANSFER_SERVER_URL="https://api.example.com/api"
8080
FEDERATION_SERVER_URL="https://federation.example.com"
8181
DOCS_URL="https://docs.example.com"
82-
SIGNING_KEY="GBUQWP3BOUZX34ULNQG23RQ6F4YUSXHTQSXUSMIQSTBE2BRUY4DQAT2B"
82+
SIGNING_KEY="GDVEU3DD4KOFECV66VIHWEZOYX4ZKR3WV27L464SIIPOU2IUI3JCZA57"
8383
```
8484

8585
## Merchant Configuration
@@ -92,7 +92,7 @@ Merchants can configure their stellar.toml information through:
9292
- `notification_email`: Support email
9393

9494
2. **Stellar Account** (set during registration):
95-
- `recipient`: Your Stellar account address (e.g., `GBUQWP3BOUZX34ULNQG23RQ6F4YUSXHTQSXUSMIQSTBE2BRUY4DQAT2B`)
95+
- `recipient`: Your Stellar account address (e.g., `GDVEU3DD4KOFECV66VIHWEZOYX4ZKR3WV27L464SIIPOU2IUI3JCZA57`)
9696

9797
3. **Branding Configuration** (via merchant settings):
9898
- `branding_config.homepage`: Your website URL
@@ -129,13 +129,13 @@ curl -X POST https://api.example.com/api/merchant-branding \
129129
- Example: `"https://federation.example.com"`
130130

131131
- **ACCOUNTS**: List of Stellar accounts operated by the merchant
132-
- Example: `["GBUQWP3BOUZX34ULNQG23RQ6F4YUSXHTQSXUSMIQSTBE2BRUY4DQAT2B"]`
132+
- Example: `["GDVEU3DD4KOFECV66VIHWEZOYX4ZKR3WV27L464SIIPOU2IUI3JCZA57"]`
133133

134134
- **DOCUMENTATION**: URL to API documentation
135135
- Example: `"https://api.example.com/api-docs"`
136136

137137
- **SIGNING_KEY**: Public key for transaction signing
138-
- Example: `"GBUQWP3BOUZX34ULNQG23RQ6F4YUSXHTQSXUSMIQSTBE2BRUY4DQAT2B"`
138+
- Example: `"GDVEU3DD4KOFECV66VIHWEZOYX4ZKR3WV27L464SIIPOU2IUI3JCZA57"`
139139

140140
### Organization Section
141141

@@ -151,10 +151,64 @@ The `[ORG]` section contains merchant information:
151151

152152
The stellar.toml is validated to ensure:
153153

154-
1. **Required fields present**: NETWORK_PASSPHRASE and TRANSFER_SERVER
154+
1. **Required fields present**: NETWORK_PASSPHRASE and TRANSFER_SERVER, each at the start of a line
155155
2. **Valid TOML syntax**: Proper formatting and escaping
156156
3. **Valid URLs**: Properly formatted URLs
157157
4. **Valid Stellar addresses**: Properly formatted account addresses
158+
5. **Size**: the document is at most 100 KB (the SEP-0001 limit)
159+
160+
### Payload sanitization and strict validation (issue #1457)
161+
162+
Every value comes from the merchant record or server environment, and the
163+
generator treats all of it as untrusted.
164+
165+
| Input | Rule | If invalid |
166+
|---|---|---|
167+
| `merchant_id` query param | Single value, must be a UUID | `400`. The database is not queried. |
168+
| `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 |
169+
| 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]` |
170+
| `recipient` → `ACCOUNTS` | Valid Stellar public key, StrKey checksum included | `ACCOUNTS` omitted |
171+
| `SIGNING_KEY` | Valid Stellar **public** key | Omitted. A secret seed is never published and is logged as an error (without the value). |
172+
| `email`, `notification_email` | Email syntax, max 254 characters | Field omitted |
173+
| `branding_config.homepage` / `logo_url` | `https` URL, no credentials, no whitespace, max 2048 characters | Field omitted |
174+
| `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. |
175+
| `FEDERATION_SERVER_URL`, `DOCS_URL` | Same URL rules | Field omitted |
176+
| `STELLAR_NETWORK_PASSPHRASE` | Printable, max 128 characters | `500`, as above |
177+
178+
Omitted or altered fields are logged (`SEP-0001: fields omitted or altered by
179+
sanitization`) and counted in `sep0001_rejected_fields_total{field,reason}`.
180+
181+
Responses also send `Access-Control-Allow-Origin: *`, which SEP-0001 requires
182+
so browser wallets can read the file, and `X-Content-Type-Options: nosniff`.
183+
184+
## Monitoring (issue #1458)
185+
186+
Metrics (merged into `GET /metrics`):
187+
188+
| Metric | Labels | Meaning |
189+
|---|---|---|
190+
| `sep0001_requests_total` | `outcome` = `success` / `invalid_request` / `not_found` / `lookup_error` / `generation_error` | Requests by outcome |
191+
| `sep0001_generation_duration_seconds` | `outcome` | Lookup + generation latency |
192+
| `sep0001_document_size_bytes` | — | Served document size |
193+
| `sep0001_rejected_fields_total` | `field`, `reason` | Sanitization rejections |
194+
| `sep0001_health_state` | — | `0` healthy, `1` degraded, `2` unhealthy |
195+
| `sep0001_error_ratio` | — | Rolling-window server-error ratio |
196+
| `sep0001_last_success_timestamp_seconds` | — | Last successful response |
197+
198+
All label values come from fixed sets. Merchant ids never become labels.
199+
200+
Health: `GET /health/sep0001` returns the rolling-window snapshot (5 minutes by
201+
default). It responds `503` only when unhealthy. `/health` also reports
202+
`services.sep0001_generator` for information.
203+
204+
| Status | Condition (with at least `SEP0001_HEALTH_MIN_SAMPLES`, default 10) |
205+
|---|---|
206+
| `unhealthy` | server-error ratio (`lookup_error` + `generation_error`) ≥ `SEP0001_UNHEALTHY_ERROR_RATIO` (default 0.25) |
207+
| `degraded` | server-error ratio ≥ `SEP0001_DEGRADED_ERROR_RATIO` (default 0.05), or a secret seed was refused as `SIGNING_KEY` in the window |
208+
209+
`400` and `404` responses are client errors and do not affect health. The
210+
window is set by `SEP0001_HEALTH_WINDOW_MS`. Alerting rules are in
211+
`docs/alerts/sep0001-generator.rules.yml`.
158212

159213
## Caching
160214

@@ -267,6 +321,9 @@ console.log('Accounts:', toml.ACCOUNTS);
267321
1. **Public Information**: stellar.toml is publicly accessible
268322
- Don't include sensitive information
269323
- Only include information you want to share publicly
324+
- `SIGNING_KEY` must be the public key. If a secret seed is configured, the
325+
generator refuses to publish it and `Sep0001SecretSigningKeyRefused` fires.
326+
Treat that seed as exposed in config and rotate it.
270327

271328
2. **HTTPS Required**: Always use HTTPS for stellar.toml
272329
- Wallets will reject HTTP endpoints
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
# Idempotency Key Manager
2+
3+
`src/lib/idempotency.js` makes `POST` requests with an `Idempotency-Key`
4+
header safe to retry. The first 2xx response is cached in Redis for 24 hours
5+
under `idempotency:<merchant>:<key>` together with a SHA-256 hash of the
6+
request body. A later request with the same key and the same body replays the
7+
cached response (`201`). The same key with a different body gets a `400`.
8+
9+
## Retry with exponential backoff (issue #1464)
10+
11+
Every Redis call the manager makes goes through `withIdempotencyRetry()`
12+
(`src/lib/idempotency-retry.js`):
13+
14+
| Operation label | Call | On final failure |
15+
|---|---|---|
16+
| `get` | middleware cache read | Request proceeds without idempotency (fail-open, unchanged) |
17+
| `set` | caching the response | Logged. Runs in the background and never delays the response. |
18+
| `lookup` | `lookupIdempotentResponse()` (payment session handler) | Error propagates to the caller, unchanged |
19+
20+
```
21+
delay(attempt) = min(baseDelayMs * 2^attempt, maxDelayMs) * (1 ± jitterRatio)
22+
```
23+
24+
Retries stop at `maxRetries`, or when the next delay would push total elapsed
25+
time past `totalBudgetMs`, so a Redis outage adds bounded latency.
26+
27+
Only transient errors are retried: socket closed or reset, connect timeouts,
28+
`ECONNRESET`/`ETIMEDOUT`/..., and the server replies `LOADING`, `BUSY`,
29+
`TRYAGAIN`, `READONLY`, `MASTERDOWN` and `CLUSTERDOWN`. A closed client
30+
(`redis.js` disables reconnects), auth/permission/`WRONGTYPE` errors and
31+
corrupt cache JSON fail immediately.
32+
33+
| Env var | Default | Bounds |
34+
|---|---|---|
35+
| `IDEMPOTENCY_REDIS_MAX_RETRIES` | 2 | 0–5 |
36+
| `IDEMPOTENCY_REDIS_RETRY_BASE_MS` | 25 | 1–1000 |
37+
| `IDEMPOTENCY_REDIS_RETRY_MAX_MS` | 250 | base–5000 |
38+
| `IDEMPOTENCY_REDIS_RETRY_BUDGET_MS` | 500 | 0–2000 |
39+
40+
Jitter is fixed at ±20%.
41+
42+
## Metrics
43+
44+
Exposed on `GET /metrics`, from their own registry:
45+
46+
| Metric | Labels |
47+
|---|---|
48+
| `idempotency_redis_operations_total` | `operation` (`get`/`set`/`lookup`), `outcome` (`success`/`recovered`/`failed`/`non_retryable`) |
49+
| `idempotency_redis_retries_total` | `operation` |
50+
| `idempotency_redis_retry_delay_seconds` | `operation` |
51+
52+
A rising `outcome="failed"` rate for `get` means idempotency is currently
53+
failing open, so duplicate payments are possible. Alert on it together with
54+
Redis availability.
55+
56+
## Tests
57+
58+
```bash
59+
npx vitest run src/lib/idempotency-retry.test.js src/lib/idempotency.test.js
60+
```
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
# WebSocket Relay Service: integration and stress tests
2+
3+
Issue #1456.
4+
5+
| Suite | File | What it proves |
6+
|---|---|---|
7+
| 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 |
8+
| 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 |
9+
10+
Only Postgres is faked. `tests/helpers/fake-relay-db.js` interprets the exact
11+
SQL emitted by `websocket-relay-queries.js`. Each statement applies
12+
atomically, mirroring `FOR UPDATE SKIP LOCKED` row locking, so the
13+
exactly-once assertions test the claiming logic rather than the fake.
14+
15+
Timing assertions in the stress suite are deliberately loose. They catch
16+
pathological regressions (quadratic or blocking behaviour), not small slowdowns.
17+
18+
## Finding fixed
19+
20+
`auditRelayEvent()` spread caller metadata **after** the fixed fields, so
21+
metadata containing `audit`, `ts` or `event` (for example values echoed from
22+
a client) could overwrite them and forge an audit line. Metadata is now
23+
spread first.
24+
25+
## Running
26+
27+
```bash
28+
npx vitest run src/lib/websocket-relay
29+
```
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
# Prometheus alerting rules for the SEP-0001 stellar.toml generator (issue #1458).
2+
#
3+
# Load with: rule_files: ["docs/alerts/sep0001-generator.rules.yml"]
4+
# Validate: promtool check rules docs/alerts/sep0001-generator.rules.yml
5+
#
6+
# Metric reference: SEP0001_GENERATOR_GUIDE.md ("Monitoring")
7+
8+
groups:
9+
- name: sep0001-generator
10+
rules:
11+
- alert: Sep0001GeneratorUnhealthy
12+
expr: max(sep0001_health_state) >= 2
13+
for: 5m
14+
labels:
15+
severity: critical
16+
annotations:
17+
summary: stellar.toml generation is failing
18+
description: >
19+
The rolling-window server error ratio is above the unhealthy
20+
threshold. Wallets cannot discover TRANSFER_SERVER or verify
21+
merchant accounts. See GET /health/sep0001 and logs for
22+
"SEP-0001: failed to fetch merchant" / "invalid server configuration".
23+
24+
- alert: Sep0001GeneratorDegraded
25+
expr: max(sep0001_health_state) == 1
26+
for: 15m
27+
labels:
28+
severity: warning
29+
annotations:
30+
summary: stellar.toml generator degraded for 15 minutes
31+
description: Elevated error ratio or a refused secret SIGNING_KEY. See GET /health/sep0001.
32+
33+
- alert: Sep0001SecretSigningKeyRefused
34+
expr: sum(increase(sep0001_rejected_fields_total{field="SIGNING_KEY",reason="secret_seed"}[15m])) > 0
35+
for: 0m
36+
labels:
37+
severity: critical
38+
annotations:
39+
summary: SIGNING_KEY is configured with a secret seed
40+
description: >
41+
The generator refused to publish it, but the seed is present in the
42+
environment. Rotate the key and set SIGNING_KEY to the public key.
43+
44+
- alert: Sep0001InvalidConfiguration
45+
expr: sum(increase(sep0001_requests_total{outcome="generation_error"}[10m])) > 0
46+
for: 10m
47+
labels:
48+
severity: warning
49+
annotations:
50+
summary: stellar.toml cannot be generated
51+
description: >
52+
Usually TRANSFER_SERVER_URL / API_BASE_URL is not https or
53+
STELLAR_NETWORK_PASSPHRASE is invalid. Check logs for
54+
"SEP-0001: invalid server configuration".
55+
56+
- alert: Sep0001MerchantDataRejected
57+
expr: sum by (field, reason) (increase(sep0001_rejected_fields_total{field!="SIGNING_KEY"}[1h])) > 20
58+
for: 0m
59+
labels:
60+
severity: info
61+
annotations:
62+
summary: "Many stellar.toml {{ $labels.field }} values rejected ({{ $labels.reason }})"
63+
description: Merchant records contain values that are being omitted from stellar.toml.

0 commit comments

Comments
 (0)