Security posture
Stellaroid Earn is a Stellar testnet pilot, and this page documents its security controls the way they are built: concrete guards at the contract, frontend, API, and operational layers — with known limitations stated plainly rather than hidden.
This page summarizes the security controls verified for the Stellaroid Earn MVP across four layers: the Soroban smart contract, the Next.js frontend, the small server-side API surface, and operations. It is a working posture for a testnet pilot, not a claim of production hardening — the Known limitations section is part of the posture, not an appendix.
Smart contract security
Every state-changing function is gated by an explicit authorization check, and every failure path returns a typed error code. The table below maps each guard to its mechanism.
| Guard | Mechanism |
|---|---|
| Admin access control | Admin-only functions call admin.require_auth() and match the caller against the stored admin address before executing. |
| Issuer gating | register_certificate and verify_certificate reject callers that do not hold approved issuer status. Issuers self-register into a pending queue; only the admin approves them. |
| Duplicate prevention | Re-submitting an existing certificate hash returns AlreadyExists (error 4) — no silent overwrites of a credential. |
| Lifecycle guards | verify only transitions a certificate from Issued; revoke and suspend check caller authorization before mutating state. |
| Payment authorization | The token transfer only executes when cert.owner equals the submitting student address. |
| Checks-effects-interactions | Escrow transfers apply the checks-effects-interactions pattern in the contract source (state updates before token transfers). Note: the deployed testnet bytecode is not yet source-verified against current commits — see the verification runbook. |
| Expiry enforcement (partial) | ensure_not_expired() rejects records with a nonzero expired timestamp before verification or payment. The current issuer flow sets expires_at = 0, so there is no automatic expiry transition yet. |
| Storage TTL | Storage TTL is set to 518,400–1,036,800 ledgers and entries are extended on access to prevent premature archival. |
| Bounded iteration | All storage reads and writes are O(1) keyed lookups; opportunity milestone counts are capped at 24, and UI render paths clamp defensively. |
Cross-contract re-entrancy is not a threat class here: Soroban's single-contract execution model makes it impossible by design.
Typed error codes
The contract defines a typed #[contracterror] enum (17 variants in total per the security checklist). The frontend's humanizeError() maps every code to safe, non-leaking copy — no raw ScVal or HostError ever reaches the UI. The twelve credential-layer codes surfaced in the app are:
| Code | Error | Category | Meaning |
|---|---|---|---|
| 1 | AlreadyInitialized | state | Init called twice. |
| 2 | NotInitialized | state | Admin/token not set yet. |
| 3 | Unauthorized | auth | Caller isn't allowed. |
| 4 | AlreadyExists | input | Duplicate cert hash. |
| 5 | NotFound | input | Hash isn't registered. |
| 6 | InvalidAmount | input | Amount must be > 0. |
| 7 | IssuerNotFound | input | Issuer hasn't registered on-chain. |
| 8 | IssuerNotApproved | state | Issuer still needs admin approval. |
| 9 | IssuerSuspended | state | Issuer has been suspended. |
| 10 | InvalidStatus | state | Credential is in the wrong lifecycle state for this action. |
| 11 | CredentialRevoked | state | Credential was revoked and can no longer unlock payment. |
| 12 | CredentialExpired | state | Credential expired and must be reissued or renewed. |
Frontend security
Nonce-based Content Security Policy
The CSP is built per request in src/middleware.ts. A fresh nonce is generated for every request (16 bytes from crypto.getRandomValues, base64-encoded), attached to the script-src directive, and forwarded to server components via an x-nonce request header so inline scripts (such as JSON-LD blocks) can carry it. Production script-src does not allow unsafe-inline.
| Directive | Production value |
|---|---|
| default-src | 'self' |
| script-src | 'self' 'nonce-{per-request}' https://va.vercel-scripts.com |
| style-src | 'self' 'unsafe-inline' https://fonts.googleapis.com |
| font-src | 'self' https://fonts.gstatic.com |
| img-src | 'self' data: blob: |
| worker-src | 'self' |
| manifest-src | 'self' |
| connect-src | 'self' https://*.stellar.org |
| frame-src | 'none' |
| object-src | 'none' |
| base-uri | 'self' |
| form-action | 'self' |
| frame-ancestors | 'none' (relaxed to * only on /proof/{hash}/embed) |
Two environment-scoped relaxations exist and are compiled out of production: development adds 'unsafe-eval' to script-src (Next.js dev tooling), and Vercel preview deployments add https://vercel.live to script-src, and https://vercel.live plus https://*.vercel.live to connect-src for the preview toolbar. Network egress from the browser is otherwise restricted to https://*.stellar.org — the Soroban RPC and Horizon hosts.
The embed route is the single intentional framing exception: the middleware matches /proof/{64-hex}/embed and emits frame-ancestors * there so the verified badge can be embedded elsewhere, while every other route gets frame-ancestors 'none'. Browsers that support CSP Level 2 give frame-ancestors precedence over X-Frame-Options, which is how the embed route can be framed despite the global DENY header below.
default-src 'self'; script-src 'self' 'nonce-<per-request>' https://va.vercel-scripts.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: blob:; worker-src 'self'; manifest-src 'self'; connect-src 'self' https://*.stellar.org; frame-src 'none'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'Security headers
Static security headers are applied to every route from next.config.ts; the CSP itself is nonce-based and therefore applied from middleware instead.
| Header | Value |
|---|---|
| Strict-Transport-Security | max-age=63072000; includeSubDomains; preload |
| X-Frame-Options | DENY |
| X-Content-Type-Options | nosniff |
| Referrer-Policy | strict-origin-when-cross-origin |
| Permissions-Policy | camera=(), microphone=(), geolocation=() |
The service worker script /sw.js is additionally served with Cache-Control: no-cache, no-store, must-revalidate so each deploy's worker takes over quickly and stale client code does not linger.
Input validation and output encoding
- Proof hashes are validated as 64-character hex before any RPC call is made; dynamic proof pages are cached with revalidate=60.
- External URLs (metadata, evidence, issuer links) must be HTTPS and pass an SSRF check that rejects localhost, private IPv4 ranges (10/8, 172.16/12, 192.168/16, 169.254/16, 127/8), IPv6 loopback, unique-local and link-local addresses, and IPv4-mapped IPv6 forms such as ::ffff:127.0.0.1. Issuer-supplied metadata URIs are never fetched server-side — they render as links only, which removes the SSRF fetch surface entirely.
- Internal links from untrusted metadata must start with a single slash, contain no backslashes, and resolve to the canonical origin.
- Proof metadata is sanitized before render: all text fields are length-truncated (title 140 chars, description 700, skill 64, evidence label 100), skills are capped at 12 entries, evidence links at 8, and any evidence href failing the URL safety checks is dropped.
- JSON-LD is serialized through an escaping helper that replaces <, >, &, U+2028, and U+2029 with unicode escapes before injection, and every JSON-LD script tag carries the per-request CSP nonce.
- Wallet signing is validated: the network passphrase returned by the wallet is compared to the expected value before signing, and a mismatch aborts the transaction.
API protections
The server-side attack surface is deliberately small: four API routes. Three of the four are guarded by dependency-free, in-memory abuse controls from src/lib/rate-limit.ts (GET /api/health relies on its 30-second response cache instead) — fixed-window rate limiting, concurrency slots for long-lived streams, and a rolling spend budget. Clients are identified by the first x-forwarded-for hop (falling back to x-real-ip), and the counter maps are opportunistically pruned once a bucket exceeds 5,000 keys so a spray of unique keys cannot grow memory without bound.
| Endpoint | Purpose | Guards |
|---|---|---|
| GET /api/health | RPC health probe | Response cached for 30 seconds to reduce amplification risk |
| GET /api/events | Recent contract events | 60 requests/min per client IP; limit parameter clamped to 1–40; 30-second revalidate cache |
| GET /api/events/stream | Live event stream (SSE) | 20 new connections per IP per minute; 4 simultaneous streams per IP; 200 simultaneous streams per instance |
| POST /api/fee-bump | Restricted fee sponsorship | Bearer-token auth plus full transaction validation (below) |
Fee-bump validation chain
Fee sponsorship is intentionally not public. The endpoint returns 503 unless both the sponsor secret and bearer token are configured, and every request passes this chain:
- Bearer token must match the configured sponsor token.
- Submitted XDR is capped at 32,000 characters.
- The inner transaction must contain exactly one operation, and it must be a Soroban invokeHostFunction contract call.
- The invoked contract must match the configured contract ID, and the method must be on a 13-entry allowlist (register_issuer, register_certificate, verify_certificate, revoke_certificate, suspend_certificate, reward_student, link_payment, create_opportunity, fund_opportunity, submit_milestone, approve_milestone, release_payment, refund_opportunity).
- The inner fee is capped at 1,000,000 stroops (0.1 XLM), the network passphrase must match the expected network, the transaction must already carry the user's signature, and the sponsor cannot sponsor its own source account.
- Abuse ceilings: a per-instance rate limit (default 30 requests/min, override via FEE_SPONSOR_MAX_REQUESTS_PER_MIN) and a rolling spend budget across all callers (default 200,000,000 stroops — 20 XLM — per minute, override via FEE_SPONSOR_MAX_STROOPS_PER_MIN), so a leaked or shared token cannot script an unbounded sponsor-account drain.
Operational security
- Testnet only: all contract deployments and transactions target Stellar testnet; mainnet is explicitly out of scope.
- No custody of user funds: payments move wallet-to-wallet through the native Stellar Asset Contract, and transactions are signed client-side in the user's wallet (Freighter or Albedo). User private keys never touch the server. The one server-held secret is the optional fee-bump sponsor key (FEE_SPONSOR_SECRET) — a dedicated testnet-only account gated by a bearer token and spend caps.
- No accounts, passwords, or logins: public verification of a proof page requires no wallet and no sign-in; the only identifier in on-chain records is a public wallet address alongside a SHA-256 certificate hash.
- No private keys are stored in code, environment variables, or version control; all NEXT_PUBLIC_* variables in the client bundle are non-sensitive public config (RPC URL, network passphrase, contract ID).
- Admin key separation: the admin key used for contract deployment is separate from any personal wallet.
- Crawl protection: robots.ts disallows crawlers from spidering /proof/{hash} routes to prevent mass enumeration of proof pages.
- RPC health monitoring: the app surfaces a visible error state when the Soroban RPC endpoint is unreachable, and /api/health responses are cached for 30 seconds.
- TLS termination and certificate renewal are fully managed by the hosting platform (Vercel), with a 2-year HSTS policy including subdomains and preload.
Known limitations
These are stated deliberately. For a testnet pilot, an accurate limitation list is more useful to reviewers than an inflated control list.
- Testnet MVP, no audit yet: no formal third-party audit and no penetration test have been performed. Both are deferred until before any mainnet deployment.
- Per-instance rate limits: every API abuse guard is in-memory and per-warm-instance. Traffic spread across serverless instances is only bounded per instance; platform-level (WAF/edge) rate limiting is managed in the Vercel dashboard outside this repo, so the in-memory guards are the per-instance backstop documented here.
- Single admin key: one admin address, set at init, gates issuer approval and suspension and triggers reward payouts. There is no multisig or role separation.
- Partial expiry enforcement: the contract can reject expired credentials, but the current issuer flow sets expires_at = 0, so no credential automatically expires yet.
- Source verification pending: the runbook records the deployed WASM hash, but contract-metadata and GitHub-attestation verification is incomplete — the deployment must not be described as source-verified.
- Public fee sponsorship intentionally disabled: sponsored transactions require a trusted server-held bearer token.
The underlying checklist lives in the repository at docs/reference/security.md and was last reviewed on 2026-07-02.
Frequently asked questions
- Has Stellaroid Earn been audited?
- No. Stellaroid Earn is a testnet MVP and has had no formal third-party audit or penetration test. Both are explicitly deferred and planned before any mainnet deployment. In the meantime, the contract test suite covers authorization, duplicate rejection, payment gating, and the full credential lifecycle.
- Does Stellaroid Earn hold or custody user funds?
- No. Payments move wallet-to-wallet through the native Stellar Asset Contract on testnet. Transactions are signed client-side in the user's wallet (Freighter or Albedo), and the server never sees or stores a user's private key. (The optional fee-bump endpoint holds its own dedicated testnet sponsor key, gated by a bearer token and spend caps.)
- Are the API rate limits enforced globally?
- No, and the docs say so on purpose. The rate limits, stream concurrency slots, and fee-sponsorship spend budget are in-memory guards that are per-warm-instance on serverless hosting. They bound abuse per instance and cut upstream RPC fan-out, but a hard global guarantee requires an edge WAF or firewall rate limit, which is deferred to production.
- What personal data does the platform store?
- There are no user accounts, passwords, or logins. Public proof verification works without a wallet or sign-in. On-chain records contain a SHA-256 certificate hash, credential metadata, and public wallet addresses — no private keys are stored in code, environment variables, or version control.
- How does the frontend prevent XSS?
- Every request gets a fresh Content-Security-Policy with a random per-request nonce; production script-src allows only self, nonce-tagged scripts, and Vercel analytics — no unsafe-inline. JSON-LD output is serialized through an escaping helper (replacing angle brackets, ampersands, and line separators), untrusted metadata is length-truncated and URL-checked before render, and frame-src and object-src are set to none.
Run it yourself on testnet
Everything documented here is live in the early-access pilot — free, testnet-only, and auditable on stellar.expert.