Skip to content

Commit 9bf6437

Browse files
Security hardening, wallet fast-path, and doc updates
Security: - Add URL validation (core/url_validation.py) to block SSRF: private IPs, cloud metadata, non-HTTP schemes return 400 url_blocked - Sanitize error responses: generic messages to clients, details only in logs - Wallet ledger: thread-safe balance operations, crypto-random tokens, test wallets only when FAIRFETCH_TEST_MODE=true - CORS: allow * only in test mode; production restricted to publisher domain - Validate wallet inputs: positive amounts, owner length, balance caps Wallet fast-path: - In-memory WalletLedger with charge/top_up/transactions - X-WALLET-TOKEN skips 402 when balance sufficient - Endpoints: POST /wallet/register, GET /wallet/balance, POST /wallet/topup, GET /wallet/transactions Docs: - README: Security section, config notes, project structure - DEVELOPMENT: URL validation and test_mode behavior - CONCEPTS: Allowed URLs and SSRF protection - AI_AGENT_GUIDE: url_blocked, test wallets only in test mode - PUBLISHER_GUIDE: Production CORS and wallet behavior - openapi: 400 UrlBlocked, 502/503, X-WALLET-TOKEN in payment description
1 parent 0ae286a commit 9bf6437

14 files changed

Lines changed: 1154 additions & 82 deletions

DEVELOPMENT.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,31 @@ make lint # ruff check + format
166166
make typecheck # mypy strict mode
167167
```
168168

169+
## Security
170+
171+
### URL validation (SSRF protection)
172+
173+
The `url` query parameter is validated before any outbound fetch. Blocked targets include:
174+
175+
- Non-HTTP(S) schemes (`file://`, `ftp://`, etc.)
176+
- Loopback and private IPs (`127.x`, `10.x`, `172.16–31.x`, `192.168.x`)
177+
- Link-local and cloud metadata (e.g. `169.254.169.254`, `metadata.google.internal`)
178+
179+
Requests with a disallowed URL receive `400` with `{"error": "url_blocked", "detail": "The requested URL is not allowed."}`.
180+
181+
To test:
182+
183+
```bash
184+
curl -s "http://localhost:8402/content/fetch?url=http://127.0.0.1/admin" \
185+
-H "X-PAYMENT: test_paid_fairfetch"
186+
# → 400, url_blocked
187+
```
188+
189+
### Test mode vs production
190+
191+
- **`FAIRFETCH_TEST_MODE=true`** (default): CORS allows all origins (`*`); wallet ledger pre-seeds `wallet_test_agent_alpha` and `wallet_test_agent_beta`; mock payment tokens accepted.
192+
- **`FAIRFETCH_TEST_MODE=false`**: CORS is restricted to `https://{FAIRFETCH_PUBLISHER_DOMAIN}`; no pre-seeded wallets; use real payment integration.
193+
169194
## Architecture Decisions
170195

171196
### Open Core: interfaces/ vs implementations

README.md

Lines changed: 78 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -289,6 +289,65 @@ X-Content-Hash: sha256:2c449548... # Fingerprint of the content
289289
290290
<br />
291291

292+
## 👛 Wallet-Based Payment (Fast Path)
293+
294+
The 402 round-trip makes sense for discovery, but once an AI company is onboarded it's inefficient to negotiate payment on every request. Fairfetch supports **pre-funded wallets** that skip the 402 entirely:
295+
296+
```bash
297+
# Register a wallet (in production, this happens through the Fairfetch marketplace)
298+
curl -X POST "http://localhost:8402/wallet/register?owner=AcmeAI&initial_balance=100000"
299+
# → {"wallet_token": "wallet_a1b2c3d4...", "balance": 100000, ...}
300+
301+
# Now fetch content instantly — no 402, no X-PAYMENT negotiation
302+
curl -H "X-WALLET-TOKEN: wallet_test_agent_alpha" \
303+
-H "Accept: text/markdown" \
304+
"http://localhost:8402/content/fetch?url=https://example.com"
305+
```
306+
307+
The response includes your remaining balance and a transaction receipt:
308+
309+
```http
310+
X-FairFetch-Payment-Method: wallet # Paid via wallet (not x402)
311+
X-FairFetch-Wallet-Balance: 99000 # Remaining balance after this charge
312+
X-PAYMENT-RECEIPT: ff_3a7c9e2b... # Transaction ID in the ledger
313+
X-FairFetch-License-ID: 47db4290...:k2+w # Usage Grant (same as x402 flow)
314+
```
315+
316+
**How it works in practice:**
317+
318+
| | x402 (One-Time Payment) | Wallet (Pre-Funded) |
319+
|---|---|---|
320+
| **First request** | 402 → pay → retry → content | Content immediately |
321+
| **Round-trips** | 2 | 1 |
322+
| **Best for** | Occasional access, discovery | High-volume production use |
323+
| **Billing** | Per-request settlement | Balance deducted, settled monthly (Premium) |
324+
325+
<details>
326+
<summary><strong>Wallet management endpoints</strong></summary>
327+
328+
```bash
329+
# Check balance
330+
curl "http://localhost:8402/wallet/balance?token=wallet_test_agent_alpha"
331+
# → {"owner": "TestAgentAlpha", "balance": 99000, ...}
332+
333+
# Add funds
334+
curl -X POST "http://localhost:8402/wallet/topup?token=wallet_test_agent_alpha&amount=50000"
335+
# → {"amount_added": 50000, "new_balance": 149000}
336+
337+
# Transaction history
338+
curl "http://localhost:8402/wallet/transactions?token=wallet_test_agent_alpha"
339+
# → {"transactions": [{"tx_id": "ff_...", "amount": 1000, ...}, ...]}
340+
```
341+
342+
</details>
343+
344+
> [!TIP]
345+
> Two test wallets are pre-loaded for local development:
346+
> - `wallet_test_agent_alpha` — balance 100,000 ($0.10)
347+
> - `wallet_test_agent_beta` — balance 500,000 ($0.50)
348+
349+
<br />
350+
292351
## 📊 Usage Categories & Tiered Pricing
293352

