From 173387ca57d26d3a98d624c5c270f2a6975acd56 Mon Sep 17 00:00:00 2001 From: Nick Bradley Date: Wed, 5 Aug 2026 11:30:53 +0100 Subject: [PATCH] feat: explain delivery_ips_not_public when a gateway hides the caller MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The server folds the requester's address into the delivery allow-list, so behind a corporate VPN or secure gateway the API sees a private address and refuses — no flag avoids it, and the bare 400 reads like a fault. Emit guidance (stderr, plus a hint field in the --json error envelope) that names the routing cause, states it is not a security block, and tells AI agents to defer to the user rather than touch network settings. Suppressed when the user passed the rejected IP themselves or the rejected addresses are public. --- README.md | 5 ++ src/commands/create.ts | 8 ++- src/lib/ip-check.ts | 62 ++++++++++++++++++++++ test/create.test.mjs | 114 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 188 insertions(+), 1 deletion(-) 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) {