Use API route proxy for Directus assets instead of direct internal URL

Next.js image optimizer blocks internal Docker URLs due to SSRF
protection (private IP resolution). Instead, proxy assets through
/api/assets/[id] which fetches from internal Directus URL server-side.

This bypasses both Cloudflare Access and SSRF protection since the
<Image> src is a same-origin path, not an external URL.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-02-13 13:11:13 -07:00
parent cd7c0200f3
commit 5f0d2eff16
5 changed files with 48 additions and 25 deletions

View File

@ -12,14 +12,10 @@ COPY . .
# Build args # Build args
ARG DIRECTUS_API_TOKEN ARG DIRECTUS_API_TOKEN
ARG NEXT_PUBLIC_DIRECTUS_ASSET_URL=http://katheryn-cms:8055
ARG NEXT_PUBLIC_DIRECTUS_ASSET_TOKEN
# Set environment for build # Set environment for build
ENV NEXT_PUBLIC_DIRECTUS_URL=https://katheryn-cms.jeffemmett.com ENV NEXT_PUBLIC_DIRECTUS_URL=https://katheryn-cms.jeffemmett.com
ENV DIRECTUS_API_TOKEN=${DIRECTUS_API_TOKEN} ENV DIRECTUS_API_TOKEN=${DIRECTUS_API_TOKEN}
ENV NEXT_PUBLIC_DIRECTUS_ASSET_URL=${NEXT_PUBLIC_DIRECTUS_ASSET_URL}
ENV NEXT_PUBLIC_DIRECTUS_ASSET_TOKEN=${NEXT_PUBLIC_DIRECTUS_ASSET_TOKEN}
ENV NEXT_TELEMETRY_DISABLED=1 ENV NEXT_TELEMETRY_DISABLED=1
# Build the application # Build the application

View File

@ -5,8 +5,6 @@ services:
dockerfile: Dockerfile dockerfile: Dockerfile
args: args:
- DIRECTUS_API_TOKEN=katheryn-frontend-readonly-8591de0316ded82fab45328cf1e49cb1 - DIRECTUS_API_TOKEN=katheryn-frontend-readonly-8591de0316ded82fab45328cf1e49cb1
- NEXT_PUBLIC_DIRECTUS_ASSET_URL=http://katheryn-cms:8055
- NEXT_PUBLIC_DIRECTUS_ASSET_TOKEN=katheryn-frontend-readonly-8591de0316ded82fab45328cf1e49cb1
container_name: katheryn-frontend container_name: katheryn-frontend
restart: unless-stopped restart: unless-stopped
env_file: env_file:
@ -15,8 +13,6 @@ services:
- NEXT_PUBLIC_DIRECTUS_URL=https://katheryn-cms.jeffemmett.com - NEXT_PUBLIC_DIRECTUS_URL=https://katheryn-cms.jeffemmett.com
- DIRECTUS_API_TOKEN=katheryn-frontend-readonly-8591de0316ded82fab45328cf1e49cb1 - DIRECTUS_API_TOKEN=katheryn-frontend-readonly-8591de0316ded82fab45328cf1e49cb1
- DIRECTUS_INTERNAL_URL=http://katheryn-cms:8055 - DIRECTUS_INTERNAL_URL=http://katheryn-cms:8055
- NEXT_PUBLIC_DIRECTUS_ASSET_URL=http://katheryn-cms:8055
- NEXT_PUBLIC_DIRECTUS_ASSET_TOKEN=katheryn-frontend-readonly-8591de0316ded82fab45328cf1e49cb1
labels: labels:
- "traefik.enable=true" - "traefik.enable=true"
- "traefik.http.routers.katheryn-staging.rule=Host(`katheryn-staging.jeffemmett.com`)" - "traefik.http.routers.katheryn-staging.rule=Host(`katheryn-staging.jeffemmett.com`)"

View File

