diff --git a/README.md b/README.md index f3ae9aa..4dec711 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,11 @@ Running with no arguments defaults to `create`. - Cloud lifetime is server-controlled (no TTL parameter in the API). - Unclaimed clouds are reaped at `expires_at`, assets included. Claiming (a human opens `claim_url` and verifies an email) makes the account permanent with the same API key. +- **Behind a corporate VPN or secure gateway (e.g. Cloudflare WARP)** the API may see the + request arriving from a private address and refuse with `delivery_ips_not_public` — + the requester's address is always part of the delivery allow-list, so `--ip` cannot + avoid it. Re-run from a connection the gateway does not route (pause the VPN for the + one command, or use another network or host). ## Library API diff --git a/src/commands/create.ts b/src/commands/create.ts index 204508f..25afcdf 100644 --- a/src/commands/create.ts +++ b/src/commands/create.ts @@ -9,7 +9,7 @@ import { } from '../lib/provision.js'; import { writeCloudEnv, hasCloudinaryUrl, isEnvExposedToGit, type EnvWriteResult } from '../lib/env-file.js'; import { printHumanSummary, printPlainSummary } from '../lib/output.js'; -import { getObservedPublicIp, deliveryIpMismatchWarning } from '../lib/ip-check.js'; +import { getObservedPublicIp, deliveryIpMismatchWarning, privateRequesterHint } from '../lib/ip-check.js'; import { detectAgentMetadata } from '../lib/agent-metadata.js'; export interface CreateOptions { @@ -117,6 +117,9 @@ export async function createCommand(options: CreateOptions): Promise { if (mismatch) console.error(pc.yellow(`Warning: ${mismatch}`)); } catch (err) { if (err instanceof ProvisionError) { + const requesterHint = err.code === 'delivery_ips_not_public' + ? privateRequesterHint(err.message, options.ip) + : null; // --json callers parse stdout; give them the API's error envelope shape. if (options.json) { console.log(JSON.stringify({ @@ -124,6 +127,7 @@ export async function createCommand(options: CreateOptions): Promise { category: err.category ?? 'error', ...(err.code ? { code: err.code } : {}), message: err.message, + ...(requesterHint ? { hint: requesterHint } : {}), }, }, null, 2)); process.exit(1); @@ -134,6 +138,8 @@ export async function createCommand(options: CreateOptions): Promise { console.error(pc.dim('A rate limit was hit. Wait a while before provisioning another cloud.')); } else if (err.code === 'agent_registration_disabled') { console.error(pc.dim('Cloud provisioning is currently disabled by Cloudinary.')); + } else if (requesterHint) { + console.error(pc.dim(requesterHint)); } process.exit(1); } diff --git a/src/lib/ip-check.ts b/src/lib/ip-check.ts index a4c96ad..c9af9a3 100644 --- a/src/lib/ip-check.ts +++ b/src/lib/ip-check.ts @@ -37,6 +37,68 @@ export async function getObservedPublicIp(fetchImpl: typeof fetch = fetch): Prom } } +/** + * Guidance for delivery_ips_not_public when a rejected private address was + * derived by the server rather than passed via --ip. The server folds the + * requester's address into the delivery allow-list, so behind a corporate VPN + * or secure gateway (e.g. Cloudflare WARP) the API sees a private address and + * refuses — no flag can avoid that. Worded for AI agents as much as humans: + * explicitly not a security block, and the remedy is the user's call. + * + * Returns null when the hint would be wrong or redundant: every rejected + * address is public (the "private address" story doesn't apply), the user + * passed the private address themselves, or the message has no parseable IP + * while --ip values were given (can't attribute the rejection to the server). + */ +export function privateRequesterHint(message: string, suppliedIps?: string[]): string | null { + const rejected = extractIps(message); + const rejectedPrivate = rejected.filter(ip => !isPublicIp(ip)); + if (rejected.length > 0 && rejectedPrivate.length === 0) return null; + + const supplied = new Set((suppliedIps ?? []).map(normalizeIp)); + const derived = rejectedPrivate.find(ip => !supplied.has(normalizeIp(ip))); + if (rejectedPrivate.length > 0 && derived === undefined) return null; + if (rejected.length === 0 && supplied.size > 0) return null; + + return [ + `This request reached Cloudinary from a private network address${derived ? ` (${derived})` : ''} —`, + 'usually a corporate VPN or secure gateway (e.g. Cloudflare WARP) routing the connection.', + 'The service derives the media-delivery allow-list from that address and cannot use a', + 'private one, so it refused to provision. This is a network-routing limitation — not a', + 'security block, and not a problem with the request, this tool, or your account.', + 'To proceed, re-run from a connection the gateway does not route: pause the VPN for this', + 'one command, or use another network or host. If you are an AI agent, report this to the', + 'user and let them decide — do not change network or VPN settings yourself.', + ].join('\n'); +} + +/** IP-shaped tokens in prose. IPv6 must contain '::' or all 8 groups, so times ("10:30:45") don't match. */ +function extractIps(text: string): string[] { + const candidates = text.match(/(?:\d{1,3}\.){3}\d{1,3}|[0-9a-fA-F:]*::[0-9a-fA-F:.]*|(?:[0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}/g) ?? []; + return candidates.filter(v => { + const m = v.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/); + if (m) return m.slice(1).every(o => Number(o) <= 255); + return v.includes('::') || v.split(':').length === 8; + }); +} + +/** + * Comparison form only (never displayed): lowercase, '::' expanded, leading + * zeros stripped — so a user-supplied "2001:db8::1" matches the server's + * "2001:0DB8:0000:0000:0000:0000:0000:0001". IPv4 passes through unchanged. + */ +function normalizeIp(ip: string): string { + if (!ip.includes(':')) return ip; + const lower = ip.toLowerCase(); + const [head = '', tail = ''] = lower.split('::'); + const h = head ? head.split(':') : []; + const t = tail ? tail.split(':') : []; + const groups = lower.includes('::') + ? [...h, ...Array(Math.max(0, 8 - h.length - t.length)).fill('0'), ...t] + : h; + return groups.map(g => g.replace(/^0+(?=.)/, '')).join(':'); +} + /** Warning text when the observed IP is missing from the allow-list, else null. */ export function deliveryIpMismatchWarning(deliveryIps: string[], observedIp: string | null): string | null { if (!observedIp || deliveryIps.length === 0 || deliveryIps.includes(observedIp)) return null; diff --git a/test/create.test.mjs b/test/create.test.mjs index 0c95627..0b6dbb3 100644 --- a/test/create.test.mjs +++ b/test/create.test.mjs @@ -4,7 +4,10 @@ import { createServer } from 'node:http'; import { mkdtempSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; +import { execFile } from 'node:child_process'; import { runCreate, ProvisionError } from '../dist/lib/index.js'; +// Deliberately not part of the public library surface — imported from the module. +import { privateRequesterHint } from '../dist/lib/ip-check.js'; const noEcho = async () => { throw new Error('offline'); }; @@ -123,6 +126,117 @@ test('an unwritable .env never swallows the credentials', () => }), )); +// --- delivery_ips_not_public guidance --- + +test('derived private requester IP produces the gateway hint', () => { + const hint = privateRequesterHint('delivery IP must be public: 10.16.231.234', undefined); + assert.match(hint, /10\.16\.231\.234/); + assert.match(hint, /VPN|WARP/); + assert.match(hint, /not a\s+security block/); + assert.match(hint, /do not change network/); +}); + +test('no hint when the user passed the rejected private IP themselves', () => { + assert.equal(privateRequesterHint('delivery IP must be public: 10.0.0.5', ['10.0.0.5']), null); +}); + +test('IPv6 forms are compared normalized, not textually', () => { + assert.equal( + privateRequesterHint( + 'delivery IP must be public: FD00:0000:0000:0000:0000:0000:0000:0001', + ['fd00::1'], + ), + null, + ); +}); + +test('a derived private IP hints even when a user-supplied IP appears first in the message', () => { + const hint = privateRequesterHint( + 'delivery IPs rejected: 203.0.113.7 is allowed but 10.16.231.234 must be public', + ['203.0.113.7'], + ); + assert.match(hint, /10\.16\.231\.234/); +}); + +test('no hint when every rejected address is public — the private-address story would be wrong', () => { + assert.equal(privateRequesterHint('delivery IP must be public: 203.0.113.7', undefined), null); +}); + +test('unparseable message: hint only when the server had to derive (no --ip given)', () => { + assert.match(privateRequesterHint('delivery IP must be public', undefined), /VPN|WARP/); + assert.equal(privateRequesterHint('delivery IP must be public', ['203.0.113.7']), null); +}); + +test('times and stray hex in prose are not mistaken for IPv6', () => { + assert.match(privateRequesterHint('rejected at 10:30:45 near node bad:beef', undefined), /VPN|WARP/); +}); + +// --- CLI integration: the hint reaches real output --- + +const CLI_BIN = new URL('../dist/index.js', import.meta.url).pathname; + +function withNotPublicStub(run) { + return new Promise((resolve, reject) => { + const server = createServer((req, res) => { + req.on('data', () => {}); + req.on('end', () => { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + error: { + category: 'user_error', + code: 'delivery_ips_not_public', + message: 'delivery IP must be public: 10.16.231.234', + }, + })); + }); + }); + server.listen(0, async () => { + const host = `http://localhost:${server.address().port}`; + try { + resolve(await run(host)); + } catch (err) { + reject(err); + } finally { + server.close(); + } + }); + }); +} + +function runCli(args, apiHost, cwd) { + return new Promise(resolve => { + execFile( + process.execPath, + [CLI_BIN, ...args], + { cwd, env: { ...process.env, CLOUDINARY_API_HOST: apiHost, NO_COLOR: '1' } }, + (err, stdout, stderr) => resolve({ code: err?.code ?? 0, stdout, stderr }), + ); + }); +} + +test('--json error envelope carries the hint field', () => + inTempCwd(dir => + withNotPublicStub(async host => { + const { code, stdout } = await runCli(['--json'], host, dir); + assert.equal(code, 1); + const payload = JSON.parse(stdout); + assert.equal(payload.error.code, 'delivery_ips_not_public'); + assert.match(payload.error.hint, /not a\s+security block/); + assert.match(payload.error.hint, /do not change network/); + }), + )); + +test('human mode prints the hint to stderr after the error line', () => + inTempCwd(dir => + withNotPublicStub(async host => { + const { code, stderr } = await runCli([], host, dir); + assert.equal(code, 1); + assert.match(stderr, /delivery IP must be public/); + assert.match(stderr, /VPN|WARP/); + assert.match(stderr, /report this to the\s+user/); + }), + )); + // --- default delivery_ips composition --- function withBodyCapture(run) {