294353
Not all content usage is equal. Fairfetch defines **usage categories** that control what an AI agent is permitted to do with the content, with escalating compliance requirements and pricing:
@@ -480,7 +539,9 @@ Every successful response includes these headers. Think of them as a receipt and
480539
| `X-FairFetch-Origin-Signature` | A digital fingerprint proving the publisher's server produced this exact content. Like a notary stamp — tamper-proof. | `GllQLb/V4Vd+Su...` (base64) |
481540
| `X-FairFetch-License-ID` | Your Usage Grant reference. Store this — it's your proof of legal access if questions arise later. Format: `grant_id:signature_prefix`. | `47db4290...:k2+wXE3x...` |
482541
| `X-Content-Hash` | A fingerprint of the content body itself, so you can verify nothing was altered in transit. | `sha256:2c449548...` |
483-
| `X-PAYMENT-RECEIPT` | Proof that payment was settled. In test mode this is a simulated transaction hash. In production, a real on-chain transaction ID. | `0x6d8ce1bf...` |
542+
| `X-PAYMENT-RECEIPT` | Proof that payment was settled. For x402: a transaction hash. For wallets: a ledger transaction ID (`ff_...`). | `0x6d8ce1bf...` or `ff_3a7c9e...` |
543+
| `X-FairFetch-Payment-Method` | How the agent paid: `wallet` (pre-funded account) or `x402` (one-time payment). | `wallet` |
544+
| `X-FairFetch-Wallet-Balance` | Remaining wallet balance after this charge (only present for wallet payments). | `99000` |
484545
| `X-Fairfetch-Version` | Protocol version, so clients know which Fairfetch spec they're talking to. | `0.2` |
485546

