完善插件独立密钥保存权限与加密回归
Some checks are pending
Security Checks / security (push) Waiting to run

This commit is contained in:
smail 2026-09-20 08:12:06 +08:00
parent 05b1441d69
commit 54396abeb7
5 changed files with 222 additions and 27 deletions

View File

@ -0,0 +1,124 @@
package controllers
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"git.pcux.cn/xuans/Smail/hooks"
"git.pcux.cn/xuans/Smail/models"
"git.pcux.cn/xuans/Smail/services/access"
appcontext "git.pcux.cn/xuans/Smail/utils/context"
)
func callPluginSecret(ctx *appcontext.Context, method, pluginID, body string) *httptest.ResponseRecorder {
recorder := httptest.NewRecorder()
PluginSecret(ctx, recorder, httptest.NewRequest(method, "/api/v1/plugin/secrets/"+pluginID, strings.NewReader(body)))
return recorder
}
func TestPluginSecretAuthorization(t *testing.T) {
service, engine := newTelegramControllerConfigService(t)
previous := hooks.PluginConfigService()
hooks.SetPluginConfigService(service)
t.Cleanup(func() { hooks.SetPluginConfigService(previous) })
for _, tc := range []struct {
name string
ctx *appcontext.Context
code string
}{
{"anonymous", nil, "ACCESS_DENIED"},
{"no session user", &appcontext.Context{Role: access.RoleSuperAdmin, RecentAuth: true, RecoveryCodeConfigured: true}, "ACCESS_DENIED"},
{"ordinary user", &appcontext.Context{UserID: 3, Role: access.RoleUser, RecentAuth: true, RecoveryCodeConfigured: true}, "ACCESS_DENIED"},
{"missing recent authentication", &appcontext.Context{UserID: 2, Role: access.RoleAdmin, RecoveryCodeConfigured: true}, "RECENT_AUTH_REQUIRED"},
{"missing recovery code", &appcontext.Context{UserID: 2, Role: access.RoleAdmin, RecentAuth: true}, "RECOVERY_CODE_REQUIRED"},
} {
t.Run(tc.name, func(t *testing.T) {
recorder := callPluginSecret(tc.ctx, http.MethodPut, "telegram_push", `{"version":0,"change":{"operation":"replace","value":{"plaintext":"test-only-secret"}}}`)
if recorder.Code != http.StatusForbidden || !strings.Contains(recorder.Body.String(), tc.code) {
t.Fatalf("status=%d response=%s", recorder.Code, recorder.Body.String())
}
})
}
count, err := engine.Count(new(models.PluginSecret))
if err != nil || count != 0 {
t.Fatalf("denied writes changed secrets: count=%d err=%v", count, err)
}
for _, role := range []access.Role{access.RoleAdmin, access.RoleSuperAdmin} {
recorder := callPluginSecret(&appcontext.Context{UserID: 2, Role: role}, http.MethodGet, "telegram_push", "")
if recorder.Code != http.StatusOK || !strings.Contains(recorder.Body.String(), `"configured":false`) {
t.Fatalf("read role=%s status=%d body=%s", role, recorder.Code, recorder.Body.String())
}
}
recorder := callPluginSecret(&appcontext.Context{UserID: 3, Role: access.RoleUser}, http.MethodGet, "telegram_push", "")
if recorder.Code != http.StatusForbidden {
t.Fatalf("ordinary user read status=%d", recorder.Code)
}
}
func TestPluginSecretEncryptedLifecycleAndValidation(t *testing.T) {
service, engine := newTelegramControllerConfigService(t)
previous := hooks.PluginConfigService()
hooks.SetPluginConfigService(service)
t.Cleanup(func() { hooks.SetPluginConfigService(previous) })
ctx := &appcontext.Context{UserID: 2, Role: access.RoleAdmin, RecentAuth: true, RecoveryCodeConfigured: true}
for _, id := range []string{"telegram_push", "wechat_push"} {
t.Run(id, func(t *testing.T) {
for _, body := range []string{
`{"version":0,"change":{"operation":"replace","value":{"plaintext":"test-only-secret"}},"userId":99}`,
`{"version":0,"change":{"operation":"replace","value":{"plaintext":"test-only-secret","extra":true}}}`,
`{"version":0,"change":{"operation":"replace","value":{"plaintext":"test-only-secret"}}} {}`,
`{"version":0,"change":{"operation":"replace","value":{"plaintext":""}}}`,
`{"version":0,"change":{"operation":"replace","value":{"plaintext":"` + strings.Repeat("x", 17000) + `"}}}`,
} {
recorder := callPluginSecret(ctx, http.MethodPut, id, body)
if strings.Contains(recorder.Body.String(), `"errorNo":0`) {
t.Fatal("invalid request accepted")
}
}
body := `{"version":0,"change":{"operation":"replace","value":{"plaintext":"test-only-secret"}}}`
recorder := callPluginSecret(ctx, http.MethodPut, id, body)
if recorder.Code != http.StatusOK || !strings.Contains(recorder.Body.String(), `"version":1`) {
t.Fatalf("save status=%d body=%s", recorder.Code, recorder.Body.String())
}
if recorder.Header().Get("Cache-Control") != "no-store" || strings.Contains(recorder.Body.String(), "test-only-secret") {
t.Fatal("unsafe secret response")
}
record := models.PluginSecret{}
found, err := engine.Where("plugin_id = ?", id).Get(&record)
if err != nil || !found || record.Ciphertext == "" || record.Ciphertext == "test-only-secret" || record.KeyVersion == 0 {
t.Fatalf("secret was not encrypted: found=%v err=%v", found, err)
}
recorder = callPluginSecret(ctx, http.MethodGet, id, "")
var payload struct {
Data map[string]any `json:"data"`
}
if err := json.Unmarshal(recorder.Body.Bytes(), &payload); err != nil || len(payload.Data) != 4 || payload.Data["configured"] != true || payload.Data["version"] != float64(1) {
t.Fatalf("unexpected public status: %s", recorder.Body.String())
}
if strings.Contains(recorder.Body.String(), record.Ciphertext) || strings.Contains(recorder.Body.String(), "test-only-secret") {
t.Fatal("secret leaked in status")
}
recorder = callPluginSecret(ctx, http.MethodPut, id, body)
if recorder.Code != http.StatusConflict {
t.Fatalf("stale version accepted: %s", recorder.Body.String())
}
recorder = callPluginSecret(ctx, http.MethodPut, id, `{"version":1,"change":{"operation":"clear"}}`)
if strings.Contains(recorder.Body.String(), `"errorNo":0`) {
t.Fatal("unconfirmed clear accepted")
}
recorder = callPluginSecret(ctx, http.MethodPut, id, `{"version":1,"change":{"operation":"clear","confirmed":true}}`)
if recorder.Code != http.StatusOK || !strings.Contains(recorder.Body.String(), `"configured":false`) {
t.Fatalf("clear failed: %s", recorder.Body.String())
}
})
}
if recorder := callPluginSecret(ctx, http.MethodDelete, "telegram_push", ""); recorder.Code != http.StatusMethodNotAllowed {
t.Fatalf("unsupported method status=%d", recorder.Code)
}
if recorder := callPluginSecret(ctx, http.MethodPut, "unknown", `{}`); recorder.Code != http.StatusNotFound {
t.Fatalf("unknown plugin status=%d", recorder.Code)
}
}

