Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
8 changes: 7 additions & 1 deletion src/commands/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -117,13 +117,17 @@ export async function createCommand(options: CreateOptions): Promise<void> {
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({
error: {
category: err.category ?? 'error',
...(err.code ? { code: err.code } : {}),
message: err.message,
...(requesterHint ? { hint: requesterHint } : {}),
},
}, null, 2));
process.exit(1);
Expand All @@ -134,6 +138,8 @@ export async function createCommand(options: CreateOptions): Promise<void> {
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);
}
Expand Down
62 changes: 62 additions & 0 deletions src/lib/ip-check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
114 changes: 114 additions & 0 deletions test/create.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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'); };

Expand Down Expand Up @@ -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) {
Expand Down
Loading