Skip to content

Commit 4034380

Browse files
fix(security): reject loopback for BYOK asset download URLs (#5478) (#6072)
* fix(security): reject loopback for BYOK asset download URLs (#5478) The assertExternalAssetUrl guard previously routed through validateBaseUrlResolved without forbidLoopback, so asset URLs pointing at 127.0.0.1, localhost, or ::1 passed validation and were fetched by the daemon. This allowed a compromised or misconfigured upstream gateway to exfiltrate data from internal services via SSRF. Changes: - Add forbidLoopback option to ValidateBaseUrlOptions (packages/contracts) - validateBaseUrl rejects loopback hosts when forbidLoopback is set - validateBaseUrlResolved skips the loopback early-return when forbidLoopback - validateBaseUrlResolved: when forbidLoopback is true, DNS-resolved loopback addresses are also rejected (not just literal loopback hosts) - assertExternalAssetUrl now passes forbidLoopback: true - User-configured provider endpoints (validateUserProviderBaseUrl) are unaffected — they still allow loopback for local gateways - Adds regression tests covering literal loopback rejection, DNS-resolved loopback rejection (IPv4, IPv6, mixed results), and provider endpoint exemption Note: PR #5529 fixed the library-ingest SSRF path. This PR closes the separate BYOK asset-download path (assertExternalAssetUrl) that retains the loopback carve-out. Closes #5478 * fix(security): pin DNS resolution to prevent rebinding in asset fetch (#5478) The validation step (validateBaseUrlResolved) and the fetch step (assertAndFetchExternalAsset) previously performed independent DNS lookups. A DNS-rebinding domain could return a public IP for validation and then 127.0.0.1 / ::1 at fetch time, bypassing the loopback guard. Fix: - validateBaseUrlResolved now attaches the DNS-resolved addresses that passed validation to its return value (BaseUrlValidationResult.resolvedAddresses) - assertExternalAssetUrl passes these addresses through on the ok branch - assertAndFetchExternalAsset creates an Undici Agent with a custom connect.lookup that returns only the validated addresses, pinning the TCP connection regardless of any DNS rebind between validation and fetch - 3 call sites in byok-tools.ts that used validate-then-fetch separately now route through assertAndFetchExternalAsset for a single unified path - 4 regression tests covering: single validated address, DNS-rebind mock (public→loopback), round-robin DNS, and IP-literal short-circuit * fix(security): fail-closed on DNS error + connection-time validating lookup (#5478) Addresses mrcfps third review: the fallback to unpinned fetch when validation did not attach resolved addresses left a DNS-rebinding hole where an attacker could make the validation lookup throw (ENOTFOUND / SERVFAIL) and then answer loopback for the fetch lookup. Changes: 1. validateBaseUrlResolved: when forbidLoopback is true, DNS lookup failures now return a forbidden result instead of the sync success. This prevents the fail-then-rebind vector. 2. assertAndFetchExternalAsset: removed the unpinned-fetch fallback. Non-IP-literal hostnames without resolvedAddresses now throw — the fetch never happens. IP literals are safe because they were validated synchronously and have no hostname to rebind. 3. Replaced the per-request pinned Agent with a long-lived Undici Agent (assetDispatcher) whose connection-time lookup (createAssetValidatingLookup) rejects any non-public address. This is defense-in-depth on top of the pre-validation, following the same pattern as brands/safe-fetch.ts and plugins/plugin-asset-cache.ts. A shared dispatcher also avoids the keep-alive socket leak of per-request Agents. 4. createAssetValidatingLookup is exported for unit testing. 5. Expanded test coverage: - DNS failure → fail-closed (forbidLoopback true and false) - createAssetValidatingLookup: rejects loopback, RFC1918, metadata IP; allows public - assertAndFetchExternalAsset: throws on blocked URLs and internal IPs - Updated existing public-CDN tests to use IP literals (fail-closed behavior now correctly rejects hostnames when DNS is unavailable) * fix(security): injectable lookup + testable fetch for asset SSRF guard (#5478) Addresses mrcfps 4th review: daemon CI was red because fail-closed behavior caused hostname-based test fixtures to NXDOMAIN, and undiciFetch bypassed vi.stubGlobal('fetch') stubs. Changes: 1. assertExternalAssetUrl and assertAndFetchExternalAsset now accept an optional injectable `lookup` (DnsLookupFn) and `fetchImpl` parameter. Production callers pass neither, so default DNS and globalThis.fetch are used. Tests can inject mock lookups/fetch. 2. assertAndFetchExternalAsset uses globalThis.fetch (not undiciFetch) so vi.stubGlobal('fetch', ...) stubs in existing test suites (byok-tools, aihubmix, proxy-routes, senseaudio) still intercept. 3. Existing test fixtures: replaced cdn.example.test with 93.184.216.34 (public IP literal) in 6 test files (41 substitutions) so IP literals skip DNS resolution and don't trigger fail-closed on NXDOMAIN .test hostnames. 4. Added 3 TOCTOU regression tests driving assertAndFetchExternalAsset with injected lookup + fetch: - Validation lookup public → fetch called with redirect:error - Validation lookup throws → assertAndFetchExternalAsset rejects without invoking fetch (fail-then-rebind vector closed) - IP literal → skips DNS, calls fetch directly 5. assetDispatcher + createAssetValidatingLookup kept for production connection-time defense-in-depth (exported, available for future wiring to a production fetch dispatcher). Total: 31 tests in asset-ssrf-loopback.test.ts, all passing. * fix(security): attach validating dispatcher to asset fetch (#5478) mrcfps 5th review: assetDispatcher was built but never attached to the fetch call. The validating lookup only runs at connect time through the dispatcher, so without it the fetch re-resolves the attacker-controlled hostname and a public-then-loopback rebind still reaches the daemon. Fix: attach assetDispatcher to the RequestInit via the dispatcher key (same pattern as plugins/plugin-asset-cache.ts safeExternalFetch). Production globalThis.fetch (Node/undici) uses it to refuse connect-time non-public addresses. Test stubs still see redirect:'error' and can ignore dispatcher. Also fixed the misnamed TOCTOU test to assert dispatcher is present on the captured RequestInit, and removed unused undiciFetch import. * fix(tests): update dispatcher assertions for assertAndFetchExternalAsset assertAndFetchExternalAsset now attaches a validating assetDispatcher to the fetch RequestInit (issue #5478). Tests that blanket-checked init?.dispatcher === testMock fail because asset-download calls correctly carry the validating dispatcher instead. Fix: conditional assertion — when init.redirect === 'error' (asset download path), check dispatcher exists rather than matching the test mock. API calls still verify the caller's dispatcher is forwarded unchanged. * fix(tests): URL-keyed dispatcher assertions + document asset dispatcher override Address mrcfps's non-blocking review items: 1. Replace the redirect==='error' heuristic with URL-keyed assertions. The heuristic silently dropped the submit dispatcher identity check on the AIHubMix video path (its submit hop also sets redirect:'error'). Submit/poll hops now assert toBe(callerDispatcher); the asset hop asserts toBe(getAssetValidatingDispatcher()) — exported from connectionTest.ts so the exact validating instance is compared. 2. Document the intentional dispatcher override on the BYOKToolContext.requestInit JSDoc: asset downloads through assertAndFetchExternalAsset intentionally never ride the turn proxy dispatcher; submit/poll hops keep it. --------- Co-authored-by: CVE-Hunter-Leo <cve-hunter-leo@users.noreply.github.com>
1 parent 9475b98 commit 4034380

10 files changed

Lines changed: 685 additions & 77 deletions

apps/daemon/src/byok-tools.ts

Lines changed: 18 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
import path from 'node:path';
1616
import { writeFile, readFile, readdir, stat } from 'node:fs/promises';
1717
import { randomBytes } from 'node:crypto';
18-
import { assertExternalAssetUrl, assertAndFetchExternalAsset } from './connectionTest.js';
18+
import { assertAndFetchExternalAsset } from './connectionTest.js';
1919
import { resolveProviderConfig } from './media/config.js';
2020
import { IMAGE_MODELS } from './media/models.js';
2121
import { ensureProject } from './projects.js';
@@ -447,8 +447,15 @@ export interface BYOKToolContext {
447447
videoPollIntervalMs?: number;
448448
/** Optional per-request init copied from the live chat turn. Used to
449449
* forward the current proxy dispatcher AND the client-cancellation
450-
* signal into every upstream/download fetch the BYOK tool executor
451-
* performs, so a disconnected client stops the tool loop's paid work. */
450+
* signal into every upstream fetch the BYOK tool executor performs,
451+
* so a disconnected client stops the tool loop's paid work.
452+
*
453+
* Exception — asset downloads: when a provider result URL is fetched
454+
* through `assertAndFetchExternalAsset` (connectionTest.ts), that
455+
* helper intentionally OVERRIDES `init.dispatcher` with the shared
456+
* asset-validating dispatcher whose connect-time DNS lookup rejects
457+
* non-public addresses (issue #5478). Asset downloads therefore never
458+
* ride the turn proxy dispatcher; submit/poll hops keep it. */
452459
requestInit?: Pick<RequestInit, 'dispatcher' | 'signal'>;
453460
}
454461

@@ -693,12 +700,11 @@ export async function executeGenerateImage(
693700
};
694701
}
695702

696-
const imageUrlCheck = await assertExternalAssetUrl(imageUrl);
697-
if (!imageUrlCheck.ok) return { ok: false, error: imageUrlCheck.error };
698-
699703
let bytes: Buffer;
700704
try {
701-
const imgResp = await fetch(imageUrl, withToolRequestInit(ctx, { redirect: 'error' }));
705+
// Use assertAndFetchExternalAsset (validate + pinned-DNS fetch) so a
706+
// malicious gateway can't DNS-rebind into loopback/metadata space.
707+
const imgResp = await assertAndFetchExternalAsset(imageUrl, withToolRequestInit(ctx, {}));
702708
if (!imgResp.ok) {
703709
return { ok: false, error: `image download ${imgResp.status}` };
704710
}
@@ -923,15 +929,12 @@ export async function executeGenerateVideo(
923929
}
924930

925931
// Step 3: download the mp4 bytes and persist into the project folder.
926-
// Re-validate the returned URL through validateBaseUrlResolved so a
927-
// malicious gateway can't point us at 169.254.169.254 (AWS / Azure
928-
// metadata service) or RFC1918 hosts via the response payload.
929-
const videoUrlCheck = await assertExternalAssetUrl(videoUrl);
930-
if (!videoUrlCheck.ok) return { ok: false, error: videoUrlCheck.error };
932+
// Use assertAndFetchExternalAsset (validate + pinned-DNS fetch) so a
933+
// malicious gateway can't DNS-rebind into loopback / metadata space.
931934

932935
let bytes: Buffer;
933936
try {
934-
const videoResp = await fetch(videoUrl, withToolRequestInit(ctx, { redirect: 'error' }));
937+
const videoResp = await assertAndFetchExternalAsset(videoUrl, withToolRequestInit(ctx, {}));
935938
if (!videoResp.ok) {
936939
return { ok: false, error: `video download ${videoResp.status}` };
937940
}
@@ -1314,10 +1317,9 @@ async function resolveAIHubMixReferenceImage(
13141317
if (typeof imageUrl !== 'string' || !imageUrl.trim()) return null;
13151318
const raw = imageUrl.trim();
13161319
if (/^https?:\/\//i.test(raw)) {
1317-
const check = await assertExternalAssetUrl(raw);
1318-
if (!check.ok) return null;
13191320
try {
1320-
const resp = await fetch(raw, withToolRequestInit(ctx, { redirect: 'error' }));
1321+
// assertAndFetchExternalAsset validates + pins DNS to prevent rebind.
1322+
const resp = await assertAndFetchExternalAsset(raw, withToolRequestInit(ctx, {}));
13211323
if (!resp.ok) return null;
13221324
const buf = Buffer.from(await resp.arrayBuffer());
13231325
if (!buf.length) return null;

apps/daemon/src/connectionTest.ts

Lines changed: 157 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
// contracts so Settings and daemon-side checks reject the same hosts.
1818

1919
import { spawn } from 'node:child_process';
20-
import { promises as dnsPromises } from 'node:dns';
20+
import { promises as dnsPromises, lookup as dnsLookupCb } from 'node:dns';
2121
import { promises as fsp } from 'node:fs';
2222
import os from 'node:os';
2323
import path from 'node:path';
@@ -143,7 +143,9 @@ export async function validateBaseUrlResolved(
143143
if (sync.error || !sync.parsed) return sync;
144144

145145
const hostname = sync.parsed.hostname.toLowerCase();
146-
if (isLoopbackApiHost(hostname)) return sync;
146+
// When forbidLoopback is set, do NOT short-circuit on loopback — let it
147+
// fall through to the block check (issue #5478).
148+
if (!options.forbidLoopback && isLoopbackApiHost(hostname)) return sync;
147149
// Issue #3225 — an operator who trusts this hostname has opted it out of the
148150
// guard entirely, so skip the resolved-IP block even though it points into
149151
// private space. The sync check above already honored a literal-IP allowlist
@@ -155,12 +157,31 @@ export async function validateBaseUrlResolved(
155157
try {
156158
addresses = await lookup(hostname);
157159
} catch {
160+
// When forbidLoopback is set (attacker-controllable asset URLs), a DNS
161+
// lookup failure must fail closed. An attacker who controls the resolver
162+
// can make the validation lookup throw (ENOTFOUND / ETIMEOUT / SERVFAIL)
163+
// and then answer loopback for the fetch-time lookup, bypassing the guard.
164+
// (issue #5478)
165+
if (options.forbidLoopback) {
166+
return { error: 'DNS resolution failed for asset URL', forbidden: true };
167+
}
158168
return sync;
159169
}
160170

161171
for (const addr of addresses) {
162172
const ip = String(addr.address).toLowerCase();
163-
if (isLoopbackApiHost(ip)) continue;
173+
// When forbidLoopback is set (asset download URLs from issue #5478),
174+
// a DNS name that resolves to loopback is just as dangerous as a
175+
// literal loopback host — reject it instead of skipping.
176+
if (isLoopbackApiHost(ip)) {
177+
if (options.forbidLoopback) {
178+
return {
179+
error: `DNS-resolved loopback address blocked (${ip})`,
180+
forbidden: true,
181+
};
182+
}
183+
continue;
184+
}
164185
// A resolved address the operator explicitly allowlisted (they listed the
165186
// IP rather than the hostname) is permitted; everything else in private
166187
// space is still blocked.
@@ -170,7 +191,11 @@ export async function validateBaseUrlResolved(
170191
}
171192
}
172193

173-
return sync;
194+
// Attach validated addresses so the caller can pin the actual fetch to them.
195+
// This prevents DNS rebinding: without pinning, the attacker's DNS can return
196+
// a public IP here and then 127.0.0.1 at fetch time, so the daemon connects
197+
// to loopback despite the validation having passed (issue #5478).
198+
return { ...sync, resolvedAddresses: addresses };
174199
}
175200

176201
/**
@@ -211,14 +236,27 @@ export function validateUserProviderBaseUrl(
211236
* Both hand the URL straight to `fetch(...)` next, so pair this
212237
* guard with `redirect: 'error'` on the fetch to also block a
213238
* 3xx hop into private space.
239+
*
240+
* Returns the DNS-resolved addresses that passed validation on the `ok` branch
241+
* so callers (e.g. {@link assertAndFetchExternalAsset}) can pin the actual
242+
* connection to those addresses and prevent DNS rebinding (issue #5478).
214243
*/
215244
export async function assertExternalAssetUrl(
216245
rawUrl: string,
217-
): Promise<{ ok: true } | { ok: false; error: string }> {
246+
lookup?: DnsLookupFn,
247+
): Promise<
248+
| { ok: true; resolvedAddresses?: ReadonlyArray<{ address: string; family: number }> }
249+
| { ok: false; error: string }
250+
> {
218251
if (typeof rawUrl !== 'string' || !rawUrl) {
219252
return { ok: false, error: 'empty download url' };
220253
}
221-
const validated = await validateBaseUrlResolved(rawUrl);
254+
// Asset URLs come from upstream API responses (data.url / data.video_url)
255+
// and are attacker-controllable. They MUST NOT point at loopback or
256+
// internal addresses, regardless of operator allowlists (issue #5478).
257+
const validated = await validateBaseUrlResolved(rawUrl, lookup, {
258+
forbidLoopback: true,
259+
});
222260
if (validated.error || !validated.parsed) {
223261
return {
224262
ok: false,
@@ -227,28 +265,131 @@ export async function assertExternalAssetUrl(
227265
: `invalid download url: ${validated.error ?? 'unknown reason'}`,
228266
};
229267
}
268+
// Only include resolvedAddresses when present — exactOptionalPropertyTypes
269+
// forbids assigning `undefined` to an optional property.
270+
if (validated.resolvedAddresses) {
271+
return { ok: true, resolvedAddresses: validated.resolvedAddresses };
272+
}
230273
return { ok: true };
231274
}
232275

276+
/**
277+
* Connection-time DNS validator for asset-download requests. Wraps `dns.lookup`
278+
* and rejects any resolved address that is loopback, RFC1918, link-local,
279+
* CGNAT, metadata-service, or multicast — the same predicate used during
280+
* pre-validation. Installed as the Undici Agent's `connect.lookup` so the
281+
* address we validate IS the address the socket connects to, closing the
282+
* DNS-rebinding / TOCTOU gap that a separate pre-validation lookup leaves open
283+
* (issue #5478). Same pattern as `brands/safe-fetch.ts` and
284+
* `plugins/plugin-asset-cache.ts`.
285+
*
286+
* Exported so the guard can be unit-tested without a live server.
287+
*/
288+
export function createAssetValidatingLookup(
289+
lookupImpl: typeof dnsLookupCb = dnsLookupCb,
290+
): (hostname: string, options: unknown, callback: (...args: unknown[]) => void) => void {
291+
return (
292+
hostname: string,
293+
options: unknown,
294+
callback: (...args: unknown[]) => void,
295+
): void => {
296+
const cb = (typeof options === 'function' ? options : callback) as (
297+
err: Error | null,
298+
address?: unknown,
299+
family?: number,
300+
) => void;
301+
const opts = (typeof options === 'function' ? {} : (options ?? {})) as Record<string, unknown>;
302+
lookupImpl(hostname, opts as never, (err, address, family) => {
303+
if (err) return cb(err);
304+
const list = Array.isArray(address) ? address : [{ address, family }];
305+
for (const entry of list) {
306+
const addr = typeof entry === 'string' ? entry : (entry as { address: string }).address;
307+
if (isLoopbackApiHost(String(addr)) || isBlockedExternalApiHostname(String(addr))) {
308+
return cb(new Error(`asset host resolves to non-public address: ${addr}`));
309+
}
310+
}
311+
return cb(null, address, family);
312+
});
313+
};
314+
}
315+
316+
// Long-lived dispatcher reused across calls. A per-request Agent leaks
317+
// keep-alive sockets; a shared dispatcher avoids that while still pinning the
318+
// connection-time validating lookup (same approach as plugin-asset-cache.ts).
319+
// Used by `createAssetValidatingLookup` consumers in production; the default
320+
// fetch in `assertAndFetchExternalAsset` is `globalThis.fetch` so test stubs
321+
// still intercept.
322+
const assetDispatcher = new Agent({
323+
connect: { lookup: createAssetValidatingLookup() as never },
324+
});
325+
326+
/**
327+
* Test-visible accessor for the shared asset-validating dispatcher attached by
328+
* `assertAndFetchExternalAsset`. Tests key dispatcher assertions on the asset
329+
* URL and compare against this exact instance (`toBe`), so a regression that
330+
* reverts to forwarding the caller's turn-proxy dispatcher on the asset hop
331+
* fails loudly (issue #5478).
332+
*/
333+
export function getAssetValidatingDispatcher(): NonNullable<RequestInit['dispatcher']> {
334+
return assetDispatcher as unknown as NonNullable<RequestInit['dispatcher']>;
335+
}
336+
233337
/**
234338
* Validate an upstream-controlled asset URL and fetch it with the SSRF guard
235-
* pinned through redirects. Runs `assertExternalAssetUrl` on the literal URL
236-
* and forces `redirect: 'error'`, so a validated public URL that 302s into
237-
* loopback / RFC1918 / metadata space is rejected before any bytes are read.
339+
* pinned through redirects and DNS resolution. Runs `assertExternalAssetUrl`
340+
* on the literal URL (fail-closed on DNS errors), forces `redirect: 'error'`
341+
* (blocking a 3xx hop into private space), and routes the fetch through a
342+
* long-lived Undici dispatcher whose connection-time `lookup` rejects any
343+
* non-public address — so even if an attacker's DNS returns a public address
344+
* during pre-validation and loopback at connect time, the socket is refused
345+
* (issue #5478).
346+
*
347+
* For non-IP-literal hostnames, if DNS validation did not attach a vetted
348+
* address set (e.g., lookup failure), the function throws rather than falling
349+
* back to an unpinned fetch. IP literals are safe to fetch unpinned because
350+
* they were validated synchronously and have no hostname to rebind.
238351
*
239-
* Throws on a blocked host — so the redirect bypass is impossible to forget at
240-
* call sites — and the platform fetch additionally throws when `redirect:
241-
* 'error'` encounters a 3xx. Callers keep their own `!resp.ok` HTTP-status
242-
* handling. The forced `redirect` is spread last so it overrides any value the
243-
* caller passed in `init`.
352+
* Throws on a blocked host or unpinned-fetch refusal. Callers keep their own
353+
* `!resp.ok` HTTP-status handling. The forced `redirect` is spread last so it
354+
* overrides any value the caller passed in `init`.
244355
*/
245356
export async function assertAndFetchExternalAsset(
246357
url: string,
247358
init: RequestInit = {},
359+
lookup?: DnsLookupFn,
360+
fetchImpl: typeof fetch = fetch,
248361
): Promise<Response> {
249-
const check = await assertExternalAssetUrl(url);
362+
const check = await assertExternalAssetUrl(url, lookup);
250363
if (!check.ok) throw new Error(check.error);
251-
return fetch(url, { ...init, redirect: 'error' });
364+
365+
// Determine whether the hostname is an IP literal. If so, the synchronous
366+
// validation already vetted it — no DNS rebind is possible.
367+
let parsedUrl: URL;
368+
try {
369+
parsedUrl = new URL(url);
370+
} catch {
371+
throw new Error(`invalid asset url: ${url}`);
372+
}
373+
const hostname = parsedUrl.hostname.toLowerCase();
374+
const isIpLiteral = looksLikeIpLiteral(hostname);
375+
376+
// For non-IP-literal hostnames, require validated resolved addresses. If
377+
// they are missing (DNS lookup failed and was caught → fail-closed in
378+
// validateBaseUrlResolved), never fall back to an unpinned fetch — that
379+
// would allow the attacker to rebind at fetch time (issue #5478).
380+
if (!isIpLiteral && (!check.resolvedAddresses || check.resolvedAddresses.length === 0)) {
381+
throw new Error('asset URL hostname was not DNS-validated — refusing unpinned fetch');
382+
}
383+
384+
// Route through the long-lived asset dispatcher whose connection-time lookup
385+
// rejects non-public addresses. The dispatcher is attached to the init object
386+
// so injected fetch stubs (vi.stubGlobal) still see redirect:'error' and
387+
// can ignore dispatcher, while production globalThis.fetch (Node/undici)
388+
// uses it to refuse a connect-time rebind to loopback/metadata (issue #5478).
389+
// Same pattern as plugins/plugin-asset-cache.ts safeExternalFetch.
390+
const requestInit: RequestInit = { ...init, redirect: 'error' };
391+
(requestInit as { dispatcher?: unknown }).dispatcher = assetDispatcher;
392+
return fetchImpl(url, requestInit);
252393
}
253394

254395
// Aggressive but not punitive — happy paths usually return in under 2 s.

apps/daemon/tests/aihubmix-asset-ssrf.test.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -100,12 +100,12 @@ describe('AIHubMix asset downloads pin redirect:"error"', () => {
100100
return new Response(
101101
JSON.stringify({
102102
status: 'completed',
103-
video_url: 'https://cdn.example.test/video/done.mp4',
103+
video_url: 'https://93.184.216.34/video/done.mp4',
104104
}),
105105
{ status: 200, headers: { 'content-type': 'application/json' } },
106106
);
107107
}
108-
if (url === 'https://cdn.example.test/video/done.mp4') {
108+
if (url === 'https://93.184.216.34/video/done.mp4') {
109109
downloadInit = init;
110110
return new Response(mp4Bytes, {
111111
status: 200,
@@ -128,11 +128,11 @@ describe('AIHubMix asset downloads pin redirect:"error"', () => {
128128
const url = String(input);
129129
if (url === 'https://aihubmix.com/v1/images/generations') {
130130
return new Response(
131-
JSON.stringify({ data: [{ url: 'https://cdn.example.test/img/out.png' }] }),
131+
JSON.stringify({ data: [{ url: 'https://93.184.216.34/img/out.png' }] }),
132132
{ status: 200, headers: { 'content-type': 'application/json' } },
133133
);
134134
}
135-
if (url === 'https://cdn.example.test/img/out.png') {
135+
if (url === 'https://93.184.216.34/img/out.png') {
136136
downloadInit = init;
137137
return new Response(pngBytes, {
138138
status: 200,

0 commit comments

Comments
 (0)