Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,15 @@ RATE_LIMIT_PER_MINUTE=30
PAYMENT_AMOUNT_USDC=0.001
PAYMENT_AMOUNT_STROOPS=10000

# Number of reverse-proxy hops between clients and this app (Vercel, nginx, LB).
# Express trusts exactly this many hops to resolve the real client IP, so
# express-rate-limit buckets each distinct client separately.
# unset / 0 → trust NO proxy (default). req.ip ignores X-Forwarded-For;
# safest when the app is reachable directly.
# 1, 2, ... → trust that many proxy hops. Set to your proxy count (Vercel: 1).
# true → trust all proxies. Only for opaque, fully-controlled networks.
TRUST_PROXY_HOPS=0

# Comma-separated browser origins allowed when NODE_ENV=production.
# Development always uses * regardless of this value.
# Example: http://localhost:5173,https://your-app.vercel.app
Expand Down
60 changes: 53 additions & 7 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,5 @@
# Contributing to StellarSearch

<<<<<<< HEAD
Thanks for contributing to StellarSearch. This guide covers the local setup, the wallet and Stellar testnet requirements, and the conventions used for pull requests.
=======
Thank you for taking the time to contribute! StellarSearch is an open-source, pay-per-query web search API built on the Stellar blockchain using the x402 payment protocol. Every improvement — from a one-line typo fix to a full feature implementation — is welcome.

This document covers everything you need to go from zero to a merged pull request.
Expand Down Expand Up @@ -64,7 +61,6 @@ Serper.dev ──── real Google results ────► Browser
| `serper.dev` | Real-time Google search results |

---
>>>>>>> 7804e9c (feat(ci): generate CycloneDX SBOM and gate dependency vulnerabilities)

## Prerequisites

Expand Down Expand Up @@ -114,6 +110,57 @@ Install or prepare the following before you begin:

