fix(web): 完善本地后端代理与路径校验

This commit is contained in:
tbphp committed 2026-07-31 01:00:27 +08:00
1 parent d427a307ba
commit 143ae27bd7
2 files changed
+78 -30

No files matched your search

+70 -24
View File
@@ -46,44 +46,90 @@ export interface ApiClientWithResponse extends ApiClient {
type Envelope = SuccessEnvelope<unknown> | ErrorEnvelope
const apiPathBase = 'https://gpt-load.invalid'
function isSafeApiPath(path: unknown): path is ApiPath {
if (typeof path !== 'string' || !path.startsWith('/api/')) {
return false
function hasControlCharacter(value: string): boolean {
for (const character of value) {
const codePoint = character.codePointAt(0)
if (codePoint !== undefined && (codePoint <= 0x1f || codePoint === 0x7f)) {
return true
}
}
try {
decodeURIComponent(path)
} catch {
return false
}
return false
}
const rawPathname = path.split(/[?#]/, 1)[0]
if (!rawPathname) {
return false
}
for (const segment of rawPathname.split('/')) {
let decodedSegment: string
try {
decodedSegment = decodeURIComponent(segment)
} catch {
return false
function hasEncodedControlCharacter(value: string): boolean {
let encodedValue = value
for (let depth = 0; depth <= value.length; depth += 1) {
if (/%(?:0[0-9a-f]|1[0-9a-f]|7f)/i.test(encodedValue)) {
return true
}
const decodedPercents = encodedValue.replace(/%25/gi, '%')
if (decodedPercents === encodedValue) {
return false
}
encodedValue = decodedPercents
}
return true
}
function isSafeApiPathSegment(segment: string): boolean {
let decodedSegment = segment
for (let depth = 0; depth <= segment.length; depth += 1) {
if (
decodedSegment.length === 0 ||
decodedSegment === '.' ||
decodedSegment === '..' ||
decodedSegment.includes('/') ||
decodedSegment.includes('\\')
decodedSegment.includes('\\') ||
decodedSegment.includes('?') ||
decodedSegment.includes('#') ||
hasControlCharacter(decodedSegment)
) {
return false
}
if (!decodedSegment.includes('%')) {
return true
}
try {
decodedSegment = decodeURIComponent(decodedSegment)
} catch {
return false
}
}
const target = new URL(path, apiPathBase)
return target.origin === apiPathBase && target.pathname.startsWith('/api/')
return false
}
function isSafeApiPath(path: unknown): path is ApiPath {
if (
typeof path !== 'string' ||
!path.startsWith('/api/') ||
path.startsWith('//') ||
path.includes('\\') ||
path.includes('#') ||
hasControlCharacter(path)
) {
return false
}
const queryStart = path.indexOf('?')
const pathname = queryStart === -1 ? path : path.slice(0, queryStart)
const query = queryStart === -1 ? '' : path.slice(queryStart + 1)
try {
decodeURIComponent(query)
} catch {
return false
}
if (hasEncodedControlCharacter(query)) {
return false
}
const segments = pathname.slice('/api/'.length).split('/')
return segments.every(isSafeApiPathSegment)
}
function isRecord(value: unknown): value is Record<string, unknown> {
+8 -6
View File
@@ -10,6 +10,8 @@ export const pageRouteManifestPath = fileURLToPath(
)
export const devServerFileSystemAllow = [webRootPath, pageRouteManifestPath]
const proxyTarget = process.env.VITE_DEV_PROXY_TARGET || 'http://127.0.0.1:3001'
export default defineConfig({
root: webRootPath,
plugins: [vue(), tailwindcss()],
@@ -22,12 +24,12 @@ export default defineConfig({
fs: {
allow: devServerFileSystemAllow,
},
proxy: {
'/api': {
target: 'http://127.0.0.1:3001',
changeOrigin: true,
},
},
proxy: Object.fromEntries(
['/api', '/health', '/v1', '/v1beta'].map((path) => [
path,
{ target: proxyTarget, changeOrigin: true },
]),
),
},
build: {
outDir: '../internal/webui/dist',