@ -15,12 +15,6 @@ const nextConfig: NextConfig = {
port: '8055', port: '8055',
pathname: '/assets/**', pathname: '/assets/**',
}, },
{
protocol: 'http',
hostname: 'katheryn-cms',
port: '8055',
pathname: '/assets/**',
},
{ {
protocol: 'https', protocol: 'https',
hostname: 'images.squarespace-cdn.com', hostname: 'images.squarespace-cdn.com',

View File

@ -0,0 +1,42 @@
import { NextRequest, NextResponse } from 'next/server';
const DIRECTUS_URL = process.env.DIRECTUS_INTERNAL_URL || process.env.NEXT_PUBLIC_DIRECTUS_URL || 'https://katheryn-cms.jeffemmett.com';
const API_TOKEN = process.env.DIRECTUS_API_TOKEN || '';
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
const { searchParams } = request.nextUrl;
// Build the Directus asset URL
const assetUrl = new URL(`/assets/${id}`, DIRECTUS_URL);
if (API_TOKEN) assetUrl.searchParams.set('access_token', API_TOKEN);
// Forward image transformation params to Directus
for (const key of ['width', 'height', 'quality', 'format']) {
const value = searchParams.get(key);
if (value) assetUrl.searchParams.set(key, value);
}
try {
const response = await fetch(assetUrl.toString());
if (!response.ok) {
return new NextResponse(null, { status: response.status });
}
const buffer = await response.arrayBuffer();
const contentType = response.headers.get('content-type') || 'image/jpeg';
return new NextResponse(buffer, {
headers: {
'Content-Type': contentType,
'Cache-Control': 'public, max-age=31536000, immutable',
},
});
} catch {
return new NextResponse(null, { status: 502 });
}
}

View File

@ -185,27 +185,21 @@ const publicUrl = process.env.NEXT_PUBLIC_DIRECTUS_URL || 'https://katheryn-cms.
const directusUrl = process.env.DIRECTUS_INTERNAL_URL || publicUrl; const directusUrl = process.env.DIRECTUS_INTERNAL_URL || publicUrl;
const apiToken = process.env.DIRECTUS_API_TOKEN || ''; const apiToken = process.env.DIRECTUS_API_TOKEN || '';
// For asset URLs: use internal Docker URL to bypass Cloudflare Access,
// and use NEXT_PUBLIC_ token so it's available in client components
const assetBaseUrl = process.env.NEXT_PUBLIC_DIRECTUS_ASSET_URL || publicUrl;
const assetToken = process.env.NEXT_PUBLIC_DIRECTUS_ASSET_TOKEN || apiToken;
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
export const directus = createDirectus<any>(directusUrl) export const directus = createDirectus<any>(directusUrl)
.with(staticToken(apiToken)) .with(staticToken(apiToken))
.with(rest()); .with(rest());
// Helper to get asset URL (includes auth token since Directus assets require authentication) // Helper to get asset URL via local API proxy (/api/assets/[id])
// Uses internal Docker URL when available to bypass Cloudflare Access // The proxy fetches from internal Directus URL, bypassing both:
// 1. Cloudflare Access (uses Docker internal network)
// 2. Next.js SSRF protection (same-origin URL, no private IP resolution)
export function getAssetUrl(fileId: string | DirectusFile | undefined, options?: { width?: number; height?: number; quality?: number; format?: 'webp' | 'jpg' | 'png' }): string { export function getAssetUrl(fileId: string | DirectusFile | undefined, options?: { width?: number; height?: number; quality?: number; format?: 'webp' | 'jpg' | 'png' }): string {
if (!fileId) return '/placeholder.jpg'; if (!fileId) return '/placeholder.jpg';
const id = typeof fileId === 'string' ? fileId : fileId.id; const id = typeof fileId === 'string' ? fileId : fileId.id;
const params = new URLSearchParams(); const params = new URLSearchParams();
// Auth token required for asset access
if (assetToken) params.append('access_token', assetToken);
if (options) { if (options) {
if (options.width) params.append('width', options.width.toString()); if (options.width) params.append('width', options.width.toString());
if (options.height) params.append('height', options.height.toString()); if (options.height) params.append('height', options.height.toString());
@ -213,7 +207,8 @@ export function getAssetUrl(fileId: string | DirectusFile | undefined, options?:
if (options.format) params.append('format', options.format); if (options.format) params.append('format', options.format);
} }
return `${assetBaseUrl}/assets/${id}?${params.toString()}`; const query = params.toString();
return `/api/assets/${id}${query ? `?${query}` : ''}`;
} }
// Map Directus artwork fields to frontend Artwork interface // Map Directus artwork fields to frontend Artwork interface