CORTX is a server-side monitoring tool that makes real payments from a test wallet on behalf of registered users. The primary threats are:
- SSRF — a user registers a malicious endpoint URL that causes the check runner to probe internal infrastructure
- Overspend — a malicious or misconfigured service charges more than expected, draining the test wallet
- Key exposure — the test wallet private key escapes into logs, the database, or error messages
- Data isolation — a user reads another user's services, checks, or incidents
- Cron abuse — the scheduler endpoint is invoked by an external party to trigger unauthorized checks
- Injection — user-supplied JSON (test_input, expected_schema) is used in a way that allows code execution
CORTX uses a single dedicated test wallet for all check payments. This wallet is separate from any production funds and holds only the minimum balance needed for testing.
- Private key stored in
CORTX_TEST_WALLET_KEYenvironment variable - Never written to the database
- Never included in log output (check runner catches and strips key patterns before logging)
- Never returned in API responses
- Never included in error messages — caught at the application boundary and replaced with
[REDACTED]
function getTestWalletKey(): string {
const key = process.env.CORTX_TEST_WALLET_KEY;
if (!key) throw new Error('CORTX_TEST_WALLET_KEY not set');
return key;
}The key is fetched at execution time, not at module load time. It is never assigned to a variable that persists beyond the function scope.
Before executing a payment, the runner verifies the wallet has sufficient balance. If balance < observed_price: abort with INSUFFICIENT_BALANCE. No payment is attempted.
Spend caps are enforced server-side before the payment stage. They are checked in sequence:
observed_price must be ≤ service.max_price. This is the hard gate. If exceeded: abort with PRICE_EXCEEDS_MAXIMUM, do not proceed to payment under any circumstances.
This check happens in the check runner, not in user input validation. Even if a service config record were tampered with, the runtime check uses the observed price from the endpoint response, not from a client-supplied value.
Total spend across all checks in the last 24 hours (rolling window) must not exceed CORTX_DAILY_SPEND_CAP_USDC (env var, default: 1.00).
const todaySpend = await getTodaySpend(); // sum of observed_price for passed payment stages today
if (todaySpend + observedPrice > dailyCap) {
throw new Error('SPEND_CAP_EXCEEDED');
}Total spend in the current calendar month must not exceed CORTX_MONTHLY_SPEND_CAP_USDC (env var, default: 10.00).
Both caps are checked before payment. If either would be exceeded: abort without payment, log the cap event, continue storing the check result with a SPEND_CAP_EXCEEDED error in the payment stage evidence.
Before any network request, the endpoint URL is validated:
- Scheme check — must be
https. HTTP is rejected. - DNS resolution — resolve the hostname to an IP address at validation time, not at request time (prevents DNS rebinding attacks where the IP changes between check and request).
- Private range check — the resolved IP must not fall in any reserved range:
| Range | Description |
|---|---|
10.0.0.0/8 |
Private |
172.16.0.0/12 |
Private |
192.168.0.0/16 |
Private |
127.0.0.0/8 |
Loopback |
::1/128 |
IPv6 loopback |
169.254.0.0/16 |
Link-local (APIPA) |
fc00::/7 |
IPv6 unique local |
0.0.0.0/8 |
Reserved |
100.64.0.0/10 |
Shared address space (CGNAT) |
198.18.0.0/15 |
Benchmarking |
- Port check — blocked ports:
22, 25, 465, 587, 3306, 5432, 6379, 27017. Default HTTPS port (443) is always allowed. - TLS validation — self-signed certificates are rejected. Certificate must be valid and chain to a trusted CA.
import dns from 'node:dns/promises';
import ipaddr from 'ipaddr.js';
async function validateUrl(endpointUrl: string): Promise<URL> {
let parsed: URL;
try {
parsed = new URL(endpointUrl);
} catch {
throw new StageError('INVALID_URL');
}
if (parsed.protocol !== 'https:') throw new StageError('NON_HTTPS');
const blockedPorts = [22, 25, 465, 587, 3306, 5432, 6379, 27017];
const port = parsed.port ? parseInt(parsed.port) : 443;
if (blockedPorts.includes(port)) throw new StageError('BLOCKED_PORT');
const records = await dns.lookup(parsed.hostname, { all: true });
for (const record of records) {
const ip = ipaddr.parse(record.address);
if (ip.range() !== 'unicast') throw new StageError('SSRF_BLOCKED');
}
return parsed;
}Secrets are redacted from all evidence before it is written to the database.
Redaction runs as a post-processing step after the full pipeline completes, before any database write.
Any evidence field whose key matches these patterns (case-insensitive) has its value replaced with "[REDACTED]":
private_key,privatekey,private_key_hexsecret,api_key,apikey,api_secretauthorization,bearer,auth_token,access_tokenpassword,passwd,credentialmnemonic,seed_phrase,seedphrase
String values matching these patterns are redacted regardless of key name:
- Base58 strings 44–88 chars long (Solana private keys)
0xfollowed by 64 hex characters (Ethereum private keys)- Strings matching
Bearer [A-Za-z0-9._-]{20,}(bearer tokens) - AWS secret pattern: 40-char alphanumeric strings following
aws_secret_access_key
function redactSecrets(obj: unknown): unknown {
if (typeof obj === 'string') return redactString(obj);
if (Array.isArray(obj)) return obj.map(redactSecrets);
if (obj && typeof obj === 'object') {
return Object.fromEntries(
Object.entries(obj as Record<string, unknown>).map(([k, v]) => [
k,
isSecretKey(k) ? '[REDACTED]' : redactSecrets(v),
])
);
}
return obj;
}Redaction is applied to the entire stages array before insert.
All database tables have RLS enabled. Users can only access their own rows.
| Table | Policy |
|---|---|
profiles |
SELECT, UPDATE: id = auth.uid() |
services |
ALL: user_id = auth.uid() |
checks |
SELECT: user_id = auth.uid(). INSERT: service role only |
incidents |
ALL: user_id = auth.uid() |
alert_destinations |
ALL: user_id = auth.uid() |
The check runner uses the Supabase service role key (stored in SUPABASE_SERVICE_ROLE_KEY env var) to insert check results. The service role bypasses RLS. This key is never exposed to the client.
The client-facing Next.js app uses the anon key (NEXT_PUBLIC_SUPABASE_ANON_KEY), which is subject to RLS.
The Vercel Cron endpoint that triggers check runs must not be invokable by external parties.
Vercel automatically injects an Authorization: Bearer $CRON_SECRET header on all cron-triggered requests. The endpoint validates this header before executing any checks:
export async function GET(req: Request) {
const authHeader = req.headers.get('Authorization');
if (authHeader !== `Bearer ${process.env.CRON_SECRET}`) {
return new Response('Unauthorized', { status: 401 });
}
// proceed with checks
}CRON_SECRET is a random 32-byte hex string generated at project setup and stored as a Vercel environment variable.
User-supplied JSON fields (test_input, expected_schema) are stored as JSONB in Postgres. They are never executed, eval'd, or interpolated into shell commands. They are deserialized and passed to controlled functions only.
expected_schema is compiled by AJV at check time. AJV schema compilation does not execute arbitrary code. The schema is validated against the JSON Schema meta-schema before storage (at add-service time).
test_input is serialized to a JSON string and sent as an HTTP request body. It is not interpolated into any SQL query or command.
| Variable | Required | Notes |
|---|---|---|
CORTX_TEST_WALLET_KEY |
✓ | Test wallet private key. Never logged. |
SUPABASE_SERVICE_ROLE_KEY |
✓ | Service role key for check runner DB writes. Never exposed to client. |
NEXT_PUBLIC_SUPABASE_URL |
✓ | Public Supabase project URL |
NEXT_PUBLIC_SUPABASE_ANON_KEY |
✓ | Public anon key (subject to RLS) |
CRON_SECRET |
✓ | Random secret for cron endpoint auth |
TELEGRAM_BOT_TOKEN |
✓ | Bot token for sending alerts |
CORTX_DAILY_SPEND_CAP_USDC |
— | Default: 1.00 |
CORTX_MONTHLY_SPEND_CAP_USDC |
— | Default: 10.00 |
No secret is ever written to .env.local that is committed to git. .env.local is in .gitignore.
The following events are logged to the server console (structured JSON) and retained in Vercel's log drain:
| Event | Fields logged |
|---|---|
| Check started | service_id, started_at |
| Check completed | service_id, status, failure_stage, latency_ms |
| Payment attempted | service_id, amount (not key, not tx_hash) |
| Spend cap enforced | service_id, daily_spend, cap |
| SSRF blocked | service_id, url, reason |
| Incident opened | service_id, incident_id, severity |
| Incident resolved | service_id, incident_id, resolution_type |
| Alert sent | service_id, incident_id, channel |
| Alert failed | service_id, incident_id, attempt, error |
| Runner error | service_id, error_message (secrets stripped) |
Private keys, transaction hashes, and wallet addresses are never logged.