feat(security): harden gateway boundaries and usage policies

Consolidate subscription usage policy enforcement, privacy-safe persistence, and gateway security hardening into one reviewable change.

Includes bounded HTTP and execution envelopes, header and protocol guards, DNS and relay validation, authentication and secret projection hardening, secure backup/install paths, and regression coverage.
This commit is contained in:
elky
2026-09-04 03:45:52 +08:00
parent ddcbeb3ae9
commit 579f2c7cc1
1019 changed files with 190437 additions and 26080 deletions
+128 -43
View File
@@ -10,6 +10,16 @@ import { cache } from '@/utils/cache'
// 在开发环境下使用代理,生产环境使用环境变量
const API_BASE_URL = import.meta.env.VITE_API_URL || ''
export const AUTH_STATE_CHANGE_EVENT = 'aether-auth-state-change'
export const AUTH_SESSION_SIGNAL_KEY = 'aether_auth_session_signal'
export type AuthStateChangeDetail = {
authenticated: boolean
}
export type AuthSessionSignal = AuthStateChangeDetail & {
eventId: string
emittedAt: number
}
type MockRuntime = typeof import('@/mocks')
@@ -48,6 +58,14 @@ function isAuthRequest(url?: string): boolean {
return url?.includes('/auth/login') || url?.includes('/auth/refresh') || url?.includes('/auth/logout') || false
}
function isProtectedOperationalEndpoint(url?: string): boolean {
if (!url) return false
const path = url.split('?', 1)[0]
return path === '/_gateway/metrics' ||
path.startsWith('/_gateway/audit/') ||
path.startsWith('/_gateway/async-tasks/')
}
/**
* 判断 403 错误是否表示用户账号级别的问题(需要清除认证并跳转)
*/
@@ -100,17 +118,11 @@ function createDemoAdapter(defaultAdapter: AxiosAdapter) {
class ApiClient {
private client: AxiosInstance
private token: string | null = null
private authStateVersion = 0
private isRefreshing = false
private refreshPromise: Promise<string> | null = null
private readonly refreshCoordinator = new CrossTabRefreshCoordinator()
private readonly onStorageSync = (event: StorageEvent): void => {
if (event.key !== 'access_token') {
return
}
this.syncTokenState(event.newValue)
}
constructor() {
this.client = axios.create({
baseURL: API_BASE_URL,
@@ -126,7 +138,7 @@ class ApiClient {
this.client.defaults.adapter = createDemoAdapter(defaultAdapter)
this.setupInterceptors()
this.setupCrossTabAuthSync()
this.purgeLegacyStoredTokens()
}
/**
@@ -136,12 +148,15 @@ class ApiClient {
// 请求拦截器 - 仅处理认证
this.client.interceptors.request.use(
(config) => {
if (config.url?.includes('/api/')) {
const carriesSessionCredentials = config.url?.includes('/api/') ||
isProtectedOperationalEndpoint(config.url)
if (carriesSessionCredentials) {
config.headers['X-Client-Device-Id'] = getClientDeviceId()
}
const requiresAuth = !isPublicEndpoint(config.url, config.method) &&
config.url?.includes('/api/')
carriesSessionCredentials
if (requiresAuth) {
const token = this.getToken()
@@ -161,23 +176,51 @@ class ApiClient {
)
}
private setupCrossTabAuthSync(): void {
if (typeof window !== 'undefined') {
window.addEventListener('storage', this.onStorageSync)
}
}
private emitAuthStateChange(token: string | null): void {
private emitAuthStateChange(authenticated: boolean): void {
if (typeof window === 'undefined') {
return
}
window.dispatchEvent(
new CustomEvent<{ token: string | null }>(AUTH_STATE_CHANGE_EVENT, {
detail: { token },
new CustomEvent<AuthStateChangeDetail>(AUTH_STATE_CHANGE_EVENT, {
detail: { authenticated },
})
)
}
private publishAuthSessionSignal(authenticated: boolean): void {
if (typeof window === 'undefined') {
return
}
const signal: AuthSessionSignal = {
authenticated,
eventId: typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'
? crypto.randomUUID()
: `${Date.now()}-${Math.random().toString(36).slice(2)}`,
emittedAt: Date.now(),
}
try {
window.localStorage.setItem(AUTH_SESSION_SIGNAL_KEY, JSON.stringify(signal))
} catch {
// Cross-tab notification is best effort. The HttpOnly cookie remains the
// source of truth when another tab starts or makes its next request.
}
}
private purgeLegacyStoredTokens(): void {
if (typeof window === 'undefined') {
return
}
for (const storage of [window.localStorage, window.sessionStorage]) {
try {
storage.removeItem('access_token')
} catch {
// Storage may be disabled; the token still only lives in memory.
}
}
}
/**
* 处理响应错误
*/
@@ -274,23 +317,21 @@ class ApiClient {
originalRequest: InternalAxiosRequestConfig,
originalError: import('axios').AxiosError
): Promise<AxiosResponse> {
this.isRefreshing = true
this.refreshPromise = this.coordinatedRefresh()
try {
const accessToken = await this.refreshPromise
this.setToken(accessToken)
this.isRefreshing = false
this.refreshPromise = null
const accessToken = await this.restoreSession()
// 重试原始请求
originalRequest.headers.Authorization = `Bearer ${accessToken}`
return this.client.request(originalRequest)
} catch (refreshError: unknown) {
log.error('Token refresh failed', refreshError instanceof Error ? refreshError.message : String(refreshError))
this.isRefreshing = false
this.refreshPromise = null
this.clearAuth()
const status = axios.isAxiosError(refreshError) ? refreshError.response?.status : undefined
// Network errors and refresh-rotation conflicts do not prove that the
// current access token or another tab's newly rotated session is invalid.
// Only an authoritative refresh rejection signs the browser out.
if (status === 401 || status === 403) {
this.clearAuth()
}
return Promise.reject(originalError)
}
}
@@ -314,32 +355,59 @@ class ApiClient {
currentMockUserToken = token
}
setToken(token: string): void {
setToken(token: string, notifyOtherTabs = false): void {
this.purgeLegacyStoredTokens()
this.authStateVersion += 1
if (this.token === token) {
cache.clear()
}
this.syncTokenState(token)
localStorage.setItem('access_token', token)
this.emitAuthStateChange(true)
if (notifyOtherTabs) {
this.publishAuthSessionSignal(true)
}
}
getToken(): string | null {
if (!this.token) {
this.syncTokenState(localStorage.getItem('access_token'))
}
return this.token
}
clearAuth(): void {
const hadAuth = this.token !== null || localStorage.getItem('access_token') !== null
if (hadAuth && this.token === null) {
cache.clear()
}
clearAuth(notifyOtherTabs = true, emitLocalEvent = true): void {
const hadAuth = this.token !== null
this.authStateVersion += 1
this.syncTokenState(null)
localStorage.removeItem('access_token')
// 同标签页内清理认证状态时不会触发 storage 事件,这里主动广播一次。
if (hadAuth) {
this.emitAuthStateChange(null)
this.purgeLegacyStoredTokens()
if (emitLocalEvent && hadAuth) {
this.emitAuthStateChange(false)
}
if (notifyOtherTabs) {
this.publishAuthSessionSignal(false)
}
}
async restoreSession(notifyOtherTabs = false): Promise<string> {
if (this.refreshPromise) {
return this.refreshPromise
}
this.isRefreshing = true
const requestAuthStateVersion = this.authStateVersion
let restorePromise!: Promise<string>
restorePromise = (async () => {
const accessToken = await this.coordinatedRefresh()
if (requestAuthStateVersion !== this.authStateVersion) {
throw new Error('Auth state changed during session restore')
}
this.setToken(accessToken, notifyOtherTabs)
return accessToken
})().finally(() => {
if (this.refreshPromise === restorePromise) {
this.refreshPromise = null
this.isRefreshing = false
}
})
this.refreshPromise = restorePromise
return restorePromise
}
async refreshToken(): Promise<AxiosResponse> {
@@ -372,4 +440,21 @@ class ApiClient {
}
}
export function parseAuthSessionSignal(raw: string | null): AuthSessionSignal | null {
if (!raw) return null
try {
const signal = JSON.parse(raw) as Partial<AuthSessionSignal>
if (
typeof signal.authenticated !== 'boolean' ||
typeof signal.eventId !== 'string' ||
typeof signal.emittedAt !== 'number'
) {
return null
}
return signal as AuthSessionSignal
} catch {
return null
}
}
export default new ApiClient()