mirror of
https://github.com/ManInDark/HabitTrove.git
synced 2026-03-11 04:49:49 +01:00
Release/v0.2.31 (#188)
This commit is contained in:
197
app/api/avatars/[...path]/route.test.ts
Normal file
197
app/api/avatars/[...path]/route.test.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, mock, spyOn, test } from 'bun:test'
|
||||
import path from 'path'
|
||||
|
||||
const mockReadFile = mock()
|
||||
const mockRealpath = mock()
|
||||
const mockLstat = mock()
|
||||
|
||||
mock.module('fs/promises', () => ({
|
||||
default: {
|
||||
readFile: mockReadFile,
|
||||
realpath: mockRealpath,
|
||||
lstat: mockLstat,
|
||||
},
|
||||
readFile: mockReadFile,
|
||||
realpath: mockRealpath,
|
||||
lstat: mockLstat,
|
||||
}))
|
||||
|
||||
let GET: typeof import('./route').GET
|
||||
|
||||
beforeAll(async () => {
|
||||
;({ GET } = await import('./route'))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
mock.restore()
|
||||
})
|
||||
|
||||
describe('GET /api/avatars/[...path]', () => {
|
||||
beforeEach(() => {
|
||||
mockReadFile.mockReset()
|
||||
mockRealpath.mockReset()
|
||||
mockLstat.mockReset()
|
||||
|
||||
spyOn(process, 'cwd').mockReturnValue('/app')
|
||||
|
||||
mockRealpath.mockImplementation(async (value: string) => value)
|
||||
mockLstat.mockResolvedValue({ isSymbolicLink: () => false })
|
||||
})
|
||||
|
||||
test('returns avatar image for valid file path', async () => {
|
||||
mockReadFile.mockResolvedValue(Buffer.from('avatar-binary'))
|
||||
|
||||
const response = await GET(new Request('http://localhost:3000/api/avatars/avatar.png'), {
|
||||
params: Promise.resolve({ path: ['avatar.png'] }),
|
||||
})
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(response.headers.get('Content-Type')).toBe('image/png')
|
||||
expect(response.headers.get('X-Content-Type-Options')).toBe('nosniff')
|
||||
expect(mockReadFile).toHaveBeenCalledWith(path.resolve('/app', 'data', 'avatars', 'avatar.png'))
|
||||
})
|
||||
|
||||
test('allows nested valid avatar paths', async () => {
|
||||
mockReadFile.mockResolvedValue(Buffer.from('avatar-binary'))
|
||||
|
||||
const response = await GET(new Request('http://localhost:3000/api/avatars/user-1/avatar.png'), {
|
||||
params: Promise.resolve({ path: ['user-1', 'avatar.png'] }),
|
||||
})
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(mockReadFile).toHaveBeenCalledWith(path.resolve('/app', 'data', 'avatars', 'user-1', 'avatar.png'))
|
||||
})
|
||||
|
||||
test('supports uppercase extensions', async () => {
|
||||
mockReadFile.mockResolvedValue(Buffer.from('avatar-binary'))
|
||||
|
||||
const response = await GET(new Request('http://localhost:3000/api/avatars/avatar.PNG'), {
|
||||
params: Promise.resolve({ path: ['avatar.PNG'] }),
|
||||
})
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(response.headers.get('Content-Type')).toBe('image/png')
|
||||
})
|
||||
|
||||
test('rejects traversal segments', async () => {
|
||||
const response = await GET(new Request('http://localhost:3000/api/avatars/../auth.json'), {
|
||||
params: Promise.resolve({ path: ['..', 'auth.json'] }),
|
||||
})
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
expect(await response.json()).toEqual({ error: 'Invalid avatar path' })
|
||||
expect(mockReadFile).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('rejects encoded traversal payloads', async () => {
|
||||
const response = await GET(new Request('http://localhost:3000/api/avatars/%2e%2e%2fauth.json'), {
|
||||
params: Promise.resolve({ path: ['%2e%2e%2fauth.json'] }),
|
||||
})
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
expect(await response.json()).toEqual({ error: 'Invalid avatar path' })
|
||||
expect(mockReadFile).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('rejects encoded backslash traversal payloads', async () => {
|
||||
const response = await GET(new Request('http://localhost:3000/api/avatars/..%5cauth.png'), {
|
||||
params: Promise.resolve({ path: ['..%5cauth.png'] }),
|
||||
})
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
expect(await response.json()).toEqual({ error: 'Invalid avatar path' })
|
||||
expect(mockReadFile).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('rejects null byte payloads', async () => {
|
||||
const response = await GET(new Request('http://localhost:3000/api/avatars/avatar.png%00'), {
|
||||
params: Promise.resolve({ path: ['avatar.png%00'] }),
|
||||
})
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
expect(await response.json()).toEqual({ error: 'Invalid avatar path' })
|
||||
expect(mockReadFile).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('rejects dot-only segments', async () => {
|
||||
const response = await GET(new Request('http://localhost:3000/api/avatars/./avatar.png'), {
|
||||
params: Promise.resolve({ path: ['.', 'avatar.png'] }),
|
||||
})
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
expect(await response.json()).toEqual({ error: 'Invalid avatar path' })
|
||||
expect(mockReadFile).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('rejects malformed encoded segments', async () => {
|
||||
const response = await GET(new Request('http://localhost:3000/api/avatars/%E0%A4%A'), {
|
||||
params: Promise.resolve({ path: ['%E0%A4%A'] }),
|
||||
})
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
expect(await response.json()).toEqual({ error: 'Invalid avatar path' })
|
||||
expect(mockReadFile).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('rejects unsupported file extensions', async () => {
|
||||
const response = await GET(new Request('http://localhost:3000/api/avatars/config.json'), {
|
||||
params: Promise.resolve({ path: ['config.json'] }),
|
||||
})
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
expect(await response.json()).toEqual({ error: 'Unsupported file type' })
|
||||
expect(mockReadFile).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('rejects symlinked avatar files', async () => {
|
||||
mockLstat.mockResolvedValue({ isSymbolicLink: () => true })
|
||||
|
||||
const response = await GET(new Request('http://localhost:3000/api/avatars/avatar.png'), {
|
||||
params: Promise.resolve({ path: ['avatar.png'] }),
|
||||
})
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
expect(await response.json()).toEqual({ error: 'Invalid avatar path' })
|
||||
expect(mockReadFile).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('rejects files whose real path escapes avatars directory', async () => {
|
||||
mockRealpath.mockImplementation(async (value: string) => {
|
||||
if (value === path.resolve('/app', 'data', 'avatars')) {
|
||||
return value
|
||||
}
|
||||
|
||||
return path.resolve('/app', 'data', 'auth.png')
|
||||
})
|
||||
|
||||
const response = await GET(new Request('http://localhost:3000/api/avatars/avatar.png'), {
|
||||
params: Promise.resolve({ path: ['avatar.png'] }),
|
||||
})
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
expect(await response.json()).toEqual({ error: 'Invalid avatar path' })
|
||||
expect(mockReadFile).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('returns 404 when file is missing', async () => {
|
||||
mockLstat.mockRejectedValue({ code: 'ENOENT' })
|
||||
|
||||
const response = await GET(new Request('http://localhost:3000/api/avatars/missing.png'), {
|
||||
params: Promise.resolve({ path: ['missing.png'] }),
|
||||
})
|
||||
|
||||
expect(response.status).toBe(404)
|
||||
expect(await response.json()).toEqual({ error: 'File not found' })
|
||||
})
|
||||
|
||||
test('returns 500 for non-ENOENT read errors', async () => {
|
||||
mockReadFile.mockRejectedValue({ code: 'EACCES' })
|
||||
|
||||
const response = await GET(new Request('http://localhost:3000/api/avatars/avatar.png'), {
|
||||
params: Promise.resolve({ path: ['avatar.png'] }),
|
||||
})
|
||||
|
||||
expect(response.status).toBe(500)
|
||||
expect(await response.json()).toEqual({ error: 'Internal server error' })
|
||||
})
|
||||
})
|
||||
@@ -1,26 +1,109 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import fs from 'fs/promises'
|
||||
import path from 'path'
|
||||
import { ALLOWED_AVATAR_EXTENSIONS, AVATAR_CONTENT_TYPE } from '@/lib/avatar'
|
||||
|
||||
function sanitizePathSegments(pathSegments?: string[]): string[] | null {
|
||||
if (!pathSegments || pathSegments.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const safeSegments: string[] = []
|
||||
|
||||
for (const rawSegment of pathSegments) {
|
||||
let segment = rawSegment
|
||||
|
||||
try {
|
||||
segment = decodeURIComponent(rawSegment)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
||||
if (!segment || segment === '.' || segment === '..') {
|
||||
return null
|
||||
}
|
||||
|
||||
if (segment.includes('/') || segment.includes('\\') || segment.includes('\0')) {
|
||||
return null
|
||||
}
|
||||
|
||||
safeSegments.push(segment)
|
||||
}
|
||||
|
||||
return safeSegments
|
||||
}
|
||||
|
||||
function isPathInsideBase(basePath: string, targetPath: string): boolean {
|
||||
return targetPath === basePath || targetPath.startsWith(`${basePath}${path.sep}`)
|
||||
}
|
||||
|
||||
function getErrorCode(error: unknown): string | null {
|
||||
if (typeof error !== 'object' || error === null || !('code' in error)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const { code } = error as { code?: unknown }
|
||||
return typeof code === 'string' ? code : null
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
request: Request,
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ path: string[] }> }
|
||||
) {
|
||||
const { path: pathSegments } = await Promise.resolve(params)
|
||||
const safeSegments = sanitizePathSegments(pathSegments)
|
||||
|
||||
if (!safeSegments) {
|
||||
return NextResponse.json({ error: 'Invalid avatar path' }, { status: 400 })
|
||||
}
|
||||
|
||||
const avatarsDir = path.resolve(process.cwd(), 'data', 'avatars')
|
||||
const filePath = path.resolve(avatarsDir, ...safeSegments)
|
||||
|
||||
if (!isPathInsideBase(avatarsDir, filePath)) {
|
||||
return NextResponse.json({ error: 'Invalid avatar path' }, { status: 400 })
|
||||
}
|
||||
|
||||
const ext = path.extname(filePath).toLowerCase()
|
||||
|
||||
if (!ALLOWED_AVATAR_EXTENSIONS.has(ext)) {
|
||||
return NextResponse.json({ error: 'Unsupported file type' }, { status: 400 })
|
||||
}
|
||||
|
||||
try {
|
||||
const { path: pathSegments } = await Promise.resolve(params)
|
||||
const filePath = path.join(process.cwd(), 'data', 'avatars', ...(pathSegments || []))
|
||||
const file = await fs.readFile(filePath)
|
||||
const ext = path.extname(filePath).slice(1)
|
||||
const realAvatarsDir = await fs.realpath(avatarsDir)
|
||||
const fileStats = await fs.lstat(filePath)
|
||||
|
||||
if (fileStats.isSymbolicLink()) {
|
||||
return NextResponse.json({ error: 'Invalid avatar path' }, { status: 400 })
|
||||
}
|
||||
|
||||
const realFilePath = await fs.realpath(filePath)
|
||||
|
||||
if (!isPathInsideBase(realAvatarsDir, realFilePath)) {
|
||||
return NextResponse.json({ error: 'Invalid avatar path' }, { status: 400 })
|
||||
}
|
||||
|
||||
const file = await fs.readFile(realFilePath)
|
||||
|
||||
return new NextResponse(file, {
|
||||
headers: {
|
||||
'Content-Type': `image/${ext}`,
|
||||
'Content-Type': AVATAR_CONTENT_TYPE[ext] ?? 'application/octet-stream',
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
if (getErrorCode(error) === 'ENOENT') {
|
||||
return NextResponse.json(
|
||||
{ error: 'File not found' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
console.error('Error reading avatar file:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'File not found' },
|
||||
{ status: 404 }
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user