mirror of
https://github.com/saltbo/zpan.git
synced 2026-09-21 13:20:33 +08:00
fix(image-hosting): bypass auth on custom domains (#532)
* fix(image-hosting): bypass auth on custom domains * test(image-hosting): cover traffic compensation
This commit is contained in:
@@ -497,6 +497,75 @@ describe('public redirect cloud traffic reporting', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('denies custom-domain images and refunds traffic when Cloud rejects usage', async () => {
|
||||
const { app, db } = await createTestApp()
|
||||
await seedTrafficBinding(db)
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue(makeCloudResponse({ error: { code: 'insufficient_credits' } }, 402)),
|
||||
)
|
||||
await authedHeaders(app)
|
||||
await insertStorage(db)
|
||||
const orgId = await getOrgId(db)
|
||||
await insertImage(db, orgId, 'ih-cloud-domain-blocked', 'ih_clouddomainblocked', 'blog/domain-blocked.png')
|
||||
await insertImageConfig(db, orgId, 'img-blocked.example.com')
|
||||
await setTrafficQuota(db, orgId)
|
||||
|
||||
const res = await app.request('https://img-blocked.example.com/blog/domain-blocked.png', {
|
||||
headers: { host: 'img-blocked.example.com' },
|
||||
redirect: 'manual',
|
||||
})
|
||||
|
||||
expect(res.status).toBe(402)
|
||||
await expect(res.json()).resolves.toMatchObject({
|
||||
error: {
|
||||
details: [{ reason: 'INSUFFICIENT_CREDITS', metadata: { resource: 'storage_egress' } }],
|
||||
},
|
||||
})
|
||||
const quotaRows = await db.all<{ trafficUsed: number }>(
|
||||
sql`SELECT traffic_used AS trafficUsed FROM org_quotas WHERE org_id = ${orgId}`,
|
||||
)
|
||||
expect(quotaRows).toEqual([{ trafficUsed: 25 }])
|
||||
await expect(trafficReports(db)).resolves.toMatchObject([{ status: 'blocked', error: 'insufficient_credits' }])
|
||||
const failures = await db.all<{ reason: string }>(sql`
|
||||
SELECT json_extract(metadata, '$.reason') AS reason
|
||||
FROM audit_events
|
||||
WHERE action = 'download_failed' AND target_id = 'ih-cloud-domain-blocked'
|
||||
`)
|
||||
expect(failures).toEqual([{ reason: 'insufficient_credits' }])
|
||||
})
|
||||
|
||||
it('reverses custom-domain traffic when issuing the download fails', async () => {
|
||||
const { app, db, deps } = await createTestApp()
|
||||
await seedTrafficBinding(db)
|
||||
vi.stubGlobal('fetch', vi.fn().mockImplementation(acceptedUsageResponse))
|
||||
await authedHeaders(app)
|
||||
await insertStorage(db)
|
||||
const orgId = await getOrgId(db)
|
||||
await insertImage(db, orgId, 'ih-cloud-domain-confirm-fail', 'ih_clouddomainconfirmfail', 'blog/confirm-fail.png')
|
||||
await insertImageConfig(db, orgId, 'img-confirm-fail.example.com')
|
||||
await setTrafficQuota(db, orgId)
|
||||
vi.spyOn(deps.cloudTrafficReports, 'markIssued').mockRejectedValueOnce(new Error('confirm failed'))
|
||||
|
||||
const res = await app.request('https://img-confirm-fail.example.com/blog/confirm-fail.png', {
|
||||
headers: { host: 'img-confirm-fail.example.com' },
|
||||
redirect: 'manual',
|
||||
})
|
||||
|
||||
expect(res.status).toBe(500)
|
||||
const quotaRows = await db.all<{ trafficUsed: number }>(
|
||||
sql`SELECT traffic_used AS trafficUsed FROM org_quotas WHERE org_id = ${orgId}`,
|
||||
)
|
||||
expect(quotaRows).toEqual([{ trafficUsed: 25 }])
|
||||
await expect(trafficReports(db)).resolves.toMatchObject([{ status: 'reversed' }])
|
||||
const failures = await db.all<{ reason: string }>(sql`
|
||||
SELECT json_extract(metadata, '$.reason') AS reason
|
||||
FROM audit_events
|
||||
WHERE action = 'download_failed' AND target_id = 'ih-cloud-domain-confirm-fail'
|
||||
`)
|
||||
expect(failures).toEqual([{ reason: 'internal' }])
|
||||
})
|
||||
|
||||
it('still redirects custom-domain images when access-count recording fails after local traffic queue', async () => {
|
||||
const { app, db } = await createTestApp()
|
||||
await seedTrafficBinding(db)
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { Context } from 'hono'
|
||||
import { Hono } from 'hono'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { AppError, NameConflictError } from '../usecases/ports'
|
||||
import { isHandledError, jsonError } from './error-handler'
|
||||
import { isHandledError, jsonError, standaloneJsonError } from './error-handler'
|
||||
import type { Env } from './platform'
|
||||
|
||||
// Build a real Context so jsonError's c.json / c.set behave as in production.
|
||||
@@ -58,3 +58,24 @@ describe('isHandledError', () => {
|
||||
expect(isHandledError(null)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('standaloneJsonError', () => {
|
||||
it('renders an AppError without a Hono context', async () => {
|
||||
const res = standaloneJsonError(
|
||||
new AppError(429, 'Try later', { reason: 'RATE_LIMITED', headers: { 'Retry-After': '5' } }),
|
||||
)
|
||||
expect(res.status).toBe(429)
|
||||
expect(res.headers.get('retry-after')).toBe('5')
|
||||
expect(await res.json()).toMatchObject({
|
||||
error: { message: 'Try later', status: 'RESOURCE_EXHAUSTED' },
|
||||
})
|
||||
})
|
||||
|
||||
it('does not expose unexpected errors', async () => {
|
||||
const res = standaloneJsonError(new Error('database password leaked'))
|
||||
expect(res.status).toBe(500)
|
||||
expect(await res.json()).toMatchObject({
|
||||
error: { message: 'Internal Server Error' },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -39,6 +39,26 @@ export function jsonError(c: Context<Env>, err: unknown): Response {
|
||||
return c.json(buildErrorBody(500, 'Internal Server Error', { reason: 'INTERNAL' }), 500)
|
||||
}
|
||||
|
||||
// Render the same JSON error outside Hono's request pipeline. Cloudflare's
|
||||
// image-domain fast path runs before the full app (and Better Auth) exists.
|
||||
export function standaloneJsonError(err: unknown): Response {
|
||||
if (err instanceof AppError) {
|
||||
return Response.json(
|
||||
buildErrorBody(err.httpStatus, err.message, {
|
||||
reason: err.meta.reason,
|
||||
status: err.meta.canonicalStatus,
|
||||
metadata: err.meta.metadata,
|
||||
}),
|
||||
{ status: err.httpStatus, headers: err.meta.headers },
|
||||
)
|
||||
}
|
||||
|
||||
const mapped = mapDomainError(err)
|
||||
if (mapped) return Response.json(mapped.json, { status: mapped.status })
|
||||
|
||||
return Response.json(buildErrorBody(500, 'Internal Server Error', { reason: 'INTERNAL' }), { status: 500 })
|
||||
}
|
||||
|
||||
// True when `jsonError` would translate `err` into a specific (non-500) result.
|
||||
// Lets `app.onError` log only genuinely unhandled errors as `http.unhandled_error`.
|
||||
export function isHandledError(err: unknown): boolean {
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
import type { Context, Next } from 'hono'
|
||||
import { ZPAN_CLOUD_URL_DEFAULT } from '../../shared/constants'
|
||||
import { imageHostingNotFound } from '../http/image-hosting-not-found'
|
||||
import { PRESIGN_TTL_SECS } from '../http/share-utils'
|
||||
import { reportTrafficForDownload } from '../http/store/traffic-metering'
|
||||
import type { Env } from '../middleware/platform'
|
||||
import type { Platform } from '../platform/interface'
|
||||
import type { Deps } from '../usecases/deps'
|
||||
import { cacheVerifiedImageDomain, resolveCachedImageDomain } from '../usecases/image-hosting/domain-cache'
|
||||
import { forbidden, notFound, quotaExceeded, storageNotFound } from '../usecases/ports'
|
||||
import { confirmDownloadTraffic, reverseDownloadTraffic } from '../usecases/store/traffic-metering'
|
||||
import { forbidden, insufficientCredits, notFound, quotaExceeded, storageNotFound } from '../usecases/ports'
|
||||
import {
|
||||
confirmDownloadTraffic,
|
||||
reportDownloadEgress,
|
||||
reverseDownloadTraffic,
|
||||
} from '../usecases/store/traffic-metering'
|
||||
import { createTrafficEventId, recordDownloadFailure, recordDownloadIssued } from '../usecases/transfer-activity'
|
||||
|
||||
function stripPort(host: string): string {
|
||||
@@ -40,37 +46,44 @@ function checkReferer(refererAllowlist: string[], refererHeader: string | null):
|
||||
}
|
||||
}
|
||||
|
||||
async function handleImageByPath(c: Context<Env>, orgId: string, virtualPath: string): Promise<Response> {
|
||||
const resolved = await c.get('deps').imageHosting.resolveActiveByOrgPath(orgId, virtualPath)
|
||||
if (!resolved) return imageHostingNotFound(c.req.raw)
|
||||
async function handleImageByPath(
|
||||
request: Request,
|
||||
deps: Deps,
|
||||
platform: Platform,
|
||||
orgId: string,
|
||||
virtualPath: string,
|
||||
): Promise<Response> {
|
||||
const resolved = await deps.imageHosting.resolveActiveByOrgPath(orgId, virtualPath)
|
||||
if (!resolved) return imageHostingNotFound(request)
|
||||
|
||||
const { image, refererAllowlist } = resolved
|
||||
|
||||
const refererHeader = c.req.header('Referer') ?? null
|
||||
const refererHeader = request.headers.get('Referer')
|
||||
if (!checkReferer(refererAllowlist, refererHeader)) {
|
||||
throw forbidden('forbidden referer')
|
||||
}
|
||||
|
||||
const storage = await c.get('deps').storages.get(image.storageId)
|
||||
const storage = await deps.storages.get(image.storageId)
|
||||
if (!storage) throw storageNotFound('Storage not found')
|
||||
|
||||
const trafficAllowed = await c.get('deps').quota.consumeTrafficIfQuotaAllows(image.orgId, image.size)
|
||||
const trafficAllowed = await deps.quota.consumeTrafficIfQuotaAllows(image.orgId, image.size)
|
||||
if (!trafficAllowed) {
|
||||
await recordImageDownloadFailure(c, image, 'quota_exceeded')
|
||||
await recordImageDownloadFailure(deps, image, 'quota_exceeded')
|
||||
throw quotaExceeded('Traffic quota exceeded')
|
||||
}
|
||||
|
||||
let url: string
|
||||
try {
|
||||
url = await c.get('deps').s3.presignInline(storage, image.storageKey, image.mime, PRESIGN_TTL_SECS)
|
||||
url = await deps.s3.presignInline(storage, image.storageKey, image.mime, PRESIGN_TTL_SECS)
|
||||
} catch (e) {
|
||||
await c.get('deps').quota.refundTraffic(image.orgId, image.size)
|
||||
await recordImageDownloadFailure(c, image, 'presign_failed')
|
||||
await deps.quota.refundTraffic(image.orgId, image.size)
|
||||
await recordImageDownloadFailure(deps, image, 'presign_failed')
|
||||
throw e
|
||||
}
|
||||
|
||||
const trafficEventId = createTrafficEventId()
|
||||
const trafficReportError = await reportTrafficForDownload(c, {
|
||||
const trafficOutcome = await reportDownloadEgress(deps, {
|
||||
cloudBaseUrl: platform.getEnv('ZPAN_CLOUD_URL') ?? ZPAN_CLOUD_URL_DEFAULT,
|
||||
orgId: image.orgId,
|
||||
bytes: image.size,
|
||||
storage,
|
||||
@@ -78,29 +91,29 @@ async function handleImageByPath(c: Context<Env>, orgId: string, virtualPath: st
|
||||
sourceId: image.id,
|
||||
eventId: trafficEventId,
|
||||
})
|
||||
if (trafficReportError) {
|
||||
await recordImageDownloadFailure(c, image, 'insufficient_credits')
|
||||
return trafficReportError
|
||||
if (!trafficOutcome.ok) {
|
||||
await recordImageDownloadFailure(deps, image, 'insufficient_credits')
|
||||
throw insufficientCredits('Insufficient credits', { metadata: { resource: 'storage_egress' } })
|
||||
}
|
||||
try {
|
||||
await confirmDownloadTraffic(c.get('deps'), { eventId: trafficEventId })
|
||||
await confirmDownloadTraffic(deps, { eventId: trafficEventId })
|
||||
} catch (error) {
|
||||
await reverseDownloadTraffic(c.get('deps'), {
|
||||
await reverseDownloadTraffic(deps, {
|
||||
orgId: image.orgId,
|
||||
bytes: image.size,
|
||||
eventId: trafficEventId,
|
||||
})
|
||||
await recordImageDownloadFailure(c, image, 'internal')
|
||||
await recordImageDownloadFailure(deps, image, 'internal')
|
||||
throw error
|
||||
}
|
||||
|
||||
try {
|
||||
await c.get('deps').imageHosting.incrementAccessCount(image.id)
|
||||
await deps.imageHosting.incrementAccessCount(image.id)
|
||||
} catch (error) {
|
||||
console.error('[image-hosting-domain] incrementAccessCount failed:', error)
|
||||
}
|
||||
await recordDownloadIssued(
|
||||
c.get('deps'),
|
||||
deps,
|
||||
{ userId: null, actorType: 'anonymous', actorRef: null },
|
||||
'image_hosting_download',
|
||||
{
|
||||
@@ -114,18 +127,22 @@ async function handleImageByPath(c: Context<Env>, orgId: string, virtualPath: st
|
||||
},
|
||||
trafficEventId,
|
||||
)
|
||||
const res = c.redirect(url, 302)
|
||||
res.headers.set('Cache-Control', 'no-store')
|
||||
return res
|
||||
return new Response(null, {
|
||||
status: 302,
|
||||
headers: {
|
||||
'Cache-Control': 'no-store',
|
||||
Location: url,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function recordImageDownloadFailure(
|
||||
c: Context<Env>,
|
||||
deps: Deps,
|
||||
image: { id: string; orgId: string; path: string; size: number; storageId: string },
|
||||
reason: string,
|
||||
): Promise<void> {
|
||||
return recordDownloadFailure(
|
||||
c.get('deps'),
|
||||
deps,
|
||||
{ userId: null, actorType: 'anonymous', actorRef: null },
|
||||
{
|
||||
orgId: image.orgId,
|
||||
@@ -140,31 +157,40 @@ function recordImageDownloadFailure(
|
||||
)
|
||||
}
|
||||
|
||||
// biome-ignore lint/suspicious/noConfusingVoidType: Next returns void; union with Response is intentional
|
||||
export async function imageHostingDomain(c: Context<Env>, next: Next): Promise<Response | void> {
|
||||
if (isApplicationPath(c.req.path)) return next()
|
||||
export interface ImageHostingDomainRequestOptions {
|
||||
request: Request
|
||||
deps: Deps
|
||||
platform: Platform
|
||||
appHosts: string[]
|
||||
webDavMountPath: string
|
||||
}
|
||||
|
||||
const rawHost = c.req.header('host')
|
||||
if (!rawHost) return next()
|
||||
export async function handleImageHostingDomainRequest({
|
||||
request,
|
||||
deps,
|
||||
platform,
|
||||
appHosts,
|
||||
webDavMountPath,
|
||||
}: ImageHostingDomainRequestOptions): Promise<Response | null> {
|
||||
const path = requestPath(request)
|
||||
if (isApplicationPath(path)) return null
|
||||
|
||||
const rawHost = request.headers.get('host') ?? new URL(request.url).host
|
||||
const host = normalizeHost(rawHost)
|
||||
if (!host) return next()
|
||||
if (!host || webDavMountPath === '') return null
|
||||
|
||||
if (c.get('webDavMountPath') === '') return next()
|
||||
|
||||
const appHosts = getAppHostCandidates(c)
|
||||
if (
|
||||
appHosts.some((candidate) => host === candidate || (candidate === 'workers.dev' && host.endsWith('.workers.dev')))
|
||||
) {
|
||||
return next()
|
||||
return null
|
||||
}
|
||||
|
||||
const verificationPrefix = '/.well-known/zpan-domain-verification/'
|
||||
if (c.req.method === 'GET' && c.req.path.startsWith(verificationPrefix)) {
|
||||
const token = c.req.path.slice(verificationPrefix.length)
|
||||
if (request.method === 'GET' && path.startsWith(verificationPrefix)) {
|
||||
const token = path.slice(verificationPrefix.length)
|
||||
const [row, provider] = await Promise.all([
|
||||
c.get('deps').imageHostingConfigs.getByDomain(host),
|
||||
c.get('deps').imageDomains.getConfig(),
|
||||
deps.imageHostingConfigs.getByDomain(host),
|
||||
deps.imageDomains.getConfig(),
|
||||
])
|
||||
if (
|
||||
row?.customDomain &&
|
||||
@@ -176,27 +202,51 @@ export async function imageHostingDomain(c: Context<Env>, next: Next): Promise<R
|
||||
!provider.error
|
||||
) {
|
||||
const now = new Date()
|
||||
await c.get('deps').imageHostingConfigs.update(row.orgId, {
|
||||
await deps.imageHostingConfigs.update(row.orgId, {
|
||||
domainStatus: 'verified',
|
||||
domainError: null,
|
||||
domainLastCheckedAt: now,
|
||||
domainVerifiedAt: now,
|
||||
})
|
||||
await cacheVerifiedImageDomain(c.get('deps'), host, row.orgId)
|
||||
return c.text(token, 200, { 'Cache-Control': 'no-store' })
|
||||
await cacheVerifiedImageDomain(deps, host, row.orgId)
|
||||
return new Response(token, {
|
||||
status: 200,
|
||||
headers: { 'Cache-Control': 'no-store', 'Content-Type': 'text/plain; charset=UTF-8' },
|
||||
})
|
||||
}
|
||||
throw notFound('Domain verification not found')
|
||||
}
|
||||
|
||||
const orgId = await resolveCachedImageDomain(c.get('deps'), host)
|
||||
if (!orgId) return next()
|
||||
const orgId = await resolveCachedImageDomain(deps, host)
|
||||
if (!orgId) return null
|
||||
|
||||
const virtualPath = c.req.path.replace(/^\/ih(?:\/|$)/, '').replace(/^\/+/, '')
|
||||
if (!virtualPath) return imageHostingNotFound(c.req.raw)
|
||||
const virtualPath = path.replace(/^\/ih(?:\/|$)/, '').replace(/^\/+/, '')
|
||||
if (!virtualPath) return imageHostingNotFound(request)
|
||||
|
||||
return handleImageByPath(c, orgId, virtualPath)
|
||||
return handleImageByPath(request, deps, platform, orgId, virtualPath)
|
||||
}
|
||||
|
||||
// biome-ignore lint/suspicious/noConfusingVoidType: Next returns void; union with Response is intentional
|
||||
export async function imageHostingDomain(c: Context<Env>, next: Next): Promise<Response | void> {
|
||||
const response = await handleImageHostingDomainRequest({
|
||||
request: c.req.raw,
|
||||
deps: c.get('deps'),
|
||||
platform: c.get('platform'),
|
||||
appHosts: getAppHostCandidates(c),
|
||||
webDavMountPath: c.get('webDavMountPath'),
|
||||
})
|
||||
return response ?? next()
|
||||
}
|
||||
|
||||
function isApplicationPath(path: string): boolean {
|
||||
return path === '/api' || path.startsWith('/api/') || path === '/dav' || path.startsWith('/dav/')
|
||||
}
|
||||
|
||||
function requestPath(request: Request): string {
|
||||
const path = new URL(request.url).pathname
|
||||
try {
|
||||
return decodeURI(path)
|
||||
} catch {
|
||||
return path
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user