6. Open [http://localhost:5173](http://localhost:5173) in your browser. The backend runs at [http://localhost:3001](http://localhost:3001). Check its health endpoint at [http://localhost:3001/health](http://localhost:3001/health).

```bash
# Required for x402 payment flow
STELLAR_RECEIVING_ADDRESS=GXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
STELLAR_NETWORK=stellar:testnet
VITE_STELLAR_NETWORK=stellar:testnet
FACILITATOR_URL=https://www.x402.org/facilitator

# Server
PORT=3001

# Hops between clients and the backend (0 = no proxy, 1 = Vercel/nginx)
TRUST_PROXY_HOPS=0

# Frontend — points the React app at your local backend
VITE_SERVER_URL=http://localhost:3001
```

> **Trusting proxies (Vercel / nginx / load balancers).** `express-rate-limit`
> keys clients by `req.ip`. Behind a proxy, `req.ip` is the proxy's IP unless the
> app is told how many hops to trust — otherwise distinct clients share one bucket
> and, without validation, spoofed `X-Forwarded-For` headers could be honored.
> Set `TRUST_PROXY_HOPS` explicitly per deployment:
>
> | Value | Behavior |
> |---|---|
> | unset / `0` | Trust no proxy (default). `req.ip` ignores `X-Forwarded-For` entirely — safe directly or in single-process setups. |
> | `1`, `2`, … | Trust exactly that many hops (e.g. `1` for Vercel). Distinct real clients get separate rate-limit buckets. |
> | `true` | Trust all proxies — only for opaque, fully-controlled networks. |
>
> This is wired in `server/index.ts` via `app.set('trust proxy', …)`.

### 4. Set up Freighter (for payment flow work)

1. Install [Freighter](https://freighter.app) browser extension.
2. Create a new wallet (or import one).
3. Switch to **Testnet**: Settings → Network → Testnet.
4. Get a funded testnet account at [Stellar Lab](https://laboratory.stellar.org/#account-creator?network=test).
5. Add the USDC trustline and claim testnet USDC from the faucet.

> If you are **not** working on the wallet or payment flow, you can skip step 4 entirely — the frontend works without a wallet for most UI changes.

### 5. Start the development servers

```bash
# Terminal 1 — Express backend (port 3001)
npm run server

# Terminal 2 — Vite frontend (port 5173)
npm run dev
```

You can also start both processes together with:

```bash
Expand Down Expand Up @@ -194,9 +241,8 @@ Before requesting review:
- Keep secrets, `.env` files, generated output, and unrelated changes out of the PR.
- Confirm that the change works on Stellar Testnet when it touches wallet or payment behavior.

<<<<<<< HEAD
Please keep reviews constructive and update the PR when feedback is addressed.
=======

2. Open a PR against `main` on GitHub.

3. Fill in the PR template:
Expand Down Expand Up @@ -427,6 +473,7 @@ node scripts/check-vulnerabilities.mjs osv-results.json # enforce the gate
| `global is not defined` | Stellar SDK needs `globalThis` polyfill | Already handled in `vite.config.ts` — do not remove the `define` block |
| Buffer errors in browser | `buffer` package not aliased | `resolve.alias` in `vite.config.ts` handles this |
| CORS errors in dev | Frontend calling server directly | Use the Vite proxy (`/search`, `/ai`, `/health` already proxied) |
| All users share one rate-limit bucket / spoofed IPs bypass limits | `TRUST_PROXY_HOPS` not set behind Vercel/nginx | Set `TRUST_PROXY_HOPS=1` (or your proxy hop count); see *Trusting proxies* above |

### Stellar / Horizon

Expand Down Expand Up @@ -454,4 +501,3 @@ All contributors are welcome to add themselves to a `CONTRIBUTORS` list. When yo
---

*StellarSearch — Stellar Hackathon 2026 · Agents on Stellar*
>>>>>>> 7804e9c (feat(ci): generate CycloneDX SBOM and gate dependency vulnerabilities)
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,10 +98,12 @@ All environment variables are read from a local `.env` (see the sanitized `.env.
| `VITE_STELLAR_NETWORK` | No | `stellar:testnet` | Frontend copy of `STELLAR_NETWORK` (must be prefixed `VITE_` for browser access). Falls back to testnet if missing. | `stellar:testnet` |
| `FACILITATOR_URL` | No | `https://www.x402.org/facilitator` | x402 facilitator endpoint for payment settlement. Falls back to the public OpenZeppelin facilitator if missing. | `https://www.x402.org/facilitator` |
| `PORT` | No | `3001` | Express server listen port. Falls back to `3001` if missing. | `3001` |
| `TRUST_PROXY_HOPS` | No | `0` | Reverse-proxy hops to trust so the rate limiter resolves real client IPs (e.g. `1` for Vercel). `0`/unset disables trusting `X-Forwarded-For` (spoof-safe); `true` trusts all proxies. | `1` |
| `RATE_LIMIT_PER_MINUTE` | No | `30` | Positive request limit applied by Express. | `30` |
| `PAYMENT_AMOUNT_USDC` | No | `0.001` | Positive USDC amount. Must exactly equal `PAYMENT_AMOUNT_STROOPS / 10^7`. | `0.001` |
| `PAYMENT_AMOUNT_STROOPS` | No | `10000` | Positive Stellar stroop amount paired with `PAYMENT_AMOUNT_USDC`. | `10000` |
| `VITE_SERVER_URL` | No | `/api` | Browser-safe API base URL. Defaults to same-origin `/api`, which works for custom domains and subpaths; Vite proxies it to Express locally. | `/api` or `https://api.example.com/stellar` |
| `MCP_ENABLE_RECEIPTS` | No | `0` | Set `1` to opt-in MCP local receipt storage for `stellar-search://receipts/recent` (in-memory capped at 50) | `1` |

### Deployment configuration

Expand Down
24 changes: 24 additions & 0 deletions server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,30 @@ const app = express()
const PORT = config.port
const RATE_LIMIT_PER_MINUTE = config.rateLimitPerMinute

// ─── Trust proxy (per-deployment) ──────────────────────────────────────────
// Behind Vercel, nginx, a load balancer, etc., req.ip reflects the nearest
// proxy unless Express is told how many trusted hops sit in front of it.
// Without this, express-rate-limit keys every client to the proxy's IP (so
// distinct clients share one bucket) while untrusted X-Forwarded-For values
// let a single client masquerade as many IPs.
//
// Values (TRUST_PROXY_HOPS):
// unset/0 → do NOT trust any proxy (Express default). Safe for direct,
// single-process deployments.
// <n> e.g. 1 → trust exactly <n> hops. Set this to the number of reverse
// proxies in front of the app (Vercel typically 1).
// true → trust all proxies (only for opaque, controlled networks).
function resolveTrustProxy(): boolean | number {
const raw = process.env.TRUST_PROXY_HOPS
if (raw === undefined || raw === '') return false
if (raw.toLowerCase() === 'true') return true
const hops = parseInt(raw, 10)
return Number.isFinite(hops) && hops > 0 ? hops : false
}

const TRUST_PROXY = resolveTrustProxy()
app.set('trust proxy', TRUST_PROXY)

const limiter = rateLimit({
windowMs: 60 * 1000,
max: RATE_LIMIT_PER_MINUTE,
Expand Down
115 changes: 115 additions & 0 deletions server/trustProxy.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
// @vitest-environment node
/**
* server/trustProxy.test.ts
*
* Proves the Express trust-proxy configuration drives how express-rate-limit
* identifies clients behind a reverse proxy (Vercel, nginx, load balancer):
*
* - The number of trusted hops is explicit and configurable per deployment
* via TRUST_PROXY_HOPS (unset/0 => no proxy trusted; <n> => trust n hops;
* true => trust all).
* - Distinct clients behind a trusted proxy are limited separately.
* - Spoofed X-Forwarded-For headers cannot bypass the limits: when no proxy
* is trusted (default), req.ip ignores XFF entirely, so a single client
* that flips the header cannot multiply its allowance.
*/

import { describe, it, expect, vi } from 'vitest'
import request from 'supertest'

// Mock x402 + heavy deps before importing app
vi.mock('@x402/express', () => ({
paymentMiddlewareFromConfig: () => (_req: any, _res: any, next: any) => next(),
}))
vi.mock('@x402/core/server', () => ({
HTTPFacilitatorClient: class { constructor(_opts: any) {} },
}))
vi.mock('@x402/stellar/exact/server', () => ({
ExactStellarScheme: class { constructor() {} },
}))
vi.mock('groq-sdk', () => ({
default: class {
chat = { completions: { create: vi.fn() } }
},
}))
vi.mock('./logger', () => ({
default: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
}))

process.env.STELLAR_RECEIVING_ADDRESS = 'GAAZI4TCR3TY5OJHCTJC2A4AFL5MNSF3GAKGOWG5W2LBBGCS2TDPZOM3'
process.env.SERPER_API_KEY = 'test-serper-key'
process.env.GROQ_API_KEY = 'gsk_test'

// Fresh module graph (and fresh express-rate-limit store) per case so buckets
// never leak between tests. TRUST_PROXY_HOPS / RATE_LIMIT_PER_MINUTE are read
// at import time inside server/index.ts.
async function loadApp(
trustProxyHops: string | undefined,
rateLimitPerMinute = '3'
): Promise<any> {
vi.resetModules()
if (trustProxyHops === undefined) delete process.env.TRUST_PROXY_HOPS
else process.env.TRUST_PROXY_HOPS = trustProxyHops
process.env.RATE_LIMIT_PER_MINUTE = rateLimitPerMinute
const mod = await import('./index.js')
return mod.default
}

describe('trust proxy — explicit hop configuration', () => {
it('defaults to NOT trusting any proxy (X-Forwarded-For ignored)', async () => {
const app = await loadApp(undefined)
expect(app.get('trust proxy')).toBe(false)
})

it('TRUST_PROXY_HOPS=<n> trusts exactly n hops', async () => {
const app = await loadApp('1')
expect(app.get('trust proxy')).toBe(1)
})

it('TRUST_PROXY_HOPS=true trusts all proxies', async () => {
const app = await loadApp('true')
expect(app.get('trust proxy')).toBe(true)
})
})

describe('trust proxy — rate limiter client isolation', () => {
it('distinct clients behind one trusted proxy each get their own bucket', async () => {
const app = await loadApp('1', '2')

// Client A uses its full allowance in its own bucket.
await request(app).get('/health').set('X-Forwarded-For', '198.51.100.11')
await request(app).get('/health').set('X-Forwarded-For', '198.51.100.11')
const aThird = await request(app).get('/health').set('X-Forwarded-For', '198.51.100.11')
expect(aThird.status).toBe(429)

// Client B with a different forwarded IP is a separate bucket → allowed.
const b = await request(app).get('/health').set('X-Forwarded-For', '198.51.100.22')
expect(b.status).toBe(200)
})

it('with no trusted proxy, spoofed X-Forwarded-For headers do not bypass the limit', async () => {
const app = await loadApp(undefined, '2')

// Two allowed requests, each carrying a DIFFERENT spoofed upstream address.
await request(app).get('/health').set('X-Forwarded-For', '203.0.113.1')
await request(app).get('/health').set('X-Forwarded-For', '203.0.113.2')

// A third request with yet another spoofed address must still be limited:
// with trust proxy disabled, req.ip ignores XFF, so every request maps to
// the single real client socket and shares one bucket.
const res = await request(app).get('/health').set('X-Forwarded-For', '203.0.113.9')
expect(res.status).toBe(429)
expect(res.body.error).toMatch(/Too many requests/)
})

it('a trusted client that legitimately passes the limit is not blocked', async () => {
const app = await loadApp('1', '5')
for (let i = 0; i < 5; i++) {
const res = await request(app).get('/health').set('X-Forwarded-For', '10.0.0.7')
expect(res.status).toBe(200)
}
// Sixth request in the same bucket over the limit.
const blocked = await request(app).get('/health').set('X-Forwarded-For', '10.0.0.7')
expect(blocked.status).toBe(429)
})
})