View File

@ -76,6 +76,8 @@ var loggedOperations = map[string]string{
"/api/setup": "执行系统初始化配置",
"/api/plugin/settings/telegram_push/user": "更新电报通知设置",
"/api/plugin/settings/telegram_push/test": "测试电报消息推送",
"/api/plugin/secrets/telegram_push": "更新电报插件密钥",
"/api/plugin/secrets/wechat_push": "更新微信插件密钥",
}
// readonlyPostEndpoints 明确排除属于查询性质的 POST 接口,防止误记操作记录。

View File

@ -699,7 +699,7 @@ export function usePage(pageKey = 'base') {
}
const savePluginSecret = async () => {
if (!canEdit() || !pluginSecretPage.value || !pluginSecretStatus.value || !secretValue.value) return false
if (saveFlow.value || !canEdit() || !pluginSecretPage.value || !pluginSecretStatus.value || !secretValue.value) return false
working.value = 'plugin-secret'
saveError.value = ''
try {
@ -707,6 +707,9 @@ export function usePage(pageKey = 'base') {
const response = await requestWithRecentAuthentication(() => http.put(`/api/v1/plugin/secrets/${selectedSection.value}`, request))
notifyResponseError(response, '插件密钥保存失败')
if (disposed) return false
if (typeof response.data?.configured !== 'boolean' || !Number.isSafeInteger(response.data.version) || response.data.version < 0) {
throw new Error('插件密钥保存结果不完整,请刷新后核对')
}
secretValue.value = ''
pluginSecretStatus.value = response.data
ElMessage.success('密钥已保存,下次启动插件时生效')

View File

@ -53,15 +53,19 @@ func TestOperationLogUsesCompletedIdentityWithoutSensitivePayload(t *testing.T)
}
runtime := logstore.Start(store)
defer runtime.Stop()
for _, path := range []string{"/api/v1/login", "/api/v1/security/login/verify", "/api/v1/security/webauthn/login/finish"} {
for _, path := range []string{"/api/v1/login", "/api/v1/security/login/verify", "/api/v1/security/webauthn/login/finish", "/api/v1/plugin/secrets/telegram_push", "/api/v1/plugin/secrets/wechat_push"} {
ctx := &appcontext.Context{}
writer, finish := operationLog(ctx, httptest.NewRequest("POST", path, nil), httptest.NewRecorder())
method := http.MethodPost
if strings.Contains(path, "/plugin/secrets/") {
method = http.MethodPut
}
writer, finish := operationLog(ctx, httptest.NewRequest(method, path, strings.NewReader(`{"plaintext":"must-not-store"}`)), httptest.NewRecorder())
ctx.UserID, ctx.UserAccount, ctx.Role = 42, "audit-probe", access.RoleSuperAdmin
response.NewSuccessResponse(map[string]string{"token": "must-not-store"}).FPrint(writer)
finish()
}
entries, err := store.Query(context.Background(), logstore.Query{Kind: "operation", ActorID: "42"})
if err != nil || len(entries) != 3 {
if err != nil || len(entries) != 5 {
t.Fatalf("missing completed login records: %d, %v", len(entries), err)
}
for _, entry := range entries {

View File

@ -1149,28 +1149,26 @@ test('插件读取状态字段不传回严格解析接口,未展示字段和
for (const key of ['telegram_push', 'wechat_push']) {
test(`${key} 密钥独立提交、留空保留,保存回读与离开后清空`, async () => {
let active = { meta: { generation: 1 }, plugins: {
const active = { meta: { generation: 1 }, plugins: {
telegram_push: { enabled: false, settings: { timeout: 30 }, secretStatus: { configured: false } },
wechat_push: { enabled: false, settings: { appId: 'test-only', future: 'preserved' }, secretStatus: { configured: false } }
} }
const original = structuredClone(active)
let pending = null
let secretStatus = { configured: false, version: 0 }
const requests = []
const http = {
get: async path => ({ errorNo: 0, data: path.endsWith('/status') ? { activeGeneration: active.meta.generation }
: path.endsWith('/pending') ? pending : active }),
: path.endsWith('/pending') ? null : path.includes('/plugin/secrets/') ? secretStatus : active }),
put: async (path, body) => {
requests.push({ path, body })
pending = { generation: 2, section: 'plugins', sameSession: true, candidateId: 'secret-test', payload: body.payload }
return { errorNo: 0, data: pending }
assert.equal(path, `/api/v1/plugin/secrets/${key}`)
assert.deepEqual(body, { version: 0, change: { operation: 'replace', value: { plaintext: ' test-only-plugin-secret ' } } })
secretStatus = { configured: true, version: 1 }
return { errorNo: 0, data: secretStatus }
},
post: async (path, body) => {
requests.push({ path, body })
if (path.endsWith('/apply')) {
active = { ...pending.payload, meta: { generation: 2 } }
active.plugins[key].secretStatus = { configured: true }
pending = null
}
assert.equal(path, '/api/v1/config/plugins/validate')
return { errorNo: 0, data: { valid: true } }
}
}
@ -1181,25 +1179,22 @@ for (const key of ['telegram_push', 'wechat_push']) {
assert.deepEqual(requests.at(-1).body.secretChanges, {})
page.updateSecret(' test-only-plugin-secret ')
assert.equal(page.hasChanges.value, true)
assert.equal(page.hasConfigChanges.value, false)
assert.equal(page.editorText.value.includes('test-only-plugin-secret'), false)
await page.validateConfig()
assert.equal(requests.at(-1).path, '/api/v1/config/plugins/validate')
assert.deepEqual(requests.at(-1).body.secretChanges, {
[`plugins.${key}.secret`]: { operation: 'replace', value: { plaintext: ' test-only-plugin-secret ' } }
})
assert.deepEqual(requests.at(-1).body.secretChanges, {})
for (const id of Object.keys(original.plugins)) {
assert.deepEqual(requests.at(-1).body.payload.plugins[id].settings, original.plugins[id].settings)
assert.equal(requests.at(-1).body.payload.plugins[id].secretStatus, undefined)
assert.equal(requests.at(-1).body.payload.plugins[id].enabled, false)
}
assert.equal(await page.saveConfig(), true)
assert.equal(page.secretValue.value, '')
const saved = requests.find(item => item.path === '/api/v1/config/plugins')
assert.equal(saved.body.secretChanges[`plugins.${key}.secret`].value.plaintext, ' test-only-plugin-secret ')
page.updateSecret('blocked-pending-edit')
assert.equal(page.secretValue.value, '')
assert.equal(await page.applyConfig(), true)
assert.equal(await page.saveAndApply(), false)
assert.equal(await page.savePluginSecret(), true)
assert.equal(page.secretValue.value, '')
assert.equal(page.hasChanges.value, false)
assert.equal(page.pendingCandidate.value, null)
assert.deepEqual(active, original)
assert.match(page.secretField.value.description, /^已设置/)
await page.validateConfig()
assert.deepEqual(requests.at(-1).body.secretChanges, {})
@ -1211,12 +1206,78 @@ for (const key of ['telegram_push', 'wechat_push']) {
await readonly.loadAll()
const previousRequests = requests.length
readonly.updateSecret('forbidden')
await readonly.saveConfig()
await readonly.savePluginSecret()
assert.equal(readonly.secretValue.value, '')
assert.equal(requests.length, previousRequests)
})
}
test('插件密钥状态读取失败禁止保存,冲突后保留输入并显示错误', async () => {
let available = false
let calls = 0
const { page } = fixture(async path => {
if (path.includes('/plugin/secrets/')) {
if (!available) throw new Error('状态读取失败')
return { errorNo: 0, data: { configured: true, version: 3 } }
}
return { errorNo: 0, data: path.endsWith('/status') ? { activeGeneration: 1 } : { plugins: { telegram_push: { enabled: false, settings: {} } } } }
}, 'telegram_push', { http: { put: async () => {
calls++
return { errorNo: 100, errorMsg: '配置已被其他操作更新,请刷新后重试' }
} } })
await page.loadAll()
page.updateSecret('blocked')
assert.equal(await page.savePluginSecret(), false)
assert.equal(calls, 0)
assert.equal(page.secretValue.value, '')
available = true
await page.loadAll()
page.updateSecret('test-only-secret')
assert.equal(await page.savePluginSecret(), false)
assert.equal(calls, 1)
assert.equal(page.secretValue.value, 'test-only-secret')
assert.equal(page.pluginSecretStatus.value.version, 3)
assert.match(page.saveError.value, /配置已被其他操作更新/)
assert.equal(page.busy.value, false)
})
test('插件密钥保存近期认证后重试一次,并阻止并发保存与离页', async () => {
const waiting = deferred()
let writes = 0
let reauth = 0
const { page } = fixture(async path => ({ errorNo: 0, data: path.includes('/plugin/secrets/')
? { configured: false, version: 0 }
: path.endsWith('/status') ? { activeGeneration: 1 } : { plugins: { telegram_push: { enabled: false, settings: {} } } } }), 'telegram_push', {
prompt: () => waiting.promise,
http: {
put: async () => {
writes++
return writes === 1 ? { errorNo: 405, data: { code: 'RECENT_AUTH_REQUIRED' } }
: { errorNo: 0, data: { configured: true, version: 1 } }
},
post: async path => {
assert.equal(path, '/api/v1/security/reauth')
reauth++
return { errorNo: 0 }
}
}
})
await page.loadAll()
page.updateSecret('test-only-secret')
const save = page.savePluginSecret()
assert.equal(page.busy.value, true)
assert.equal(await page.savePluginSecret(), false)
assert.equal(await page.beforeLeave(), false)
page.updateSecret('concurrent-edit')
assert.equal(page.secretValue.value, 'test-only-secret')
waiting.resolve({ value: 'test-only-password' })
assert.equal(await save, true)
assert.equal(writes, 2)
assert.equal(reauth, 1)
assert.equal(page.secretValue.value, '')
assert.equal(page.busy.value, false)
})
test('SQLite 不显示或发送密码,网络数据库和中继密码留空保留且不裁剪空格', async () => {
for (const type of ['sqlite', 'mysql', 'postgresql']) {
let request
@ -1329,7 +1390,7 @@ for (const key of ['spam_block', 'telegram_push', 'wechat_push']) {
get: async path => {
requests.push(path)
return { errorNo: 0, data: path.endsWith('/status') ? { activeGeneration: active.meta.generation }
: path.endsWith('/pending') ? pending : active }
: path.endsWith('/pending') ? pending : path.includes('/plugin/secrets/') ? { configured: false, version: 0 } : active }
},
put: async (path, body) => {
assert.equal(path, '/api/v1/config/plugins')
@ -1358,7 +1419,8 @@ for (const key of ['spam_block', 'telegram_push', 'wechat_push']) {
}
const { page, messages, dispose } = fixture(() => {}, key, { http })
await page.loadAll()
assert.deepEqual(requests, ['/api/v1/config/status', '/api/v1/config/plugins', '/api/v1/config/pending'])
assert.deepEqual(requests, ['/api/v1/config/status', '/api/v1/config/plugins',
...(key === 'spam_block' ? [] : [`/api/v1/plugin/secrets/${key}`]), '/api/v1/config/pending'])
assert.equal(page.selectedSection.value, key)
page.updateField(['plugins', key, 'enabled'], true)
await page.validateConfig()