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 cron endpoint that triggers check runs must not be invokable by external parties.
CORTX uses cron-job.org (not Vercel Cron) as the scheduler. The Authorization: Bearer {CRON_SECRET} header must be manually configured in the cron-job.org job settings — it is not injected automatically.
The endpoint validates the Bearer token before executing any checks:
export async function GET(req: Request) {
const auth = req.headers.get('authorization') ?? '';
const secret = auth.startsWith('Bearer ') ? auth.slice(7) : '';
if (!secret || secret !== 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. It must also be entered into the cron-job.org job's "Request headers" configuration.
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.
These are confirmed gaps identified during the Aug 2026 codebase audit. They are documented here so they are tracked and not mistaken for intended behavior. All are targeted for V1.1.
Gap: The endpoint that confirms endpoint ownership during the Claimed verification flow (verify confirm) does not apply the same SSRF protection used in the check runner. A user could claim ownership of an internal URL.
Target fix: Apply validateAndResolveUrl (or equivalent) to all URLs accepted by the verify confirm endpoint before any outbound request.
Severity: High — a bypassed SSRF check in an ownership verification flow allows probing internal infrastructure under the appearance of a legitimate claim.
Gap: When the daily or monthly spend cap is reached, paid checks silently stop. The builder sees no status change and no alert. The monitoring product continues to appear healthy while paid verification has halted.
Target fix:
- Persist a
monitoring_paused_reasonfield on services (or a separate monitoring-state table) when spend cap is hit - Expose the paused state in the service detail UI (distinct from Operational / Degraded / Critical / Unknown)
- Send a Telegram alert when the service enters the paused state due to budget exhaustion
- Send a low-balance alert when the monitoring wallet balance falls below a configurable threshold (default: 3 days of estimated spend)
Severity: High — CORTX implies reliability information is current when it is not. This is the highest immediate trust risk.
Gap: Simultaneous paid checks can race against the same cumulative daily/monthly spend total. Two checks reading the same todaySpend value before either writes a payment can both pass the cap check and together overshoot the cap.
Target fix: Implement a concurrency-safe spend reservation before the payment stage — either a database-level advisory lock, a Postgres FOR UPDATE on the spend tally row, or an atomic increment with a rollback path if the reservation fails.
Severity: Medium — the cap can be exceeded but only by one concurrent check's worth of spend. Risk is bounded by the per-payment cap.
Gap: The detect endpoint (/api/services/detect) returns a _debug object in all responses, including production. This field includes the raw response body, decoded payment headers, and parsed payment options from the target URL. While it contains no CORTX secrets, it leaks information about external endpoints and the parsing internals.
Target fix: Remove _debug from production responses. Gate it behind an admin-only flag or strip it entirely.
Severity: Low — no secrets exposed, but unnecessary information disclosure.
Gap: Admin pages check a hardcoded user ID (e9374851-ac6f-4f1e-a131-6747fc37184a). This works but is fragile — the admin user cannot be changed without a code deploy.
Target fix: Move admin user identification to an environment variable or a profiles.is_admin column with a corresponding RLS policy.
Severity: Low — no immediate exploitability, but increases operational risk if admin access needs to change quickly.