486547
> [!TIP]
@@ -510,6 +571,7 @@ Every successful response includes these headers. Think of them as a receipt and
510571
```
511572
fairfetch/
512573
├── docs/ # Guides for publishers & AI agents
574+
│ ├── CONCEPTS.md # Plain-language concepts & headers
513575
│ ├── PUBLISHER_GUIDE.md # CDN deployment & onboarding
514576
│ └── AI_AGENT_GUIDE.md # MCP/REST integration for agents
515577
├── interfaces/ # Open Standard (abstract bases)
@@ -520,7 +582,8 @@ fairfetch/
520582
│ ├── converter.py # HTML → Markdown (trafilatura)
521583
│ ├── summarizer.py # LiteLLM implementation
522584
│ ├── knowledge_packet.py # JSON-LD builder
523-
│ └── signatures.py # Ed25519 signing
585+
│ ├── signatures.py # Ed25519 signing
586+
│ └── url_validation.py # SSRF protection (block private/metadata URLs)
524587
├── mcp_server/ # Direct Pipeline (MCP)
525588
│ └── server.py # FastMCP tools + resources
526589
├── api/ # Direct Pipeline (REST)
@@ -529,7 +592,8 @@ fairfetch/
529592
│ ├── negotiation.py # Content negotiation + bot steering
530593
│ └── dependencies.py # FairFetchConfig + DI
531594
├── payments/ # x402 micro-payments
532-
│ ├── x402.py # Middleware with grant issuance
595+
│ ├── x402.py # Middleware (wallet + x402)
596+
│ ├── wallet_ledger.py # In-memory wallet ledger (test_mode seeds)
533597
│ ├── mock_facilitator.py # Local test facilitator
534598
│ └── mock_license_facilitator.py
535599
├── compliance/ # EU AI Act 2026
@@ -545,7 +609,7 @@ fairfetch/
545609
│ └── akamai/ # EdgeWorkers (JS)
546610
├── scripts/ # Dev scripts
547611
│ └── dev_server.py # Local launcher (make dev)
548-
├── tests/ # 106 tests · 98% coverage
612+
├── tests/ # 127 tests · 98% coverage
549613
├── .github/workflows/ # CI pipeline
550614
├── openapi.yaml # REST API spec
551615
├── mcp.json # MCP Inspector config
@@ -560,9 +624,9 @@ fairfetch/
560624

561625
| Variable | Default | Description |
562626
|----------|---------|-------------|
563-
| `FAIRFETCH_TEST_MODE` | `true` | Enable mock facilitator + grants |
627+
| `FAIRFETCH_TEST_MODE` | `true` | Enable mock facilitator + grants; when `false`, CORS is restricted to your domain and no test wallets are pre-seeded |
564628
| `FAIRFETCH_PUBLISHER_WALLET` | `0x000...` | EVM wallet for payments |
565-
| `FAIRFETCH_PUBLISHER_DOMAIN` | `localhost` | Publisher domain |
629+
| `FAIRFETCH_PUBLISHER_DOMAIN` | `localhost` | Publisher domain (also used as CORS origin when test mode is off) |
566630
| `FAIRFETCH_CONTENT_PRICE` | `1000` | Price in smallest USDC unit |
567631
| `FAIRFETCH_SIGNING_KEY` | *(generated)* | Ed25519 private key (b64) |
568632
| `FAIRFETCH_LICENSE_TYPE` | `publisher-terms` | Default license |
@@ -573,6 +637,14 @@ fairfetch/
573637

574638
<br />
575639

640+
## 🔒 Security
641+
642+
- **URL validation:** The `url` parameter is validated before any outbound request. Private IPs (e.g. `127.0.0.1`, `10.x`, `192.168.x`), cloud metadata endpoints (e.g. `169.254.169.254`), and non-HTTP(S) schemes are rejected with `400` and `error: "url_blocked"`. This prevents SSRF (server-side request forgery).
643+
- **Test mode:** With `FAIRFETCH_TEST_MODE=false`, CORS allows only `https://{FAIRFETCH_PUBLISHER_DOMAIN}` and the ledger does not pre-seed test wallets. Use test mode only for local development.
644+
- **Error responses:** Upstream fetch and summarization errors return generic messages to clients; details are logged server-side only.
645+
646+
<br />
647+
576648
## 📖 Detailed Guides
577649

578650
| Guide | What's Inside |

api/main.py

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
get_config,
2020
)
2121
from api.routes import router
22+
from payments.wallet_ledger import WalletLedger
2223
from payments.x402 import X402Middleware
2324

2425
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(name)s %(levelname)s %(message)s")
@@ -41,14 +42,17 @@ def create_app() -> FastAPI:
4142
redoc_url="/redoc",
4243
)
4344

45+
cors_origins = ["*"] if config.test_mode else [f"https://{config.publisher_domain}"]
46+
4447
application.add_middleware(
4548
CORSMiddleware,
46-
allow_origins=["*"],
49+
allow_origins=cors_origins,
4750
allow_methods=["*"],
4851
allow_headers=[
4952
"*",
5053
"X-PAYMENT",
5154
"X-PAYMENT-RECEIPT",
55+
"X-WALLET-TOKEN",
5256
"X-USAGE-CATEGORY",
5357
"X-FairFetch-License-ID",
5458
"X-FairFetch-Origin-Signature",
@@ -61,6 +65,8 @@ def create_app() -> FastAPI:
6165
"X-FairFetch-Compliance-Level",
6266
"X-FairFetch-License-ID",
6367
"X-FairFetch-Origin-Signature",
68+
"X-FairFetch-Payment-Method",
69+
"X-FairFetch-Wallet-Balance",
6470
"X-FairFetch-Preferred-Access",
6571
"X-FairFetch-LLMS-Txt",
6672
"X-FairFetch-MCP-Endpoint",
@@ -73,14 +79,23 @@ def create_app() -> FastAPI:
7379
license_provider = (
7480
build_license_provider(config, signer) if config.enable_usage_grants else None
7581
)
82+
wallet_ledger = WalletLedger(test_mode=config.test_mode)
7683

7784
application.add_middleware(
7885
X402Middleware,
7986
facilitator=facilitator,
8087
requirement=requirement,
8188
license_provider=license_provider,
89+
wallet_ledger=wallet_ledger,
8290
paid_path_prefixes=["/content/"],
83-
exempt_paths=["/health", "/openapi.json", "/docs", "/redoc", "/compliance/"],
91+
exempt_paths=[
92+
"/health",
93+
"/openapi.json",
94+
"/docs",
95+
"/redoc",
96+
"/compliance/",
97+
"/wallet/",
98+
],
8499
)
85100

86101
application.state.config = config
@@ -89,6 +104,7 @@ def create_app() -> FastAPI:
89104
application.state.summarizer = build_summarizer(config)
90105
application.state.packet_builder = build_packet_builder(signer)
91106
application.state.license_provider = license_provider
107+
application.state.wallet_ledger = wallet_ledger
92108
application.state.scraper_intercept_count = 0
93109

94110
application.include_router(router)

0 commit comments

Comments
 (0)