This document is the authoritative security reference for SorobanPay. It covers the on-chain security model, authorization audit results, backend secrets management, circuit breaker runbook, frontend security, known limitations, and dependency auditing.
- Trust Boundaries and Threat Model
- Contract Security Model
- Authorization Audit (SC-20)
- Backend Secrets Management
- Circuit Breaker Runbook (SC-25)
- Frontend Security
- Known Limitations and Mitigations
- Dependency Security
- Security Disclosure Policy
This section maps every component in the SorobanPay architecture to a trust tier and catalogs the threats that can originate from — or cross — each boundary.
╔══════════════════════════════════════════════════════════════════════╗
║ UNTRUSTED ZONE (public internet / user device) ║
║ ║
║ ┌──────────────────────┐ ┌─────────────────────────────────────┐ ║
║ │ Browser (subscriber │ │ Browser (merchant portal / admin) │ ║
║ │ / end-user device) │ │ │ ║
║ │ • Next.js frontend │ │ • Next.js frontend │ ║
║ │ • Freighter wallet │ │ • Freighter wallet │ ║
║ └──────────┬───────────┘ └──────────────────┬──────────────────┘ ║
║ │ HTTPS/XDR (signed transaction) │ HTTPS/XDR ║
╠═════════════╪══════════════════════════════════╪════════════════════╣
║ SEMI-TRUSTED ZONE (SorobanPay-operated, but exposed to internet) ║
║ │ │ ║
║ ┌──────────▼──────────────────────────────────▼──────────────────┐ ║
║ │ Soroban RPC node (stellar.org / ValidationCloud / self-hosted) │ ║
║ │ • getEvents(), simulateTransaction(), submitTransaction() │ ║
║ └──────────────────────────┬────────────────────────────────────-┘ ║
║ │ Soroban RPC protocol ║
╠═════════════════════════════╪══════════════════════════════════════╣
║ TRUSTED ZONE (on-chain — enforced by Stellar validators) ║
║ │ ║
║ ┌──────────────────────────▼───────────────────────────────────┐ ║
║ │ Soroban Smart Contract (SubscriptionProtocol) │ ║
║ │ • Persistent storage (subscription entries) │ ║
║ │ • SEP-41 token contract interactions │ ║
║ └──────────────────────────────────────────────────────────────┘ ║
╠════════════════════════════════════════════════════════════════════╣
║ BACKEND ZONE (operator-controlled, private network preferred) ║
║ ║
║ ┌───────────────────────────────────────────────────────────┐ ║
║ │ Optional Backend services (Express API + workers) │ ║
║ │ • EventIndexer • PaymentScheduler • WebhookNotifier │ ║
║ │ • Reconciler • PayoutSummaryGenerator │ ║
║ └───────────────────────────┬───────────────────────────────┘ ║
║ │ TCP (Prisma ORM) ║
║ ┌───────────────────────────▼───────────────────────────────┐ ║
║ │ PostgreSQL database │ ║
║ └───────────────────────────────────────────────────────────┘ ║
║ ║
║ ┌────────────────────────────────────────────────────────────┐ ║
║ │ Message queue (optional — BullMQ / Redis) │ ║
║ │ • Webhook delivery queue • Payment retry queue │ ║
║ └────────────────────────────────────────────────────────────┘ ║
╠════════════════════════════════════════════════════════════════════╣
║ OPERATOR ZONE (secrets, keys, CI/CD) ║
║ • OPERATOR_SECRET (Stellar signing key) ║
║ • DATABASE_URL, WEBHOOK_SECRET, RPC API keys ║
║ • GitHub Actions, deployment pipelines ║
╚════════════════════════════════════════════════════════════════════╝
| Tier | Components | Assumptions |
|---|---|---|
| Untrusted | Browser, Freighter, subscriber/merchant devices | Hostile environment; any input may be malicious |
| Semi-trusted | Soroban RPC nodes | Correct behavior expected but not cryptographically guaranteed; could be censoring or returning stale data |
| On-chain / trusted | Soroban contract, Stellar ledger, validators | Cryptographically enforced; cannot be tampered with outside of validator consensus |
| Backend zone | Express API, workers, PostgreSQL, Redis | Operator-controlled; trust depends on deployment security posture |
| Operator zone | Secret keys, CI/CD, deployment credentials | Highest privilege; compromise is catastrophic |
| Threat | Attack vector | Impact | Mitigation |
|---|---|---|---|
| Malicious dApp | Attacker serves a lookalike frontend | Subscriber signs a fraudulent transaction | Freighter displays exact transaction details before signing; CSP limits script injection |
| Wallet phishing | Fake Freighter extension | Subscriber's secret key stolen | Warn users to install only from official browser stores; manifest.json extension ID pinning |
| XSS | Injected script in frontend | Reads DOM state, exfiltrates form data, submits unauthorized transactions | CSP script-src 'self'; no innerHTML writes with user data; React's default escaping |
| Transaction parameter tampering | Frontend manipulates amount/merchant before signing | Subscriber pays wrong amount or merchant | Transaction is shown verbatim in Freighter before signing; contract validates all parameters on-chain |
| Stale transaction replay | Signed XDR replayed after user intent changed | Duplicate or delayed payment | timebounds set to 5 minutes on all constructed transactions |
| Supply-chain (npm) | Malicious npm package in frontend/ |
Arbitrary code in browser | npm audit, --audit-level=high in CI; exact version pinning |
| Threat | Attack vector | Impact | Mitigation |
|---|---|---|---|
| Stale data served | RPC lags or returns outdated ledger state | Backend indexes wrong subscription state | Cross-check events against on-chain reads for critical state; use fallback RPC |
| Transaction censorship | RPC drops submitted transactions | Payments or subscriptions not recorded | Retry with exponential backoff; use alternative RPC endpoint |
| Simulation manipulation | RPC returns fake minResourceFee |
Under-fee'd transaction rejected from ledger | Always add a 10–25% buffer; accept on-chain validation as authoritative |
| Man-in-the-middle | Network-level HTTPS interception | Modified transaction data | Enforce TLS certificate pinning in backend HTTP clients; browser HTTPS |
| Threat | Attack vector | Impact | Mitigation |
|---|---|---|---|
| Unauthorized subscription creation | Attacker calls subscribe without subscriber's signature |
Creates fraudulent subscription record | require_auth(subscriber) as first statement; enforced by Soroban host |
| Unauthorized payment collection | Attacker calls execute_payment impersonating merchant |
Collects payment to declared merchant (not attacker) | require_auth(merchant) as first statement; transfer always goes to declared merchant, not caller |
| Early / double payment | Merchant calls execute_payment before interval elapses |
Extra payment taken | now >= next_payment ledger-timestamp check before any transfer |
| Self-subscription | subscriber == merchant |
Funds circular loop | Explicit check in subscribe; returns SelfSubscription error |
| Token balance drain | Attacker exploits reentrancy in token callback | Contract drained | Contract holds no balance; transfers go directly subscriber → merchant |
| Integer overflow | Extreme amount values | Silent incorrect math | overflow-checks = true in release profile; AmountTooLarge guard |
| TTL expiry race | Entry expires between has() check and subsequent operations |
Stale read returns None |
TTL extended on every write; execute_payment returns NoActiveSubscription for expired entries — safe failure mode |
| Threat | Attack vector | Impact | Mitigation |
|---|---|---|---|
| Webhook replay | Attacker replays a captured webhook delivery | Duplicate event processing | HMAC-SHA256 signature + timestamp tolerance check (±300 s); idempotency key per delivery |
| Queue poisoning | Malformed job inserted into BullMQ | Worker crashes or processes bad data | Schema validation on job dequeue; DLQ for failed jobs; worker sandbox |
| Redis credential theft | REDIS_URL leaked |
Queue contents exposed; jobs injected | Secrets management (see §4); Redis requirepass; TLS in transit |
| Webhook SSRF | Merchant registers internal URL as webhook target | Attacker uses backend to probe internal network | Allowlist/denylist for webhook URLs; block RFC-1918 ranges and loopback addresses |
| Threat | Attack vector | Impact | Mitigation |
|---|---|---|---|
| SQL injection | Malicious input in query parameters | Data exfiltration or corruption | All queries use Prisma parameterized ORM; no raw string interpolation |
| Credential theft | DATABASE_URL leaked via env, logs, or error messages |
Full DB access | Never log DATABASE_URL; secrets management (see §4); rotate credentials annually |
| Privilege escalation | Application DB user has DBA rights | Schema modification, data destruction | Principle of least privilege: app user has SELECT, INSERT, UPDATE, DELETE on app tables only; no DROP or ALTER |
| Data at rest exposure | Disk or backup unencrypted | Subscription/payment history exposed | Enable encryption at rest on managed DB (RDS, Railway, Render) |
| Migration injection | Malicious migration file committed | Schema altered in production | Migrations reviewed in PRs; no auto-migration on production startup; explicit npx prisma migrate deploy |
| Threat | Attack vector | Impact | Mitigation |
|---|---|---|---|
| JWT forgery | Attacker forges a JWT without the signing key | Accesses any merchant's data | JWTs signed with ECDSA (EdDSA preferred); signing key stored in secrets manager; short TTL (24 h) |
| JWT hijacking | Bearer token intercepted | Impersonation of merchant | HTTPS enforced; short token TTL; aud and iss claims validated |
| Tenant data leakage | Query does not filter by authenticated merchant | Cross-tenant data exposure | All DB queries include WHERE merchant = jwt.sub; enforced at ORM layer, not just route handler |
| Rate limit bypass | Attacker floods API | DoS; resource exhaustion | Rate limiting per IP and per JWT (express-rate-limit); 429 responses with Retry-After |
| CORS misconfiguration | Wildcard Access-Control-Allow-Origin |
Any origin reads API responses | Allowlist known frontend origins; credentials mode requires explicit origin |
| Dependency RCE | Vulnerable npm package exploited | Server compromise | npm audit in CI; Dependabot PRs; minimal production dependencies |
| Threat | Attack vector | Impact | Mitigation |
|---|---|---|---|
| Secret key exposure | OPERATOR_SECRET leaked in logs or env dump |
Attacker collects all due payments | Never log secrets; omit OPERATOR_SECRET unless PaymentScheduler is needed; rotate after any suspected exposure |
| CI pipeline injection | Malicious PR modifies workflow YAML | Secrets exfiltrated from CI runner | pull_request_target restricted; secrets not accessible from fork PRs; branch protection on main |
| Dependency substitution (typosquatting) | Malicious npm or crate published with similar name | Arbitrary code in build | Review package names before adding; use npm install --save-exact |
| Compromised deploy key | GitHub Actions deploy key stolen | Attacker can deploy arbitrary code | Rotate keys; use short-lived OIDC tokens instead of long-lived secrets |
The following rules govern data flow across trust boundaries. Contributors must not violate these rules without a documented security review.
| Boundary crossing | Rule |
|---|---|
| Browser → RPC | All transactions must be signed by the user's wallet; frontend must never hold or construct raw secret keys |
| RPC → Contract | Only Stellar-valid signed XDR accepted by validators; contract enforces all invariants independently of RPC honesty |
| Contract → Token | Only via token.transfer(subscriber, merchant, amount) — no arbitrary calls; no balance held |
| Backend → DB | All queries use Prisma parameterized ORM; no raw SQL with user-supplied values |
| Backend → RPC | Read-only (getEvents, getLedgerEntries) unless OPERATOR_SECRET is configured; TLS required |
| Backend → Merchant webhook | HMAC-SHA256 signed payload; webhook URL allowlist enforced; internal IPs blocked |
| Operator → Backend | Secrets injected via environment or secrets manager, never via source code |
| Data | Classification | Handling |
|---|---|---|
| Subscriber Stellar public keys | Internal | Stored in DB; returned in API responses to the merchant only |
| Payment amounts and timestamps | Internal | Stored in DB; accessible to authenticated merchant |
| Transaction hashes | Internal | Stored in DB; publicly verifiable on Stellar Explorer |
OPERATOR_SECRET (Stellar private key) |
Secret | Never logged; injected at runtime; rotate on exposure |
DATABASE_URL |
Secret | Never logged; never returned in API responses |
WEBHOOK_SECRET |
Secret | Returned once at webhook creation; never returned again |
| JWT signing key | Secret | Generated at server startup or injected; never exposed via API |
| Soroban RPC API keys (if applicable) | Secret | Never logged; injected at runtime |
SorobanPay never holds token balances. All payment transfers go directly from subscriber → merchant via SEP-41 transfer(). The contract has no treasury address, no escrow wallet, and no upgrade authority over token contracts.
This means:
- A compromised contract cannot drain funds it does not hold.
- A compromised backend cannot move funds (it never submits token transfers).
- Subscribers retain full custody of their tokens between payment windows.
Every entry point calls require_auth() before any state mutation:
| Entry point | Who must authorize | What they authorize |
|---|---|---|
subscribe |
subscriber |
Create or overwrite the subscription record |
execute_payment |
merchant |
Collect the next due payment |
execute_payment_batch |
merchant |
Collect payments for a batch of subscribers |
cancel |
subscriber |
Remove the subscription record |
get_subscription |
(no auth) | Read-only view — no state change |
version |
(no auth) | Read-only — returns version symbol |
contract_name |
(no auth) | Read-only — returns name symbol |
require_auth() is enforced by the Soroban host, not by application logic. A missing or invalid signature causes the entire transaction to fail before any code in the entry point runs.
Subscribers grant a SEP-41 token allowance to the contract address:
subscriber.approve(contract_address, amount, expiry_ledger)
The contract's execute_payment then calls token.transfer(subscriber, merchant, amount). This means:
- Revoking the allowance (
approve(contract_address, 0)) prevents all future payments regardless of on-chain subscription state — subscribers have a unilateral emergency stop. - Reducing the allowance below the subscription amount causes
execute_paymentto fail withTransferFailed. - The contract does not require an allowance equal to all future payments — only enough for the next interval.
execute_payment checks now >= next_payment before attempting any transfer. This check uses the Soroban ledger timestamp, which is set by the network validators and cannot be manipulated by the transaction submitter.
if now < data.next_payment {
return Err(ContractError::PaymentNotDue);
}
Merchants cannot front-run or double-collect payments within the same billing window.
subscribe validates all inputs before touching storage:
| Check | Error | Condition |
|---|---|---|
| Self-subscription | SelfSubscription |
subscriber == merchant |
| Positive amount | AmountMustBePositive |
amount <= 0 |
| Amount ceiling | AmountTooLarge |
amount > 10^18 |
| Interval floor | IntervalTooShort |
interval < 86400 (1 day) |
| Interval ceiling | IntervalTooLong |
interval > 31536000 (365 days) |
| Timestamp validity | InvalidTimestamp |
ledger timestamp is 0 or would overflow |
Persistent entries have a TTL. Expired entries return None on read — identical to a cancelled subscription from the contract's perspective. execute_payment on an expired entry returns NoActiveSubscription, not a token transfer error.
See docs/architecture.md for the full TTL lifecycle.
This section documents which address is expected to authenticate at each contract entry point and what an attacker could do if they impersonated a different party.
| Entry point | Authenticating address | Auth position in code | Attack if wrong party |
|---|---|---|---|
subscribe |
subscriber |
Line 1, before any state read | Merchant cannot create subscriptions on behalf of users without their key |
execute_payment |
merchant |
Line 1, before storage load | Subscriber cannot block collection by impersonating merchant; random address cannot trigger transfers |
execute_payment_batch |
merchant |
Line 1, before loop | Only the declared merchant can batch-collect; one merchant cannot collect on behalf of another |
cancel |
subscriber |
Line 1, before storage check | Merchant cannot cancel a subscriber's subscription unilaterally |
This is a deliberate design choice. The merchant bears responsibility for collecting payments on schedule — authorizing as merchant means:
- The subscriber does not need to be online to approve each collection (recurrence model).
- The subscriber's auth (token allowance) is a separate, out-of-band authorization that limits transfer scope.
- Merchants sign with their own key, creating an auditable on-chain record of who collected what and when.
The subscriber's signature on subscribe is the primary consent signal. By signing this transaction, the subscriber:
- Agrees to the specific amount, interval, token, and merchant.
- Authorizes the contract to record this agreement.
- Does not pre-authorize any token transfers — the token allowance is separate.
This two-step consent model (subscribe + approve) means revoking either the subscription (cancel) or the token allowance immediately halts future payments.
As a contributor, always call require_auth() as the first statement in any entry point, before any storage reads, logging, or external calls. This prevents auth bypass via state-dependent short-circuits.
// CORRECT
pub fn subscribe(env: Env, subscriber: Address, ...) -> Result<(), ContractError> {
subscriber.require_auth(); // ← FIRST LINE
// ... validation and storage writes
}
// WRONG — auth after a state read could be bypassed
pub fn subscribe(env: Env, subscriber: Address, ...) -> Result<(), ContractError> {
let existing = env.storage().persistent().get(&key); // ← state read BEFORE auth
subscriber.require_auth();
// ...
}Add these patterns to .gitignore (already present in the repo):
.env
.env.local
.env.production
*.pem
*.key
Use a pre-commit hook to catch accidental staging of secret files:
# .git/hooks/pre-commit (chmod +x)
#!/bin/sh
STAGED=$(git diff --cached --name-only)
if echo "$STAGED" | grep -qE '^(backend/\.env|\.env\.local|\.env\.production)$'; then
echo "ERROR: Secret file staged for commit. Unstage it: git reset HEAD <file>"
exit 1
fi
# Optional: use gitleaks for deeper scanning
if command -v gitleaks >/dev/null 2>&1; then
gitleaks protect --staged --no-banner
fi| Variable | Sensitivity | Notes |
|---|---|---|
DATABASE_URL |
🔴 High | Contains credentials; never log the full URL |
RPC_URL |
🟡 Medium | High if it embeds a paid-provider API key (e.g., ValidationCloud) |
NETWORK_PASSPHRASE |
🟢 Low | Public Stellar constant; still env-configurable for flexibility |
CONTRACT_ID |
🟢 Low | Public on-chain address |
WEBHOOK_SECRET |
🔴 High | Used to compute HMAC-SHA256 signatures; exposure allows forged webhooks |
OPERATOR_SECRET |
⛔ Privileged | Stellar keypair for optional PaymentScheduler; see note below |
PORT |
🟢 Low | Server port; no security impact |
OPERATOR_SECRET is a Stellar secret key (S…) used by the optional PaymentScheduler to submit execute_payment transactions. If compromised:
- The attacker can collect payments for any subscription on the contract where the operator is the merchant.
- They cannot steal funds held by the contract (there are none) or modify subscriptions.
Never set OPERATOR_SECRET unless the PaymentScheduler is actively needed. If not set, the scheduler is disabled and the backend is fully read-only.
cd backend
cp .env.example .env
# Edit .env — fill in real DATABASE_URL, RPC_URL, CONTRACT_ID
# NEVER commit .envLoad secrets at runtime via dotenv:
import 'dotenv/config'; // top of src/index.tsSet variables in the platform's environment dashboard. No secret-store SDK needed. The app reads from process.env at runtime.
Fly.io example:
flyctl secrets set DATABASE_URL="postgresql://user:pass@host:5432/db"
flyctl secrets set WEBHOOK_SECRET="$(openssl rand -hex 32)"Render example: Settings → Environment → Add Environment Variable (mark as secret).
For AWS-hosted deployments needing audit trails and automatic rotation:
import { SecretsManagerClient, GetSecretValueCommand } from '@aws-sdk/client-secrets-manager';
async function loadSecrets() {
const client = new SecretsManagerClient({ region: 'us-east-1' });
const { SecretString } = await client.send(
new GetSecretValueCommand({ SecretId: 'sorobanpay/backend/prod' })
);
if (!SecretString) throw new Error('Secret not found');
const secrets = JSON.parse(SecretString);
process.env.DATABASE_URL = secrets.DATABASE_URL;
process.env.WEBHOOK_SECRET = secrets.WEBHOOK_SECRET;
// Assign other secrets before starting the Express server
}
await loadSecrets();
// then import and start the appStore secrets as a JSON object in a single AWS Secrets Manager entry. Enable automatic rotation for DATABASE_URL using the built-in RDS Lambda rotator.
# Store
vault kv put secret/sorobanpay/backend \
DATABASE_URL="postgresql://..." \
WEBHOOK_SECRET="..."
# Retrieve in app startup (using node-vault or vault agent sidecar)# docker-compose.yml
secrets:
webhook_secret:
external: true
db_url:
external: true
services:
backend:
secrets:
- webhook_secret
- db_url
environment:
WEBHOOK_SECRET_FILE: /run/secrets/webhook_secret
DATABASE_URL_FILE: /run/secrets/db_urlRead in app:
import { readFileSync } from 'fs';
function readSecret(envKey: string, fileEnvKey: string): string {
const filePath = process.env[fileEnvKey];
if (filePath) return readFileSync(filePath, 'utf-8').trim();
return process.env[envKey] ?? '';
}
const webhookSecret = readSecret('WEBHOOK_SECRET', 'WEBHOOK_SECRET_FILE');DATABASE_URL (password rotation):
- Create a new database user with the same privileges as the old one.
- Update the secret in your store (AWS Secrets Manager, Vault, or platform dashboard).
- Trigger a rolling restart of the backend service (
flyctl restart/render manual deploy/kubectl rollout restart). - Verify the backend connects successfully (check
/healthendpoint). - Drop the old database user.
- Estimated downtime: 0 seconds (rolling restart).
WEBHOOK_SECRET rotation:
- Generate a new secret:
openssl rand -hex 32. - Update both the backend environment and the webhook sender's configuration at the same time (coordinate with merchants using webhooks).
- During the brief transition window, verify both old and new signatures to avoid dropped events:
function verifySignature(payload: string, signature: string, secrets: string[]): boolean { return secrets.some(secret => { const expected = createHmac('sha256', secret).update(payload).digest('hex'); return timingSafeEqual(Buffer.from(expected), Buffer.from(signature)); }); }
- Remove the old secret after all senders have switched.
OPERATOR_SECRET rotation:
- Generate a new Stellar keypair:
stellar keys generate new-operator --network mainnet. - Fund the new account with enough XLM for fees.
- Update
OPERATOR_SECRETin the secret store and redeploy. - Revoke the old keypair by setting it to zero balance or removing from any multisig setups.
A "circuit breaker" for SorobanPay means stopping all payment collection and communicating clearly to users while a fix is prepared. Because the contract is non-upgradeable, the primary levers are:
- Subscriber action: Revoke token allowance.
- Backend action: Stop the
PaymentScheduler. - Merchant action: Stop calling
execute_payment. - Protocol action: Deploy a new contract version and migrate.
- Identify the affected entry point(s) and the nature of the issue:
- Logic bug (wrong amount collected, wrong party authorized)?
- Reentrancy or state corruption?
- Dependency vulnerability (SEP-41 token bug)?
- Determine scope: single subscription, all subscriptions for a token, or protocol-wide.
- Open a private incident channel (Slack / Discord / email) with core contributors.
- Do not disclose publicly until mitigations are in place unless data of subscribers is at immediate risk.
Backend — disable PaymentScheduler:
# Fly.io
flyctl secrets unset OPERATOR_SECRET
flyctl restart
# Render
# Remove OPERATOR_SECRET in dashboard → manual deploy
# Docker Compose
docker compose stop backend
# Edit docker-compose.yml: remove OPERATOR_SECRET
docker compose up -d backend
# Kubernetes
kubectl set env deployment/backend OPERATOR_SECRET="" -n sorobanpay
kubectl rollout restart deployment/backend -n sorobanpayMerchant — stop calling execute_payment manually:
Contact all merchants integrating the contract directly (via webhook, email, or Discord) and instruct them to pause automated collection scripts.
Subscriber emergency stop (self-service):
Instruct subscribers to revoke the token allowance via Freighter or the Stellar Laboratory:
stellar contract invoke \
--id <TOKEN_CONTRACT_ID> --source <subscriber-key> --network mainnet \
-- approve \
--from <subscriber-address> \
--spender <contract-address> \
--amount 0 \
--expiration_ledger 0Publish this command in your status page and social channels immediately.
- Reproduce the issue in a local test environment (
make test). - Write a failing test that captures the bug.
- Implement the fix.
- Review: get sign-off from at least two contributors who are not the patch author.
- Deploy to testnet and verify the fix with the failing test now passing.
Because Soroban contracts are immutable once deployed, fixing a contract bug requires deploying a new contract instance:
# Deploy new version to mainnet
STELLAR_NETWORK=mainnet STELLAR_IDENTITY=my-mainnet-id bash deploy/deploy.sh
NEW_CONTRACT_ID=$(...)
echo "New contract: $NEW_CONTRACT_ID"Migration steps:
- Announce the new contract address via all channels.
- Update
NEXT_PUBLIC_CONTRACT_IDin frontend.env.localand redeploy the frontend. - Update
CONTRACT_IDin the backend environment and restart. - Communicate to merchants that they must update their integrations to the new contract address.
- Instruct subscribers to re-subscribe on the new contract (subscriptions are not portable between contract instances).
- Write a post-mortem: timeline, root cause, impact assessment, remediation.
- Publish a public summary (may omit sensitive technical details until patched).
- Update this runbook with any new lessons learned.
- File a CVE if the vulnerability is in a shared dependency.
| Level | Criteria | Response time | Public disclosure |
|---|---|---|---|
| Critical | Funds at risk, active exploitation | Immediate (< 1 hour) | After mitigation |
| High | Auth bypass, data integrity | < 4 hours | After fix deployed |
| Medium | DoS, information leak | < 24 hours | With fix |
| Low | Edge case, no immediate risk | Next release | With fix |
Configure the following CSP in next.config.mjs to restrict resource loading:
// next.config.mjs
const cspHeader = `
default-src 'self';
script-src 'self' 'unsafe-eval' 'unsafe-inline';
style-src 'self' 'unsafe-inline';
img-src 'self' blob: data:;
font-src 'self';
object-src 'none';
base-uri 'self';
form-action 'self';
connect-src 'self'
https://soroban-testnet.stellar.org
https://mainnet.stellar.validationcloud.io
https://horizon-testnet.stellar.org
https://horizon.stellar.org;
frame-ancestors 'none';
`.replace(/\n/g, ' ');
export default {
async headers() {
return [
{
source: '/(.*)',
headers: [
{ key: 'Content-Security-Policy', value: cspHeader },
{ key: 'X-Frame-Options', value: 'DENY' },
{ key: 'X-Content-Type-Options', value: 'nosniff' },
{ key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
{ key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=()' },
],
},
];
},
};Note:
unsafe-evalis currently required for certain Stellar SDK WASM operations. Track stellar-sdk#1234 for resolution; remove once the SDK no longer requires it.
Freighter's security model:
- The user's secret key never leaves the Freighter extension. The frontend receives a signed XDR envelope, not the private key.
- The frontend cannot construct a transaction that transfers more than the user approved — the Freighter UI shows the exact transaction to be signed.
- A compromised frontend can show a misleading UI, but cannot forge signatures.
Contributor guidelines for transaction construction:
- Always use
simulateTransactionbefore prompting for a signature — this catches errors before the user sees a signing prompt. - Never modify a transaction after simulation and before signing.
- Display the decoded transaction details in the UI (
merchant,amount,token,interval) so users can verify before signing. - Set transaction timeout (
timebounds) to a reasonable value (e.g., 5 minutes) to prevent replay of stale signed transactions.
The frontend performs client-side validation (frontend/lib/validation.ts) before constructing any transaction:
- Stellar address format (
StrKey.isValidEd25519PublicKey) - Amount bounds (positive, not exceeding
MAX_AMOUNT) - Interval bounds (86400 – 31536000 seconds)
- Token address format
Client-side validation is UX — it does not replace on-chain validation. The contract enforces all constraints independently.
Warn users:
- Only install Freighter from the official Chrome Web Store or Firefox Add-ons.
- Verify the URL is
freighter.appbefore connecting. - SorobanPay will never ask for your seed phrase.
Limitation: Payment collection transactions are visible in the mempool before inclusion. A miner or validator could theoretically front-run execute_payment to collect a payment in a different transaction order, though there is no direct financial incentive to do so since the funds go to the declared merchant.
Mitigation: Soroban's fee market does not enable ordering by fee tip at the contract-call level. The time-lock (now >= next_payment) bounds the collection window but does not prevent ordering attacks within that window.
Risk level: Low. Front-running execute_payment does not benefit the attacker — the transfer always goes to the declared merchant, not to the transaction submitter.
Limitation: Both the frontend and the backend depend on Soroban RPC nodes to read chain state and broadcast transactions. A malicious or censoring RPC could:
- Return stale event data to the backend indexer.
- Drop broadcast transactions.
- Return incorrect simulation results.
Mitigation:
- Use multiple RPC endpoints in the backend (primary + fallback).
- The contract enforces all critical invariants on-chain — a lying RPC cannot forge payment events that did not occur.
- For critical operations, verify transaction inclusion by querying the ledger directly.
Limitation: If a subscription entry's TTL expires (after ~365 days of no successful payments), the entry is garbage-collected by the Soroban host. A subsequent execute_payment call returns NoActiveSubscription. This can surprise merchants who expect stale subscriptions to be collectable indefinitely.
Mitigation: The backend Reconciler detects subscriptions that have not had a successful payment in an extended period and can alert merchants to re-subscribe or write off the account. See docs/architecture.md.
Limitation: The execute_payment entry point only requires a single signature from merchant. A compromised merchant key allows an attacker to collect all due payments for that merchant's subscribers.
Mitigation: Use a Stellar multisig account as the merchant address for production deployments:
# Set merchant account to require 2-of-3 signatures
stellar account merge --network mainnet ...Limitation: There is no contract-level mechanism to pause an individual subscription. The is_paused field exists in SubscriptionData but is not read by execute_payment.
Mitigation: Subscribers can achieve the equivalent of a pause by revoking their token allowance (approve(contract, 0)). Merchants can stop calling execute_payment. A future contract version may implement on-chain pause semantics.
Limitation: The backend, including the PaymentScheduler, is optional infrastructure. If it goes down, the contract continues to function correctly — subscriptions can still be collected by merchants calling execute_payment directly. However, the merchant dashboard, payout summaries, and webhook notifications will be unavailable.
Mitigation: Use health monitoring (e.g., Uptime Robot on /health) and configure alerting for indexer lag. See docs/architecture.md for HA deployment options.
Audit Cargo dependencies with cargo audit:
cargo install cargo-audit
cd contracts/subscription
cargo auditFor CI, add to .github/workflows/ci.yml:
- name: Cargo audit
run: cargo audit
working-directory: contracts/subscriptionCheck for known advisories in the RustSec Advisory Database.
Key contract dependencies:
| Crate | Version | Purpose |
|---|---|---|
soroban-sdk |
pinned | Soroban environment, types, storage |
soroban-token-sdk (via token::Client) |
pinned | SEP-41 token client |
Pin all dependency versions in Cargo.toml (no open ranges). The contract's Cargo.lock is committed to ensure reproducible builds.
Audit npm dependencies:
# Backend
cd backend && npm audit
# Frontend
cd frontend && npm auditFor CI:
- name: npm audit (backend)
run: npm audit --audit-level=high
working-directory: backend
- name: npm audit (frontend)
run: npm audit --audit-level=high
working-directory: frontendUse npm audit fix cautiously — prefer reviewing and pinning exact versions manually for security-sensitive packages.
Enable Dependabot in .github/dependabot.yml:
version: 2
updates:
- package-ecosystem: npm
directory: /backend
schedule:
interval: weekly
open-pull-requests-limit: 5
- package-ecosystem: npm
directory: /frontend
schedule:
interval: weekly
open-pull-requests-limit: 5
- package-ecosystem: cargo
directory: /contracts/subscription
schedule:
interval: weekly
open-pull-requests-limit: 3- Do not use
latesttags in Docker images — pin to a specific digest. - Verify Stellar SDK releases against the js-stellar-sdk changelog before upgrading.
- For the contract, always review
soroban-sdkrelease notes before bumping — breaking changes to host function interfaces can silently alter contract behaviour.
SorobanPay uses coordinated disclosure. If you find a security vulnerability:
- Do not open a public GitHub issue.
- Send a description of the vulnerability to the maintainers via GitHub Security Advisories:
- Go to the repository → Security → Advisories → New Draft Advisory.
- Include: affected component, description, reproduction steps, potential impact, and any suggested mitigations.
- You will receive acknowledgement within 72 hours.
- We will work with you to understand and validate the issue, and coordinate a disclosure timeline of 90 days from initial report (shorter for critical issues).
- Once a fix is released, we will credit you (with your consent) in the release notes and advisory.
For questions about this policy, contact the repository maintainers via the GitHub Discussions tab.
-
.envand secret files excluded from version control - No real values in
backend/.env.example - Production secrets stored in a secret manager or platform dashboard
-
DATABASE_URLrotated at least annually (or on any suspected compromise) -
WEBHOOK_SECRETset to a random 32-byte hex value -
OPERATOR_SECRETomitted unlessPaymentScheduleris actively needed - Pre-commit hook or
gitleaksconfigured to prevent accidental secret commits -
cargo auditandnpm auditpassing in CI - CSP headers configured in
next.config.mjs - Security advisory channel tested (can create a draft advisory)