Bläddra i källkod

修复远程升级权限误跳登录

LSamien 2 månader sedan
förälder
incheckning
ca5d084d68

+ 1 - 2
src/client/api/client.ts

@@ -107,8 +107,7 @@ export async function apiRequest<T>(path: string, options: ApiRequestOptions = {
     if (!response.ok) {
       const record = payload && typeof payload === 'object' ? payload as Record<string, unknown> : undefined
       const code = typeof record?.code === 'string' ? record.code : undefined
-      if ((response.status === 401 || response.status === 403)
-        && code !== 'invalid_csrf'
+      if (response.status === 401
         && options.notifyUnauthorized !== false) {
         for (const listener of unauthorizedListeners) listener()
       }

+ 35 - 6
src/server/routes.ts

@@ -26,7 +26,7 @@ import {
   UpstreamClient,
 } from './upstream.js'
 import { receiveGroupUploads, uploadMarkdown, UploadStore } from './uploads.js'
-import { SystemUpdateManager } from './updateManager.js'
+import { SystemUpdateManager, type SystemUpdateStatus } from './updateManager.js'
 
 type JsonObject = Record<string, unknown>
 
@@ -213,10 +213,31 @@ async function requireYaoyaoStorageReady(
   }
 }
 
-function requireLocalSystemUpdate(ctx: Koa.Context, dependencies: RouteDependencies): void {
-  if (dependencies.config.allowRemoteUpdate) return
+export function systemUpdateRequestAllowed(address: string, allowRemoteUpdate = false): boolean {
+  if (allowRemoteUpdate) return true
+  return isLoopbackHost(address.replace(/^::ffff:/, ''))
+}
+
+function localSystemUpdateAllowed(ctx: Koa.Context, dependencies: RouteDependencies): boolean {
   const address = (ctx.req.socket.remoteAddress ?? '').replace(/^::ffff:/, '')
-  if (!isLoopbackHost(address)) {
+  return systemUpdateRequestAllowed(address, dependencies.config.allowRemoteUpdate)
+}
+
+function systemUpdateStatusForRequest(
+  ctx: Koa.Context,
+  dependencies: RouteDependencies,
+  status: SystemUpdateStatus,
+): SystemUpdateStatus {
+  if (!status.supported || localSystemUpdateAllowed(ctx, dependencies)) return status
+  return {
+    ...status,
+    supported: false,
+    unsupportedReason: '系统升级默认只允许在本机执行;可通过服务配置显式允许远程升级',
+  }
+}
+
+function requireLocalSystemUpdate(ctx: Koa.Context, dependencies: RouteDependencies): void {
+  if (!localSystemUpdateAllowed(ctx, dependencies)) {
     throw new HttpError(
       403,
       '系统升级默认只允许在本机执行',
@@ -925,7 +946,11 @@ export function createApiRouter(dependencies: RouteDependencies): Router {
     await withJar(ctx, async (jar) => {
       await requireGatewayAuthentication(ctx, dependencies, jar)
       const installedPluginVersion = await yaoyaoPluginVersion(dependencies, jar)
-      json(ctx, 200, dependencies.updates.status(installedPluginVersion))
+      json(ctx, 200, systemUpdateStatusForRequest(
+        ctx,
+        dependencies,
+        dependencies.updates.status(installedPluginVersion),
+      ))
     })
   })
   router.post('/api/app/system/update/check', async (ctx) => {
@@ -933,7 +958,11 @@ export function createApiRouter(dependencies: RouteDependencies): Router {
       await requireGatewayAuthentication(ctx, dependencies, jar)
       const installedPluginVersion = await yaoyaoPluginVersion(dependencies, jar)
       try {
-        json(ctx, 200, await dependencies.updates.check(installedPluginVersion))
+        json(ctx, 200, systemUpdateStatusForRequest(
+          ctx,
+          dependencies,
+          await dependencies.updates.check(installedPluginVersion),
+        ))
       } catch (error) {
         throw updateFailure(error)
       }

+ 29 - 1
tests/client/authProtocol.test.ts

@@ -1,6 +1,11 @@
 import { afterEach, describe, expect, it, vi } from 'vitest'
 import { bootstrap, login } from '@/api/auth'
-import { clearApiSecurityContext } from '@/api/client'
+import {
+  apiRequest,
+  clearApiSecurityContext,
+  onApiUnauthorized,
+  setApiCsrfToken,
+} from '@/api/client'
 
 afterEach(() => {
   clearApiSecurityContext()
@@ -61,4 +66,27 @@ describe('authentication and CSRF protocol', () => {
     await expect(login({ username: 'name', password: 'wrong' })).rejects.toMatchObject({ status: 401 })
     await expect(login({ username: 'name', password: 'correct' })).resolves.toMatchObject({ user: { id: 'user-1' } })
   })
+
+  it('keeps the login session for an authenticated 403 permission error', async () => {
+    let expirations = 0
+    const unsubscribe = onApiUnauthorized(() => { expirations += 1 })
+    setApiCsrfToken('csrf-permission')
+    vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({
+      message: '系统升级默认只允许在本机执行',
+      code: 'remote_system_update_disabled',
+    }), { status: 403, headers: { 'content-type': 'application/json' } })))
+    try {
+      await expect(apiRequest('/api/app/system/update/apply', {
+        method: 'POST',
+        body: { targetVersion: '0.3.0' },
+      })).rejects.toMatchObject({
+        status: 403,
+        code: 'remote_system_update_disabled',
+        message: '系统升级默认只允许在本机执行',
+      })
+      expect(expirations).toBe(0)
+    } finally {
+      unsubscribe()
+    }
+  })
 })

+ 8 - 0
tests/server/systemUpdateRoutes.test.ts

@@ -5,6 +5,7 @@ import request from 'supertest'
 import { afterEach, describe, expect, it } from 'vitest'
 import { createApplication, type ApplicationRuntime } from '../../src/server/app.js'
 import type { ServerConfig } from '../../src/server/config.js'
+import { systemUpdateRequestAllowed } from '../../src/server/routes.js'
 import { SystemUpdateManager } from '../../src/server/updateManager.js'
 
 const runtimes: ApplicationRuntime[] = []
@@ -27,6 +28,13 @@ function json(value: unknown, init: ResponseInit = {}) {
 }
 
 describe('system update routes', () => {
+  it('allows loopback updates and requires an explicit opt-in for LAN clients', () => {
+    expect(systemUpdateRequestAllowed('127.0.0.1')).toBe(true)
+    expect(systemUpdateRequestAllowed('::ffff:127.0.0.1')).toBe(true)
+    expect(systemUpdateRequestAllowed('192.168.1.8')).toBe(false)
+    expect(systemUpdateRequestAllowed('192.168.1.8', true)).toBe(true)
+  })
+
   it('checks and queues a fixed Web/plugin release through authenticated CSRF routes', async () => {
     const root = mkdtempSync(join(tmpdir(), 'hermes-system-update-routes-'))
     roots.push(root)