release: v1.2.4 — 修复未配置防盗链时外链图裂
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

This commit is contained in:
O96u committed 2026-08-31 15:02:42 +08:00
1 parent 20afda5869
commit 81dcc2c15e
7 files changed
+96 -7

No files matched your search

+6
View File
@@ -4,6 +4,12 @@
## [Unreleased] ## [Unreleased]
## [1.2.4] - 2026-08-31
### 修复
- 访问控制(Referer 防盗链)留空时仍会拦截外链图片:浏览器直开无 Referer 可访问,博客/论坛嵌入带 Referer 会 403 图裂;现仅在配置了白名单时才校验
## [1.2.3] - 2026-08-30 ## [1.2.3] - 2026-08-30
### 修复 ### 修复
+6
View File
@@ -4,6 +4,12 @@ Version history and notable changes. The full log is also in the repo root [CHAN
--- ---
## [1.2.4] — 2026-08-31
### Fix
- **Empty referer whitelist still blocked hotlinks**: with no Referer whitelist configured, direct browser access worked but embedded images on blogs/forums returned 403; protection now runs only when a whitelist is configured
## [1.2.3] — 2026-08-30 ## [1.2.3] — 2026-08-30
### Fix ### Fix
+6
View File
@@ -4,6 +4,12 @@ PicHost 版本历史与重要变更说明。完整记录亦见仓库根目录 [C
--- ---
## [1.2.4] — 2026-08-31
### 修复
- **访问控制留空仍拦截外链**:未配置 Referer 白名单时,浏览器直开图片正常,但博客/论坛嵌入会因 Referer 校验返回 403 图裂;现仅在配置了白名单时才启用防盗链
## [1.2.3] — 2026-08-30 ## [1.2.3] — 2026-08-30
### 修复 ### 修复
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "pichost", "name": "pichost",
"version": "1.2.3", "version": "1.2.4",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "pichost", "name": "pichost",
"version": "1.2.3", "version": "1.2.4",
"hasInstallScript": true, "hasInstallScript": true,
"license": "GPL-3.0-only", "license": "GPL-3.0-only",
"dependencies": { "dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "pichost", "name": "pichost",
"version": "1.2.3", "version": "1.2.4",
"description": "Self-hosted image hosting for your NAS / home server — Nuxt admin UI, local disk or S3-compatible storage, server-side WebP compression, referer protection, Twikoo/EasyImage compatible.", "description": "Self-hosted image hosting for your NAS / home server — Nuxt admin UI, local disk or S3-compatible storage, server-side WebP compression, referer protection, Twikoo/EasyImage compatible.",
"homepage": "https://github.com/O96u/PicHost#readme", "homepage": "https://github.com/O96u/PicHost#readme",
"repository": { "repository": {
+66 -2
View File
@@ -1,5 +1,30 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it, vi, afterEach } from 'vitest'
import { isRefererAllowed } from './serve-image' import type { H3Event } from 'h3'
import { isHotlinkBlocked, isRefererAllowed } from './serve-image'
import { getSetting, SETTINGS_ALLOWED_REFERER_HOSTS } from './db'
vi.mock('./db', async (importOriginal) => {
const actual = await importOriginal<typeof import('./db')>()
return {
...actual,
getSetting: vi.fn()
}
})
vi.stubGlobal('getRequestURL', () => new URL('https://pic.example.com'))
vi.stubGlobal('useRuntimeConfig', () => ({
apiUploadToken: '',
siteBaseUrl: '',
imageBaseUrl: ''
}))
function mockEvent(): H3Event {
return {
node: { req: {}, res: {} },
context: {}
} as H3Event
}
describe('isRefererAllowed', () => { describe('isRefererAllowed', () => {
const allowedHosts = new Set(['pic.example.com', 'blog.example.com']) const allowedHosts = new Set(['pic.example.com', 'blog.example.com'])
@@ -21,3 +46,42 @@ describe('isRefererAllowed', () => {
expect(isRefererAllowed('not-a-url', allowedHosts)).toBe(false) expect(isRefererAllowed('not-a-url', allowedHosts)).toBe(false)
}) })
}) })
describe('isHotlinkBlocked', () => {
afterEach(() => {
vi.clearAllMocks()
delete process.env.ALLOWED_REFERER_HOSTS
})
it('does not block external referers when protection is not configured', () => {
vi.mocked(getSetting).mockReturnValue(null)
const event = mockEvent()
expect(isHotlinkBlocked(event, 'https://blog.example.com/post')).toBe(false)
})
it('does not block external referers when db explicitly cleared', () => {
process.env.ALLOWED_REFERER_HOSTS = 'blog.example.com'
vi.mocked(getSetting).mockImplementation(key =>
key === SETTINGS_ALLOWED_REFERER_HOSTS ? '' : null
)
const event = mockEvent()
expect(isHotlinkBlocked(event, 'https://forum.example.com/thread')).toBe(false)
})
it('blocks external referers when protection is configured', () => {
vi.mocked(getSetting).mockImplementation(key =>
key === SETTINGS_ALLOWED_REFERER_HOSTS ? 'blog.example.com' : null
)
const event = mockEvent()
expect(isHotlinkBlocked(event, 'https://evil.example.com/hotlink')).toBe(true)
expect(isHotlinkBlocked(event, 'https://blog.example.com/post')).toBe(false)
})
it('still allows missing referer when protection is configured', () => {
vi.mocked(getSetting).mockImplementation(key =>
key === SETTINGS_ALLOWED_REFERER_HOSTS ? 'blog.example.com' : null
)
const event = mockEvent()
expect(isHotlinkBlocked(event, null)).toBe(false)
})
})
+9 -2
View File
@@ -4,7 +4,8 @@ import {
getAllowedRefererHostsRaw, getAllowedRefererHostsRaw,
getImageBaseUrlConfigured, getImageBaseUrlConfigured,
getSiteBaseUrlConfigured, getSiteBaseUrlConfigured,
hostnameFromBaseUrl hostnameFromBaseUrl,
isRefererConfigured
} from './env' } from './env'
import { findImageKeyByDatePath, findImageKeyByFilename } from './image-index' import { findImageKeyByDatePath, findImageKeyByFilename } from './image-index'
import { validateImageKey } from './image-key' import { validateImageKey } from './image-key'
@@ -58,6 +59,12 @@ export function isRefererAllowed(referer: string | null, allowedHosts: Set<strin
} }
} }
/** 未配置防盗链白名单时不拦截;已配置时校验 Referer(缺失 Referer 仍放行) */
export function isHotlinkBlocked(event: H3Event, referer: string | null): boolean {
if (!isRefererConfigured(event)) return false
return !isRefererAllowed(referer, getAllowedHosts(event))
}
function parseRangeHeader( function parseRangeHeader(
rangeHeader: string | undefined, rangeHeader: string | undefined,
size: number size: number
@@ -108,7 +115,7 @@ export function requestPathToImageKey(
export async function serveImageByKey(event: H3Event, key: string) { export async function serveImageByKey(event: H3Event, key: string) {
const referer = getHeader(event, 'referer') ?? null const referer = getHeader(event, 'referer') ?? null
if (!isRefererAllowed(referer, getAllowedHosts(event))) { if (isHotlinkBlocked(event, referer)) {
setHeader(event, 'Cache-Control', ERROR_CACHE_CONTROL) setHeader(event, 'Cache-Control', ERROR_CACHE_CONTROL)
throw createError({ throw createError({
statusCode: 403, statusCode: 403,