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
@@ -46,6 +46,21 @@ describe('authApi turnstile payloads', () => {
})
})
it('binds verification and status requests to the verification session token', async () => {
await authApi.verifyEmail('[email protected]', '123456', 'verification-session-token')
await authApi.getVerificationStatus('[email protected]', 'verification-session-token')
expect(postMock).toHaveBeenNthCalledWith(1, '/api/auth/verify-email', {
email: '[email protected]',
code: '123456',
verification_token: 'verification-session-token',
})
expect(postMock).toHaveBeenNthCalledWith(2, '/api/auth/verification-status', {
email: '[email protected]',
verification_token: 'verification-session-token',
})
})
it('refreshes auth token without a request body', async () => {
postMock.mockResolvedValue({ data: { access_token: 'new-access-token' } })
@@ -54,4 +69,12 @@ describe('authApi turnstile payloads', () => {
expect(postMock).toHaveBeenCalledWith('/api/auth/refresh')
expect(setTokenMock).toHaveBeenCalledWith('new-access-token')
})
it('publishes login session availability without changing the token payload', async () => {
postMock.mockResolvedValue({ data: { access_token: 'login-access-token' } })
await authApi.login({ email: '[email protected]', password: 'secret123' })
expect(setTokenMock).toHaveBeenCalledWith('login-access-token', true)
})
})
+101 -4
View File
@@ -1,7 +1,11 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { AxiosAdapter, AxiosInstance, InternalAxiosRequestConfig } from 'axios'
import apiClient, { AUTH_STATE_CHANGE_EVENT } from '@/api/client'
import apiClient, {
AUTH_SESSION_SIGNAL_KEY,
AUTH_STATE_CHANGE_EVENT,
parseAuthSessionSignal,
} from '@/api/client'
import { cache, cachedRequest } from '@/utils/cache'
type TestableApiClient = typeof apiClient & {
@@ -24,17 +28,82 @@ describe('apiClient auth state change event', () => {
window.addEventListener(AUTH_STATE_CHANGE_EVENT, handler as EventListener)
apiClient.setToken('access-token')
localStorage.setItem('access_token', 'legacy-local-token')
sessionStorage.setItem('access_token', 'legacy-session-token')
apiClient.clearAuth()
expect(localStorage.getItem('access_token')).toBeNull()
expect(handler).toHaveBeenCalledTimes(1)
expect(sessionStorage.getItem('access_token')).toBeNull()
expect(handler).toHaveBeenCalledTimes(2)
const event = handler.mock.calls[0][0] as CustomEvent<{ token: string | null }>
expect(event.detail).toEqual({ token: null })
const event = handler.mock.calls[1][0] as CustomEvent<{ authenticated: boolean }>
expect(event.detail).toEqual({ authenticated: false })
window.removeEventListener(AUTH_STATE_CHANGE_EVENT, handler as EventListener)
})
it('keeps access tokens in memory and only stores token-free session metadata', () => {
apiClient.setToken('sensitive-access-token', true)
expect(apiClient.getToken()).toBe('sensitive-access-token')
expect(localStorage.getItem('access_token')).toBeNull()
expect(sessionStorage.getItem('access_token')).toBeNull()
const rawSignal = localStorage.getItem(AUTH_SESSION_SIGNAL_KEY)
expect(rawSignal).not.toContain('sensitive-access-token')
expect(parseAuthSessionSignal(rawSignal)).toMatchObject({ authenticated: true })
})
it('restores a session through the refresh cookie and stores the result in memory only', async () => {
const rawClient = apiClient as TestableApiClient
const previousAdapter = rawClient.client.defaults.adapter
rawClient.client.defaults.adapter = (async (config: InternalAxiosRequestConfig) => ({
data: { access_token: 'restored-access-token' },
status: 200,
statusText: 'OK',
headers: {},
config,
})) as AxiosAdapter
try {
await expect(apiClient.restoreSession()).resolves.toBe('restored-access-token')
expect(apiClient.getToken()).toBe('restored-access-token')
expect(localStorage.getItem('access_token')).toBeNull()
expect(sessionStorage.getItem('access_token')).toBeNull()
} finally {
rawClient.client.defaults.adapter = previousAdapter
}
})
it('does not resurrect a session when logout wins an in-flight restore', async () => {
const rawClient = apiClient as TestableApiClient
const previousAdapter = rawClient.client.defaults.adapter
let resolveRefresh!: (response: Awaited<ReturnType<AxiosAdapter>>) => void
rawClient.client.defaults.adapter = (() => new Promise((resolve) => {
resolveRefresh = resolve
})) as AxiosAdapter
try {
const restore = apiClient.restoreSession()
await vi.waitFor(() => expect(resolveRefresh).toBeTypeOf('function'))
apiClient.clearAuth()
resolveRefresh({
data: { access_token: 'stale-access-token' },
status: 200,
statusText: 'OK',
headers: {},
config: {} as InternalAxiosRequestConfig,
})
await expect(restore).rejects.toThrow('Auth state changed')
expect(apiClient.getToken()).toBeNull()
} finally {
rawClient.client.defaults.adapter = previousAdapter
}
})
it('clears cached API data whenever the authentication identity changes', () => {
apiClient.setToken('first-token')
cache.set('dashboard', { owner: 'first-user' }, 30_000)
@@ -99,4 +168,32 @@ describe('apiClient auth state change event', () => {
rawClient.client.defaults.adapter = previousAdapter
}
})
it('authenticates protected gateway operational requests', async () => {
const rawClient = apiClient as TestableApiClient
const previousAdapter = rawClient.client.defaults.adapter
const requests: InternalAxiosRequestConfig[] = []
rawClient.client.defaults.adapter = (async (config: InternalAxiosRequestConfig) => {
requests.push(config)
return {
data: '',
status: 200,
statusText: 'OK',
headers: {},
config,
}
}) as AxiosAdapter
try {
apiClient.setToken('operational-access-token')
await apiClient.get('/_gateway/metrics')
expect(requests).toHaveLength(1)
expect(requests[0].headers.Authorization).toBe('Bearer operational-access-token')
expect(requests[0].headers['X-Client-Device-Id']).toBeTruthy()
} finally {
rawClient.client.defaults.adapter = previousAdapter
}
})
})
+16 -1
View File
@@ -11,8 +11,23 @@ export interface PaymentCallbackRecord {
payload_hash: string | null
signature_valid: boolean
status: string
payload: Record<string, unknown> | null
payload: null
has_payload: boolean
payload_summary: {
kind: 'null' | 'boolean' | 'number' | 'string' | 'array' | 'object'
serialized_bytes: number
objects: number
arrays: number
strings: number
numbers: number
booleans: number
nulls: number
object_fields: number
array_items: number
max_depth: number
} | null
error_message: string | null
has_error_message: boolean
created_at: string
processed_at: string | null
}
+4 -2
View File
@@ -145,7 +145,7 @@ export interface UserExport {
email: string
email_verified?: boolean
username: string
password_hash: string
password_hash?: string | null
role: string
allowed_providers?: string[] | null
allowed_providers_mode?: 'inherit' | 'unrestricted' | 'specific' | 'deny_all'
@@ -169,9 +169,11 @@ export interface UserExport {
export interface UserApiKeyExport {
api_key_id?: string
// Legacy 1.3-1.5 import-only credential fields. Version 1.6 exports omit them.
key?: string | null
key_hash: string
key_hash?: string | null
key_encrypted?: string | null
credential_state?: 'not_exported'
name?: string | null
is_standalone: boolean
allowed_providers?: string[] | null
+8
View File
@@ -204,6 +204,14 @@ export const asyncTasksApi = {
return response.data
},
async getVideoBlob(taskId: string): Promise<Blob> {
const response = await apiClient.get<Blob>(
`/api/admin/video-tasks/${encodeURIComponent(taskId)}/video`,
{ responseType: 'blob', timeout: 0 },
)
return response.data
},
async trigger(taskKey: string, payload: Record<string, unknown> = {}): Promise<{ run_id: string; status: string }> {
const response = await apiClient.post(`/api/admin/tasks/${taskKey}/trigger`, payload)
return response.data
+16 -5
View File
@@ -40,11 +40,13 @@ export interface SendVerificationCodeResponse {
message: string
success: boolean
expire_minutes?: number
verification_token: string
}
export interface VerifyEmailRequest {
email: string
code: string
verification_token: string
}
export interface VerifyEmailResponse {
@@ -54,6 +56,7 @@ export interface VerifyEmailResponse {
export interface VerificationStatusRequest {
email: string
verification_token: string
}
export interface VerificationStatusResponse {
@@ -72,6 +75,7 @@ export interface RegisterRequest {
invite_code?: string
privacy_policy_accepted?: boolean
privacy_policy_version?: string
email_verification_token?: string
}
export interface RegisterResponse {
@@ -141,7 +145,7 @@ export interface User {
export const authApi = {
async login(credentials: LoginRequest): Promise<LoginResponse> {
const response = await apiClient.post<LoginResponse>('/api/auth/login', credentials)
apiClient.setToken(response.data.access_token)
apiClient.setToken(response.data.access_token, true)
return response.data
},
@@ -184,10 +188,14 @@ export const authApi = {
return response.data
},
async verifyEmail(email: string, code: string): Promise<VerifyEmailResponse> {
async verifyEmail(
email: string,
code: string,
verificationToken: string
): Promise<VerifyEmailResponse> {
const response = await apiClient.post<VerifyEmailResponse>(
'/api/auth/verify-email',
{ email, code }
{ email, code, verification_token: verificationToken }
)
return response.data
},
@@ -204,10 +212,13 @@ export const authApi = {
return response.data
},
async getVerificationStatus(email: string): Promise<VerificationStatusResponse> {
async getVerificationStatus(
email: string,
verificationToken: string
): Promise<VerificationStatusResponse> {
const response = await apiClient.post<VerificationStatusResponse>(
'/api/auth/verification-status',
{ email }
{ email, verification_token: verificationToken }
)
return response.data
},
+30
View File
@@ -55,12 +55,14 @@ export interface GatewayTestResponse {
export interface WalletCreditEntitlement {
type: 'wallet_credit'
replacement_group?: string
amount_usd: number
balance_bucket?: WalletCreditBucket
}
export interface DailyQuotaEntitlement {
type: 'daily_quota'
replacement_group?: string
daily_quota_usd: number
reset_timezone?: string
carry_over?: boolean
@@ -69,13 +71,41 @@ export interface DailyQuotaEntitlement {
export interface MembershipGroupEntitlement {
type: 'membership_group'
replacement_group?: string
grant_user_groups: string[]
}
export type UsagePolicyMetric = 'request_count' | 'concurrency' | 'actual_cost_usd'
export type UsagePolicyEnforcement = 'hard_cap'
export type UsagePolicyWindow =
| { kind: 'rolling'; seconds: number }
| { kind: 'calendar_day'; timezone?: string }
| { kind: 'calendar_week'; timezone?: string; week_start?: number }
| { kind: 'calendar_month'; timezone?: string }
| { kind: 'subscription_period' }
| { kind: 'concurrent' }
export interface UsagePolicyRule {
metric: UsagePolicyMetric
window: UsagePolicyWindow
limit: number
enforcement?: UsagePolicyEnforcement
}
export interface UsagePolicyEntitlement {
type: 'usage_policy'
policy_id?: string
name?: string
replacement_group?: string
rules: UsagePolicyRule[]
}
export type BillingEntitlement =
| WalletCreditEntitlement
| DailyQuotaEntitlement
| MembershipGroupEntitlement
| UsagePolicyEntitlement
export interface BillingPlan {
id: string
+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()
@@ -12,6 +12,7 @@ export interface ProxyConfig {
password?: string
node_id?: string // 代理节点 ID(aether-tunnel 注册的节点,与 url 互斥)
enabled?: boolean // 是否启用代理(false 时保留配置但不使用)
has_credentials?: boolean // 管理端脱敏响应:代理包含未返回的认证信息
}
export interface OAuthOrganizationInfo {
@@ -31,6 +32,7 @@ export interface HeaderRuleSet {
action: 'set'
key: string
value: string
has_value?: boolean // value="***" 时表示保留服务端已有值
}
export interface HeaderRuleDrop {
@@ -60,6 +62,7 @@ export interface BodyRuleSet {
action: 'set'
path: string
value: unknown
has_value?: boolean // value="***" 时表示保留服务端已有值
}
/**
@@ -95,6 +98,7 @@ export interface BodyRuleAppend {
action: 'append'
path: string
value: unknown
has_value?: boolean
}
/**
@@ -109,6 +113,7 @@ export interface BodyRuleInsert {
path: string
index: number
value: unknown
has_value?: boolean
}
/**
@@ -125,6 +130,8 @@ export interface BodyRuleRegexReplace {
path: string
pattern: string
replacement: string
has_pattern?: boolean
has_replacement?: boolean
flags?: string
count?: number
}
@@ -140,6 +147,7 @@ export interface BodyRuleConditionLeaf {
path: string
op: BodyRuleConditionOp
value?: unknown // exists / not_exists 不需要 value
has_value?: boolean // value="***" 时表示保留服务端已有条件值
source?: 'body' | 'current' | 'original' | 'request_headers' | 'headers'
}
+1
View File
@@ -234,6 +234,7 @@ export const meApi = {
// 更新个人信息
async updateProfile(data: {
email?: string
email_verification_token?: string
username?: string
feature_settings?: FeatureSettingsMap | null
}): Promise<{ message: string }> {
+3 -3
View File
@@ -99,9 +99,9 @@ export const oauthApi = {
return response.data.links || []
},
async createBindToken(providerType: string): Promise<string> {
const response = await apiClient.post<{ bind_token: string }>(`/api/user/oauth/${providerType}/bind-token`)
return response.data.bind_token
async createBindAuthorization(providerType: string): Promise<string> {
const response = await apiClient.post<{ authorize_url: string }>(`/api/user/oauth/${providerType}/bind-token`)
return response.data.authorize_url
},
async unbind(providerType: string): Promise<{ message: string }> {
+2 -2
View File
@@ -22,10 +22,10 @@ export interface ProxyNode {
tunnel_mode: boolean
tunnel_connected: boolean
tunnel_connected_at: string | null
// 手动节点专用字段。列表接口返回脱敏密码,详情接口返回明文密码。
// 手动节点专用字段。密码永不通过节点接口返回,仅提供是否已配置的状态。
proxy_url?: string
proxy_username?: string
proxy_password?: string
has_proxy_password?: boolean
// 硬件信息(aether-tunnel 节点)
hardware_info: Record<string, unknown> | null
estimated_max_concurrency: number | null
+3 -4
View File
@@ -124,6 +124,7 @@ export interface PaymentOrder {
fulfillment_error?: string | null
gateway_order_id: string | null
gateway_response: Record<string, unknown> | null
has_gateway_response?: boolean
status: string
created_at: string
paid_at: string | null
@@ -145,7 +146,7 @@ export interface RefundRequest {
gateway_refund_id: string | null
payout_method: string | null
payout_reference: string | null
payout_proof: Record<string, unknown> | null
payout_proof?: Record<string, unknown> | null
created_at: string
updated_at: string
processed_at: string | null
@@ -160,6 +161,7 @@ export interface WalletRechargeCreateRequest {
pay_amount?: number
pay_currency?: string
exchange_rate?: number
idempotency_key?: string
}
export interface WalletRechargeOption {
@@ -177,9 +179,6 @@ export interface WalletRechargeOption {
export interface WalletRefundCreateRequest {
amount_usd: number
payment_order_id?: string
source_type?: string
source_id?: string
refund_mode?: string
reason?: string
idempotency_key?: string
}