release: v1.2.2 — 双域名配置与 Host 隔离修复
GitHub Release / release (push) Canceled after 0s
Publish Docker image / build (linux/amd64, ubuntu-latest, amd64) (push) Canceled after 0s
Publish Docker image / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Canceled after 0s
Publish Docker image / merge (push) Canceled after 0s
ci / ci (22, ubuntu-latest) (push) Canceled after 0s

修复设置页误清空管理域、配置与生效 URL 混淆及第三 Host 绕过隔离;加固 Settings API 与中间件,并更新文档站。
This commit is contained in:
O96u committed 2026-08-30 10:57:26 +08:00
1 parent 6c6766221b
commit c9c34515dd
36 files changed
+2578 -180

No files matched your search

+37 -17
View File
@@ -23,7 +23,7 @@ import {
parseAutoDeleteDays,
parseWebpQuality,
setGlobalAutoDeletePolicy,
validateDomainSeparationPair
validateSettingsDomainPatch
} from '../../utils/env'
interface SettingsPatchBody {
@@ -31,6 +31,7 @@ interface SettingsPatchBody {
allowedRefererHosts?: unknown
siteBaseUrl?: unknown
imageBaseUrl?: unknown
domainSeparation?: unknown
hideFolderInUrl?: unknown
storageUseDatePath?: unknown
autoDeleteDays?: unknown
@@ -71,6 +72,11 @@ export default defineEventHandler(async (event) => {
setSetting(SETTINGS_ALLOWED_REFERER_HOSTS, normalized)
}
const existingSite = normalizeSiteBaseUrl(getSetting(SETTINGS_SITE_BASE_URL) ?? '')
const existingImage = normalizeImageBaseUrl(getSetting(SETTINGS_IMAGE_BASE_URL) ?? '')
let nextSite = existingSite
let nextImage = existingImage
if (body.siteBaseUrl !== undefined) {
const configured = normalizeSiteBaseUrl(
typeof body.siteBaseUrl === 'string' ? body.siteBaseUrl : ''
@@ -79,11 +85,11 @@ export default defineEventHandler(async (event) => {
createApiError(
event,
'INVALID_REQUEST',
'SITE_BASE_URL 需为 http(s) 地址,或留空使用当前请求域名',
'SITE_BASE_URL 需为 http(s) 地址,且末尾不加 /',
400
)
}
setSetting(SETTINGS_SITE_BASE_URL, configured)
nextSite = configured
}
if (body.imageBaseUrl !== undefined) {
@@ -98,22 +104,36 @@ export default defineEventHandler(async (event) => {
400
)
}
setSetting(SETTINGS_IMAGE_BASE_URL, configured)
nextImage = configured
}
if (body.siteBaseUrl !== undefined || body.imageBaseUrl !== undefined) {
const siteConfigured = normalizeSiteBaseUrl(
getSetting(SETTINGS_SITE_BASE_URL) ?? ''
)
const imageConfigured = normalizeImageBaseUrl(
getSetting(SETTINGS_IMAGE_BASE_URL) ?? ''
)
if (siteConfigured && imageConfigured) {
const pairError = validateDomainSeparationPair(siteConfigured, imageConfigured)
if (pairError) {
createApiError(event, 'INVALID_REQUEST', pairError, 400)
}
}
const domainSeparationProvided = body.domainSeparation !== undefined
const wantSeparation = domainSeparationProvided
? Boolean(body.domainSeparation)
: null
const patchError = validateSettingsDomainPatch({
existingSite,
existingImage,
nextSite,
nextImage,
wantSeparation,
siteBaseUrlProvided: body.siteBaseUrl !== undefined
})
if (patchError) {
createApiError(event, 'INVALID_REQUEST', patchError, 400)
}
if (wantSeparation === false) {
nextSite = ''
}
if (body.siteBaseUrl !== undefined || body.domainSeparation !== undefined) {
setSetting(SETTINGS_SITE_BASE_URL, nextSite)
}
if (body.imageBaseUrl !== undefined) {
setSetting(SETTINGS_IMAGE_BASE_URL, nextImage)
}
if (body.autoDeleteDays !== undefined) {
+10 -2
View File
@@ -1,9 +1,17 @@
import { shouldBlockImageHostRequest, shouldBlockSiteHostImageRequest } from '../utils/host-isolation'
import {
shouldBlockImageHostRequest,
shouldBlockSiteHostImageRequest,
shouldBlockUnknownHostRequest
} from '../utils/host-isolation'
const HOST_ISOLATION_CACHE_CONTROL = 'private, no-cache, max-age=0'
export default defineEventHandler((event) => {
if (shouldBlockImageHostRequest(event) || shouldBlockSiteHostImageRequest(event)) {
if (
shouldBlockUnknownHostRequest(event)
|| shouldBlockImageHostRequest(event)
|| shouldBlockSiteHostImageRequest(event)
) {
setHeader(event, 'Cache-Control', HOST_ISOLATION_CACHE_CONTROL)
throw createError({ statusCode: 404, statusMessage: 'Not Found' })
}
+66 -1
View File
@@ -8,6 +8,7 @@ import {
getAutoDeleteDays,
getAutoDeleteDaysSource,
getRefererSource,
getSettingsPayload,
getSiteBaseUrl,
getSiteBaseUrlConfigured,
getSiteBaseUrlSource,
@@ -17,13 +18,15 @@ import {
isAutoDeleteDaysEnvConfigured,
isRefererEnvConfigured,
isWebpQualityEnvConfigured,
validateDomainSeparationPair
validateDomainSeparationPair,
validateSettingsDomainPatch
} from './env'
import {
getSetting,
SETTINGS_ALLOWED_REFERER_HOSTS,
SETTINGS_API_UPLOAD_TOKEN,
SETTINGS_AUTO_DELETE_DAYS,
SETTINGS_IMAGE_BASE_URL,
SETTINGS_SITE_BASE_URL,
SETTINGS_WEBP_QUALITY,
generateApiUploadToken
@@ -255,3 +258,65 @@ describe('validateDomainSeparationPair', () => {
)).toBeNull()
})
})
describe('validateSettingsDomainPatch', () => {
const dualExisting = {
existingSite: 'https://admin.example.com',
existingImage: 'https://pic.example.com'
}
it('requires both URLs when enabling domain separation', () => {
expect(validateSettingsDomainPatch({
...dualExisting,
nextSite: '',
nextImage: 'https://pic.example.com',
wantSeparation: true,
siteBaseUrlProvided: true
})).toContain('同时填写')
})
it('blocks clearing site without explicit disable when dual domain is active', () => {
expect(validateSettingsDomainPatch({
...dualExisting,
nextSite: '',
nextImage: 'https://pic.example.com',
wantSeparation: null,
siteBaseUrlProvided: true
})).toContain('不能单独清空管理域名')
})
it('allows clearing site when explicitly disabling domain separation', () => {
expect(validateSettingsDomainPatch({
...dualExisting,
nextSite: '',
nextImage: 'https://pic.example.com',
wantSeparation: false,
siteBaseUrlProvided: true
})).toBeNull()
})
})
describe('getSettingsPayload configured vs effective', () => {
afterEach(() => {
vi.clearAllMocks()
delete process.env.SITE_BASE_URL
delete process.env.IMAGE_BASE_URL
})
it('returns configured site URL without request fallback', () => {
vi.mocked(getSetting).mockImplementation((key) => {
if (key === SETTINGS_SITE_BASE_URL) return 'https://admin.example.com'
if (key === SETTINGS_IMAGE_BASE_URL) return 'https://pic.example.com'
return null
})
vi.stubGlobal('getRequestURL', () => new URL('https://pages.dev'))
const event = mockEvent()
const payload = getSettingsPayload(event)
expect(payload.siteBaseUrl).toBe('https://admin.example.com')
expect(payload.effectiveSiteBaseUrl).toBe('https://admin.example.com')
expect(payload.runtime.currentHost).toBe('pages.dev')
expect(payload.runtime.hostRole).toBe('unknown')
})
})
+101 -8
View File
@@ -465,12 +465,102 @@ export function validateDomainSeparationPair(
return null
}
export type RequestHostRole = 'site' | 'image' | 'unknown' | 'single'
export function getIsolationRequestHost(event: H3Event): string {
const requestUrl = getRequestURL(event, {
xForwardedHost: true,
xForwardedProto: true
})
return requestUrl.hostname.toLowerCase()
}
export function validateSettingsDomainPatch(input: {
existingSite: string
existingImage: string
nextSite: string
nextImage: string
wantSeparation: boolean | null
siteBaseUrlProvided: boolean
}): string | null {
const {
existingSite,
existingImage,
nextSite,
nextImage,
wantSeparation,
siteBaseUrlProvided
} = input
const hadDualDomain = (() => {
const siteHost = hostnameFromBaseUrl(existingSite)
const imageHost = hostnameFromBaseUrl(existingImage)
return Boolean(siteHost && imageHost && siteHost !== imageHost)
})()
if (wantSeparation === true) {
if (!nextSite || !nextImage) {
return '启用域名分离时需同时填写网站域名与图片域名'
}
return validateDomainSeparationPair(nextSite, nextImage)
}
if (wantSeparation === false) {
return null
}
if (siteBaseUrlProvided && !nextSite && existingSite && hadDualDomain) {
return '已启用域名分离时不能单独清空管理域名,请显式关闭域名分离'
}
if (nextSite && nextImage) {
return validateDomainSeparationPair(nextSite, nextImage)
}
return null
}
export function getRequestRuntime(event: H3Event) {
const requestUrl = getRequestURL(event, {
xForwardedHost: true,
xForwardedProto: true
})
const currentHost = requestUrl.hostname.toLowerCase()
const currentOrigin = requestUrl.origin
if (!isDomainSeparationActive(event)) {
return {
currentOrigin,
currentHost,
hostRole: 'single' as const
}
}
const siteHost = hostnameFromBaseUrl(getSiteBaseUrlConfigured(event))
const imageHost = hostnameFromBaseUrl(getImageBaseUrlConfigured(event))
let hostRole: RequestHostRole = 'unknown'
if (currentHost === siteHost) {
hostRole = 'site'
} else if (currentHost === imageHost) {
hostRole = 'image'
}
return {
currentOrigin,
currentHost,
hostRole
}
}
export function getSettingsPayload(event: H3Event) {
const config = useRuntimeConfig(event)
const webpQuality = getWebpQuality(event)
const allowedRefererHosts = getAllowedRefererHostsRaw(event)
const siteBaseUrl = getSiteBaseUrl(event)
const imageBaseUrl = getImageBaseUrl(event)
const siteBaseUrlConfigured = getSiteBaseUrlConfigured(event)
const imageBaseUrlConfigured = getImageBaseUrlConfigured(event)
const effectiveSiteBaseUrl = getSiteBaseUrl(event)
const effectiveImageBaseUrl = getImageBaseUrl(event)
const autoDeleteDays = getAutoDeleteDays(event)
const appVersion = config.appVersion as string
const domainSeparation = isDomainSeparationActive(event)
@@ -488,13 +578,16 @@ export function getSettingsPayload(event: H3Event) {
allowedRefererHosts,
refererSource: getRefererSource(),
refererEnvFallback: getRefererHostsFromEnv(),
siteBaseUrl,
siteBaseUrlConfigured: getSiteBaseUrlConfigured(event),
siteBaseUrl: siteBaseUrlConfigured,
siteBaseUrlConfigured,
siteBaseUrlSource: getSiteBaseUrlSource(event),
imageBaseUrl,
imageBaseUrlConfigured: getImageBaseUrlConfigured(event),
imageBaseUrl: imageBaseUrlConfigured,
imageBaseUrlConfigured,
imageBaseUrlSource: getImageBaseUrlSource(event),
effectiveSiteBaseUrl,
effectiveImageBaseUrl,
domainSeparation,
runtime: getRequestRuntime(event),
hideFolderInUrl,
hideFolderInUrlSource,
storageUseDatePath,
@@ -507,8 +600,8 @@ export function getSettingsPayload(event: H3Event) {
env: {
webpQuality,
refererConfigured: allowedRefererHosts.length > 0,
siteBaseUrl,
imageBaseUrl,
siteBaseUrl: effectiveSiteBaseUrl,
imageBaseUrl: effectiveImageBaseUrl,
hideFolderInUrl,
appVersion
}
+82 -1
View File
@@ -2,7 +2,8 @@ import { describe, expect, it } from 'vitest'
import type { H3Event } from 'h3'
import {
shouldBlockImageHostRequest,
shouldBlockSiteHostImageRequest
shouldBlockSiteHostImageRequest,
shouldBlockUnknownHostRequest
} from './host-isolation'
function mockEvent(): H3Event {
@@ -96,6 +97,18 @@ describe('shouldBlockSiteHostImageRequest', () => {
})).toBe(true)
})
it('blocks public image paths on site host', () => {
const event = mockEvent()
expect(shouldBlockSiteHostImageRequest(event, {
separationActive: true,
siteHost,
requestHost: siteHost,
method: 'GET',
pathname: '/2026/08/demo.webp',
hideFolder: false
})).toBe(true)
})
it('blocks legacy date short paths on site host', () => {
const event = mockEvent()
expect(shouldBlockSiteHostImageRequest(event, {
@@ -130,3 +143,71 @@ describe('shouldBlockSiteHostImageRequest', () => {
})).toBe(false)
})
})
describe('shouldBlockUnknownHostRequest', () => {
const imageHost = 'image.example.com'
const siteHost = 'admin.example.com'
it('does not block when separation is inactive', () => {
const event = mockEvent()
expect(shouldBlockUnknownHostRequest(event, {
separationActive: false,
requestHost: 'pages.dev',
siteHost,
imageHost
})).toBe(false)
})
it('blocks unconfigured third host when separation is active', () => {
const event = mockEvent()
expect(shouldBlockUnknownHostRequest(event, {
separationActive: true,
requestHost: 'pages.dev',
siteHost,
imageHost
})).toBe(true)
})
it('blocks admin paths on image host when host is unconfigured', () => {
const event = mockEvent()
expect(shouldBlockUnknownHostRequest(event, {
separationActive: true,
requestHost: 'old-img.example.com',
siteHost,
imageHost
})).toBe(true)
})
it('allows configured site host', () => {
const event = mockEvent()
expect(shouldBlockUnknownHostRequest(event, {
separationActive: true,
requestHost: siteHost,
siteHost,
imageHost
})).toBe(false)
})
it('allows configured image host', () => {
const event = mockEvent()
expect(shouldBlockUnknownHostRequest(event, {
separationActive: true,
requestHost: imageHost,
siteHost,
imageHost
})).toBe(false)
})
it('allows localhost in development', () => {
const previous = process.env.NODE_ENV
process.env.NODE_ENV = 'development'
const event = mockEvent()
expect(shouldBlockUnknownHostRequest(event, {
separationActive: true,
requestHost: 'localhost',
siteHost,
imageHost
})).toBe(false)
process.env.NODE_ENV = previous
})
})
+48 -16
View File
@@ -8,6 +8,20 @@ import {
} from './env'
import { requestPathToImageKey } from './serve-image'
const DEV_LOCAL_HOSTS = new Set(['localhost', '127.0.0.1'])
function getIsolationRequestHost(event: H3Event): string {
const requestUrl = getRequestURL(event, {
xForwardedHost: true,
xForwardedProto: true
})
return requestUrl.hostname.toLowerCase()
}
function isDevLocalHost(host: string): boolean {
return process.env.NODE_ENV === 'development' && DEV_LOCAL_HOSTS.has(host)
}
export function getConfiguredSiteHost(event: H3Event): string | null {
return hostnameFromBaseUrl(getSiteBaseUrlConfigured(event))
}
@@ -16,6 +30,30 @@ export function getConfiguredImageHost(event: H3Event): string | null {
return hostnameFromBaseUrl(getImageBaseUrlConfigured(event))
}
/** 双域名开启时,拒绝未配置的第三 Host(开发环境 localhost 例外) */
export function shouldBlockUnknownHostRequest(
event: H3Event,
options?: {
requestHost?: string
separationActive?: boolean
siteHost?: string | null
imageHost?: string | null
}
): boolean {
const separationActive = options?.separationActive ?? isDomainSeparationActive(event)
if (!separationActive) return false
const siteHost = options?.siteHost ?? getConfiguredSiteHost(event)
const imageHost = options?.imageHost ?? getConfiguredImageHost(event)
if (!siteHost || !imageHost) return false
const requestHost = (options?.requestHost ?? getIsolationRequestHost(event)).toLowerCase()
if (requestHost === siteHost || requestHost === imageHost) return false
if (isDevLocalHost(requestHost)) return false
return true
}
export function shouldBlockImageHostRequest(
event: H3Event,
options?: {
@@ -34,19 +72,16 @@ export function shouldBlockImageHostRequest(
const imageHost = options?.imageHost ?? getConfiguredImageHost(event)
if (!imageHost) return false
const requestUrl = options?.requestHost
? null
: getRequestURL(event, {
xForwardedHost: true,
xForwardedProto: true
})
const requestHost = (options?.requestHost ?? requestUrl?.hostname ?? '').toLowerCase()
const requestHost = (options?.requestHost ?? getIsolationRequestHost(event)).toLowerCase()
if (requestHost !== imageHost) return false
const method = (options?.method ?? getMethod(event)).toUpperCase()
if (method !== 'GET' && method !== 'HEAD') return true
const pathname = options?.pathname ?? requestUrl?.pathname ?? ''
const pathname = options?.pathname ?? getRequestURL(event, {
xForwardedHost: true,
xForwardedProto: true
}).pathname
const hideFolder = options?.hideFolder ?? isHideFolderInUrl(event)
return requestPathToImageKey(pathname, {
hideFolder
@@ -71,19 +106,16 @@ export function shouldBlockSiteHostImageRequest(
const siteHost = options?.siteHost ?? getConfiguredSiteHost(event)
if (!siteHost) return false
const requestUrl = options?.requestHost
? null
: getRequestURL(event, {
xForwardedHost: true,
xForwardedProto: true
})
const requestHost = (options?.requestHost ?? requestUrl?.hostname ?? '').toLowerCase()
const requestHost = (options?.requestHost ?? getIsolationRequestHost(event)).toLowerCase()
if (requestHost !== siteHost) return false
const method = (options?.method ?? getMethod(event)).toUpperCase()
if (method !== 'GET' && method !== 'HEAD') return false
const pathname = options?.pathname ?? requestUrl?.pathname ?? ''
const pathname = options?.pathname ?? getRequestURL(event, {
xForwardedHost: true,
xForwardedProto: true
}).pathname
const hideFolder = options?.hideFolder ?? isHideFolderInUrl(event)
return requestPathToImageKey(pathname, {
hideFolder
+30 -4
View File
@@ -2,13 +2,16 @@ import { describe, expect, it } from 'vitest'
import {
isBareImageFilename,
isHiddenImageDatePath,
toPublicImagePath
storageKeyToPublicPath,
toPublicImagePath,
toStorageKeyFromPublicPath
} from './image-public-path'
describe('toPublicImagePath', () => {
it('returns key unchanged when hideFolder is false', () => {
expect(toPublicImagePath('images/2026/08/a.webp', false)).toBe('images/2026/08/a.webp')
expect(toPublicImagePath('images/a.webp', false)).toBe('images/a.webp')
it('strips internal images/ prefix when hideFolder is false', () => {
expect(toPublicImagePath('images/2026/08/a.webp', false)).toBe('2026/08/a.webp')
expect(toPublicImagePath('images/a.webp', false)).toBe('a.webp')
expect(toPublicImagePath('images/blog/a.webp', false)).toBe('blog/a.webp')
})
it('returns basename when hideFolder is true', () => {
@@ -18,6 +21,29 @@ describe('toPublicImagePath', () => {
})
})
describe('storageKeyToPublicPath', () => {
it('removes images/ prefix only', () => {
expect(storageKeyToPublicPath('images/2026/08/a.webp')).toBe('2026/08/a.webp')
})
})
describe('toStorageKeyFromPublicPath', () => {
it('maps public relative paths to storage keys', () => {
expect(toStorageKeyFromPublicPath('2026/08/a.webp')).toBe('images/2026/08/a.webp')
expect(toStorageKeyFromPublicPath('blog/a.webp')).toBe('images/blog/a.webp')
expect(toStorageKeyFromPublicPath('a.webp')).toBe('images/a.webp')
})
it('accepts legacy full storage keys', () => {
expect(toStorageKeyFromPublicPath('images/2026/08/a.webp')).toBe('images/2026/08/a.webp')
})
it('rejects invalid paths', () => {
expect(toStorageKeyFromPublicPath('')).toBeNull()
expect(toStorageKeyFromPublicPath('../../../etc/passwd')).toBeNull()
})
})
describe('isHiddenImageDatePath', () => {
it('accepts YYYY/MM/file paths', () => {
expect(isHiddenImageDatePath('2026/08/demo.webp')).toBe(true)
+24 -3
View File
@@ -1,9 +1,30 @@
import { basename } from 'node:path'
import { DEFAULT_FOLDER, validateImageKey } from './image-key'
/** 对外 URL 路径:可选隐藏目录前缀(输出纯文件名) */
/** 存储 key → 对外 URL 路径(不含域名) */
export function storageKeyToPublicPath(key: string): string {
const prefix = `${DEFAULT_FOLDER}/`
if (key.startsWith(prefix)) return key.slice(prefix.length)
return key
}
/**
* 对外 URL 路径:
* - hideFolder=true:仅文件名(img.com/a8K3xP.webp)
* - hideFolder=false:去掉内部 images/ 前缀(img.com/2026/08/a.webp)
*/
export function toPublicImagePath(key: string, hideFolder: boolean): string {
if (!hideFolder) return key
return basename(key)
if (hideFolder) return basename(key)
return storageKeyToPublicPath(key)
}
/** 公开相对路径 → 存储 key;legacy 完整 key(含 images/)仍接受 */
export function toStorageKeyFromPublicPath(publicPath: string): string | null {
const raw = publicPath.replace(/^\/+/, '')
if (!raw) return null
if (validateImageKey(raw)) return raw
const candidate = `${DEFAULT_FOLDER}/${raw}`
return validateImageKey(candidate) ? candidate : null
}
const HIDDEN_DATE_PATH_PATTERN
+6 -4
View File
@@ -19,14 +19,16 @@ describe('requestPathToImageKey hide folder', () => {
.toBe('images/demo.webp')
})
it('does not resolve short paths when hide folder is disabled', () => {
it('resolves public paths when hide folder is disabled', () => {
expect(requestPathToImageKey('/2026/08/demo.webp', { hideFolder: false }))
.toBeNull()
.toBe('images/2026/08/demo.webp')
expect(requestPathToImageKey('/blog/demo.webp', { hideFolder: false }))
.toBe('images/blog/demo.webp')
expect(requestPathToImageKey('/demo.webp', { hideFolder: false }))
.toBeNull()
.toBe('images/demo.webp')
})
it('accepts flat full key when hide folder is disabled', () => {
it('accepts legacy full key when hide folder is disabled', () => {
expect(requestPathToImageKey('/images/demo.webp', { hideFolder: false }))
.toBe('images/demo.webp')
})
+12 -6
View File
@@ -8,6 +8,7 @@ import {
} from './env'
import { findImageKeyByDatePath, findImageKeyByFilename } from './image-index'
import { validateImageKey } from './image-key'
import { toStorageKeyFromPublicPath } from './image-public-path'
import { buildPublicImageUrl } from './image-response'
import { createImageStream, headImage } from './storage'
import { getBackendRowForKey } from './storage/resolver'
@@ -80,15 +81,20 @@ export function requestPathToImageKey(
options?: { hideFolder?: boolean }
): string | null {
const raw = decodeURIComponent(pathname).replace(/^\/+/, '')
if (!raw) return null
if (validateImageKey(raw)) return raw
if (options?.hideFolder) {
const byFilename = findImageKeyByFilename(raw)
if (byFilename && validateImageKey(byFilename)) return byFilename
const fromPublic = toStorageKeyFromPublicPath(raw)
if (fromPublic) return fromPublic
const byDatePath = findImageKeyByDatePath(raw)
if (byDatePath && validateImageKey(byDatePath)) return byDatePath
}
if (!options?.hideFolder) return null
const byFilename = findImageKeyByFilename(raw)
if (byFilename && validateImageKey(byFilename)) return byFilename
const byDatePath = findImageKeyByDatePath(raw)
if (byDatePath && validateImageKey(byDatePath)) return byDatePath
return null
}