From 932db9fbdc24742f432546695a74f366bb64f1f9 Mon Sep 17 00:00:00 2001 From: Odboy Date: Tue, 10 Mar 2026 21:07:22 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E5=A4=9A=E4=B8=AA?= =?UTF-8?q?=E5=AE=89=E5=85=A8=E7=BC=BA=E9=99=B7=E5=92=8C=E5=8A=9F=E8=83=BD?= =?UTF-8?q?=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 移除加密相关代码(服务端和所有客户端) - 修复 JWT 验证未检查签名算法的问题 - 修复重置密码使用不安全随机数的问题 - 实现 Logout 服务端失效机制 - 修复历史版本对比 diff 颜色标记错误 - 修复占位符解析循环引用风险 - 添加用户禁用状态检查 - 更新 bug 清单文档 --- .gitignore | 1 + kenaito-conf-c-client/main.c | 64 ------ kenaito-conf-cpp-client/main.cpp | 47 ---- kenaito-conf-dotnet-client/KenaitoConf.cs | 52 +---- kenaito-conf-go-client/client.go | 30 +-- kenaito-conf-go-client/crypto.go | 62 ------ .../main/kotlin/kenaito/conf/ConfigClient.kt | 39 +--- kenaito-conf-lua-client/client.lua | 46 +--- kenaito-conf-nodejs-client/lib/client.js | 23 +- kenaito-conf-nodejs-client/lib/crypto.js | 24 -- kenaito-conf-php-client/src/ConfigClient.php | 21 +- kenaito-conf-php-client/src/Crypto.php | 28 --- .../kenaito_conf/client.py | 22 +- .../kenaito_conf/crypto.py | 29 --- kenaito-conf-ruby-client/lib/kenaito_conf.rb | 43 +--- kenaito-conf-rust-client/src/lib.rs | 53 +---- server/bug20261015.md | 206 ++++++++++++++++++ server/cmd/main.go | 5 +- server/internal/handler/auth.go | 9 + server/internal/handler/config.go | 16 +- server/internal/handler/config_api.go | 10 +- server/internal/handler/user.go | 5 +- server/internal/model/user.go | 25 ++- server/internal/service/auth.go | 21 +- server/internal/service/config_parse.go | 11 + server/internal/service/crypto.go | 115 ---------- web/src/views/config/ConfigList.vue | 6 +- 27 files changed, 292 insertions(+), 721 deletions(-) delete mode 100644 kenaito-conf-go-client/crypto.go delete mode 100644 kenaito-conf-nodejs-client/lib/crypto.js delete mode 100644 kenaito-conf-php-client/src/Crypto.php delete mode 100644 kenaito-conf-python-client/kenaito_conf/crypto.py create mode 100644 server/bug20261015.md delete mode 100644 server/internal/service/crypto.go diff --git a/.gitignore b/.gitignore index 60afe16..23c78ae 100644 --- a/.gitignore +++ b/.gitignore @@ -50,3 +50,4 @@ env/ .env.local /kenaito-conf-springboot2-client-demo/target/classes/META-INF/spring.factories /kenaito-conf-springboot2-client-demo/target/classes/application.properties +server/application-dev.properties diff --git a/kenaito-conf-c-client/main.c b/kenaito-conf-c-client/main.c index aa42e74..8ca6917 100644 --- a/kenaito-conf-c-client/main.c +++ b/kenaito-conf-c-client/main.c @@ -2,15 +2,11 @@ #include #include #include -#include -#include -#include #include #define MAX_CONFIGS 256 #define MAX_KEY_LEN 256 #define MAX_VAL_LEN 4096 -#define CRYPT_PREFIX "{Cipher}" typedef struct { char baseUrl[512]; @@ -36,8 +32,6 @@ typedef struct { typedef struct { Config config; ConfigMap configs; - char secret[256]; - char globalSecret[256]; long lastTime; void (*onChange)(const char *key, const char *value); } ConfigClient; @@ -59,62 +53,6 @@ static size_t write_callback(void *contents, size_t size, size_t nmemb, void *us return totalSize; } -static void md5_hex(const char *input, char *output) { - unsigned char digest[MD5_DIGEST_LENGTH]; - MD5((unsigned char *)input, strlen(input), digest); - for (int i = 0; i < MD5_DIGEST_LENGTH; i++) - sprintf(output + i * 2, "%02x", digest[i]); - output[32] = 0; -} - -static int base64_decode(const char *src, unsigned char *dst) { - BIO *b64 = BIO_new(BIO_f_base64()); - BIO *mem = BIO_new_mem_buf((void *)src, strlen(src)); - mem = BIO_push(b64, mem); - BIO_set_flags(mem, BIO_FLAGS_BASE64_NO_NL); - int len = BIO_read(mem, dst, strlen(src)); - BIO_free_all(mem); - return len; -} - -static void decrypt_with_aes(const char *key, const char *data, char *output) { - char md5Hash[33]; - md5_hex(key, md5Hash); - unsigned char aesKey[16]; - memcpy(aesKey, md5Hash + 16, 16); - - unsigned char *cipherText = malloc(strlen(data) * 2); - int cipherLen = base64_decode(data, cipherText); - - unsigned char iv[16] = {0}; - unsigned char plainText[4096]; - - EVP_CIPHER_CTX *ctx = EVP_CIPHER_CTX_new(); - EVP_DecryptInit_ex(ctx, EVP_aes_128_cbc(), NULL, aesKey, iv); - EVP_CIPHER_CTX_set_padding(ctx, 0); - int len = 0, totalLen = 0; - EVP_DecryptUpdate(ctx, plainText, &len, cipherText, cipherLen); - totalLen = len; - EVP_DecryptFinal_ex(ctx, plainText + len, &len); - totalLen += len; - EVP_CIPHER_CTX_free(ctx); - - int padding = plainText[totalLen - 1]; - totalLen -= padding; - memcpy(output, plainText, totalLen); - output[totalLen] = 0; - - free(cipherText); -} - -static void decrypt_config(const char *secret, const char *data, char *output) { - if (strncmp(data, CRYPT_PREFIX, strlen(CRYPT_PREFIX)) != 0) { - strcpy(output, data); - return; - } - decrypt_with_aes(secret, data + strlen(CRYPT_PREFIX), output); -} - static char *http_post_json(const char *url, const char *jsonBody) { CURL *curl = curl_easy_init(); if (!curl) return NULL; @@ -144,8 +82,6 @@ static char *http_post_json(const char *url, const char *jsonBody) { void config_client_init(ConfigClient *client, Config *config) { client->config = *config; client->configs.count = 0; - client->secret[0] = 0; - client->globalSecret[0] = 0; client->lastTime = 0; client->onChange = NULL; curl_global_init(CURL_GLOBAL_DEFAULT); diff --git a/kenaito-conf-cpp-client/main.cpp b/kenaito-conf-cpp-client/main.cpp index 9af3c6b..1d1cccd 100644 --- a/kenaito-conf-cpp-client/main.cpp +++ b/kenaito-conf-cpp-client/main.cpp @@ -11,8 +11,6 @@ #include #include #include -#include -#include namespace kenaito_conf { @@ -27,50 +25,6 @@ struct Config { std::string cacheDir = "."; }; -class Crypto { -public: - static std::string decryptConfig(const std::string &secret, const std::string &data) { - const std::string prefix = "{Cipher}"; - if (data.substr(0, prefix.size()) != prefix) return data; - return decryptWithAES(secret, data.substr(prefix.size())); - } - - static std::string decryptWithAES(const std::string &key, const std::string &data) { - unsigned char digest[MD5_DIGEST_LENGTH]; - MD5((unsigned char *)key.c_str(), key.size(), digest); - char md5Hex[33]; - for (int i = 0; i < MD5_DIGEST_LENGTH; i++) - sprintf(md5Hex + i * 2, "%02x", digest[i]); - md5Hex[32] = 0; - - std::string aesKey(md5Hex + 16, 16); - - // Base64 decode - BIO *b64 = BIO_new(BIO_f_base64()); - BIO *mem = BIO_new_mem_buf(data.c_str(), data.size()); - mem = BIO_push(b64, mem); - BIO_set_flags(mem, BIO_FLAGS_BASE64_NO_NL); - std::vector cipherText(data.size()); - int cipherLen = BIO_read(mem, cipherText.data(), data.size()); - BIO_free_all(mem); - - unsigned char iv[16] = {0}; - std::vector plainText(cipherLen + 16); - EVP_CIPHER_CTX *ctx = EVP_CIPHER_CTX_new(); - EVP_DecryptInit_ex(ctx, EVP_aes_128_cbc(), nullptr, (unsigned char *)aesKey.c_str(), iv); - EVP_CIPHER_CTX_set_padding(ctx, 0); - int len = 0, totalLen = 0; - EVP_DecryptUpdate(ctx, plainText.data(), &len, cipherText.data(), cipherLen); - totalLen = len; - EVP_DecryptFinal_ex(ctx, plainText.data() + len, &len); - totalLen += len; - EVP_CIPHER_CTX_free(ctx); - - int padding = plainText[totalLen - 1]; - return std::string(plainText.begin(), plainText.begin() + totalLen - padding); - } -}; - class ConfigClient { public: ConfigClient(const Config &config) : config_(config) {} @@ -112,7 +66,6 @@ public: private: Config config_; std::map configs_; - std::string secret_, globalSecret_; long lastTime_ = 0; bool started_ = false; std::atomic running_{false}; diff --git a/kenaito-conf-dotnet-client/KenaitoConf.cs b/kenaito-conf-dotnet-client/KenaitoConf.cs index 1fe19c9..971528d 100644 --- a/kenaito-conf-dotnet-client/KenaitoConf.cs +++ b/kenaito-conf-dotnet-client/KenaitoConf.cs @@ -2,7 +2,6 @@ using System; using System.Collections.Generic; using System.IO; using System.Net.Http; -using System.Security.Cryptography; using System.Text; using System.Text.Json; using System.Threading; @@ -22,39 +21,6 @@ namespace KenaitoConf public string CacheDir { get; set; } = "."; } - public static class Crypto - { - private const string CryptPrefix = "{Cipher}"; - - public static string DecryptConfig(string secret, string data) - { - if (!data.StartsWith(CryptPrefix)) - return data; - return DecryptWithAES(secret, data.Substring(CryptPrefix.Length)); - } - - public static string DecryptWithAES(string key, string data) - { - using var md5 = MD5.Create(); - string md5Hash = BitConverter.ToString(md5.ComputeHash(Encoding.UTF8.GetBytes(key))).Replace("-", "").ToLower(); - string aesKeyStr = md5Hash.Substring(16); - byte[] aesKey = Encoding.UTF8.GetBytes(aesKeyStr); - byte[] cipherText = Convert.FromBase64String(data); - byte[] iv = new byte[16]; - - using var aes = Aes.Create(); - aes.Key = aesKey; - aes.IV = iv; - aes.Mode = CipherMode.CBC; - aes.Padding = PaddingMode.None; - - using var decryptor = aes.CreateDecryptor(); - byte[] plainText = decryptor.TransformFinalBlock(cipherText, 0, cipherText.Length); - int padding = plainText[plainText.Length - 1]; - return Encoding.UTF8.GetString(plainText, 0, plainText.Length - padding); - } - } - public class Cache { private readonly string _cacheDir; @@ -86,8 +52,6 @@ namespace KenaitoConf private readonly Cache _cache; private readonly HttpClient _http = new(); private readonly Dictionary _configs = new(); - private string _secret = ""; - private string _globalSecret = ""; private long _lastTime = 0; private bool _started = false; private CancellationTokenSource _cts; @@ -188,10 +152,6 @@ namespace KenaitoConf throw new Exception($"server error: {(data.ContainsKey("msg") ? data["msg"] : "unknown")}"); } - if (data.TryGetValue("kenaito.config.encrypt-secret", out var s)) - _secret = s?.ToString() ?? ""; - if (data.TryGetValue("kenaito.config.global-encrypt-secret", out var gs)) - _globalSecret = gs?.ToString() ?? ""; if (data.TryGetValue("latest.fetch.timestamp", out var ts)) _lastTime = Convert.ToInt64(ts); @@ -231,7 +191,7 @@ namespace KenaitoConf var changed = new Dictionary(); foreach (var kv in changedData) { - changed[kv.Key] = TryDecrypt(kv.Value?.ToString() ?? ""); + changed[kv.Key] = kv.Value?.ToString() ?? ""; } foreach (var kv in changed) @@ -252,16 +212,8 @@ namespace KenaitoConf continue; if (kv.Value is JsonElement) continue; - _configs[kv.Key] = TryDecrypt(kv.Value?.ToString() ?? ""); + _configs[kv.Key] = kv.Value?.ToString() ?? ""; } } - - private string TryDecrypt(string value) - { - string decrypted = Crypto.DecryptConfig(_secret, value); - if (decrypted == value && !string.IsNullOrEmpty(_globalSecret)) - decrypted = Crypto.DecryptConfig(_globalSecret, value); - return decrypted; - } } } diff --git a/kenaito-conf-go-client/client.go b/kenaito-conf-go-client/client.go index 1a3838a..1067a4c 100644 --- a/kenaito-conf-go-client/client.go +++ b/kenaito-conf-go-client/client.go @@ -18,8 +18,6 @@ type Client struct { cache *Cache listener *Listener configs map[string]string - secret string - globalSecret string lastTime int64 mu sync.RWMutex httpClient *http.Client @@ -157,14 +155,6 @@ func (c *Client) fetchAll() error { } } - // extract secrets - if s, ok := result["kenaito.config.encrypt-secret"]; ok { - c.secret, _ = s.(string) - } - if s, ok := result["kenaito.config.global-encrypt-secret"]; ok { - c.globalSecret, _ = s.(string) - } - // extract timestamp if ts, ok := result["latest.fetch.timestamp"]; ok { if tsStr, ok := ts.(string); ok { @@ -234,17 +224,9 @@ func (c *Client) fetchChanged() error { } if len(wrapper.Data) > 0 { - // decrypt values changed := make(map[string]string) for k, v := range wrapper.Data { - decrypted, err := decryptConfig(c.secret, v) - if err != nil && c.globalSecret != "" { - decrypted, err = decryptConfig(c.globalSecret, v) - } - if err != nil { - decrypted = v - } - changed[k] = decrypted + changed[k] = v } // update local configs @@ -275,15 +257,7 @@ func (c *Client) applyConfigs(data map[string]interface{}) { if !ok { continue } - // decrypt if needed - try app secret first, then global secret - decrypted, err := decryptConfig(c.secret, strVal) - if err != nil && c.globalSecret != "" { - decrypted, err = decryptConfig(c.globalSecret, strVal) - } - if err != nil { - decrypted = strVal - } - c.configs[k] = decrypted + c.configs[k] = strVal } } diff --git a/kenaito-conf-go-client/crypto.go b/kenaito-conf-go-client/crypto.go deleted file mode 100644 index 5dd7baf..0000000 --- a/kenaito-conf-go-client/crypto.go +++ /dev/null @@ -1,62 +0,0 @@ -package client - -import ( - "crypto/aes" - "crypto/cipher" - "crypto/md5" - "encoding/base64" - "encoding/hex" - "fmt" - "strings" -) - -const CryptPrefix = "{Cipher}" - -func decryptConfig(secret string, data string) (string, error) { - if !strings.HasPrefix(data, CryptPrefix) { - return data, nil - } - encrypted := strings.TrimPrefix(data, CryptPrefix) - return decryptWithAES(secret, encrypted) -} - -func decryptWithAES(key string, data string) (string, error) { - secretKey := md5.Sum([]byte(key)) - aesKey := hex.EncodeToString(secretKey[:])[16:] - - cipherText, err := base64.StdEncoding.DecodeString(data) - if err != nil { - return "", err - } - - block, err := aes.NewCipher([]byte(aesKey)) - if err != nil { - return "", err - } - - if len(cipherText)%aes.BlockSize != 0 { - return "", fmt.Errorf("ciphertext is not a multiple of block size") - } - - plainText := make([]byte, len(cipherText)) - mode := cipher.NewCBCDecrypter(block, make([]byte, aes.BlockSize)) - mode.CryptBlocks(plainText, cipherText) - - plainText, err = pkcs7Unpad(plainText, aes.BlockSize) - if err != nil { - return "", err - } - - return string(plainText), nil -} - -func pkcs7Unpad(data []byte, blockSize int) ([]byte, error) { - if len(data) == 0 { - return nil, fmt.Errorf("empty data") - } - padding := int(data[len(data)-1]) - if padding > blockSize || padding == 0 { - return nil, fmt.Errorf("invalid padding") - } - return data[:len(data)-padding], nil -} diff --git a/kenaito-conf-kotlin-client/src/main/kotlin/kenaito/conf/ConfigClient.kt b/kenaito-conf-kotlin-client/src/main/kotlin/kenaito/conf/ConfigClient.kt index 5f89583..3f354a1 100644 --- a/kenaito-conf-kotlin-client/src/main/kotlin/kenaito/conf/ConfigClient.kt +++ b/kenaito-conf-kotlin-client/src/main/kotlin/kenaito/conf/ConfigClient.kt @@ -8,12 +8,8 @@ import java.net.URI import java.net.http.HttpClient import java.net.http.HttpRequest import java.net.http.HttpResponse -import java.security.MessageDigest import java.time.Duration import java.util.concurrent.ConcurrentHashMap -import javax.crypto.Cipher -import javax.crypto.spec.IvParameterSpec -import javax.crypto.spec.SecretKeySpec @Serializable data class Config( @@ -27,27 +23,6 @@ data class Config( val cacheDir: String = "." ) -object Crypto { - private const val CRYPT_PREFIX = "{Cipher}" - - fun decryptConfig(secret: String, data: String): String { - if (!data.startsWith(CRYPT_PREFIX)) return data - return decryptWithAES(secret, data.removePrefix(CRYPT_PREFIX)) - } - - fun decryptWithAES(key: String, data: String): String { - val md5 = MessageDigest.getInstance("MD5").digest(key.toByteArray()).joinToString("") { "%02x".format(it) } - val aesKey = md5.substring(16).toByteArray() - val cipherText = java.util.Base64.getDecoder().decode(data) - val iv = IvParameterSpec(ByteArray(16)) - val cipher = Cipher.getInstance("AES/CBC/NoPadding") - cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(aesKey, "AES"), iv) - val plainText = cipher.doFinal(cipherText) - val padding = plainText.last().toInt() and 0xFF - return String(plainText, 0, plainText.size - padding) - } -} - class Cache(private val cacheDir: String) { private val cacheFile = File(cacheDir, "config-cache.json") fun write(data: String) { cacheFile.writeText(data) } @@ -57,8 +32,6 @@ class Cache(private val cacheDir: String) { class ConfigClient(private val config: Config) { private val httpClient = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(10)).build() private val configs = ConcurrentHashMap() - private var secret = "" - private var globalSecret = "" private var lastTime = 0L private var started = false private val handlers = mutableListOf<(Map) -> Unit>() @@ -101,8 +74,6 @@ class ConfigClient(private val config: Config) { val data = json.parseToJsonElement(resp.body()).jsonObject if (data.containsKey("code") && data["code"]!!.jsonPrimitive.int != 0) throw RuntimeException("server error: ${data["code"]}") - data["kenaito.config.encrypt-secret"]?.let { secret = it.jsonPrimitive.content } - data["kenaito.config.global-encrypt-secret"]?.let { globalSecret = it.jsonPrimitive.content } data["latest.fetch.timestamp"]?.let { lastTime = it.jsonPrimitive.content.toLong() } Cache(config.cacheDir).write(resp.body()) applyConfigs(resp.body()) @@ -121,7 +92,7 @@ class ConfigClient(private val config: Config) { val changedData = result["data"]!!.jsonObject.toMutableMap() changedData.remove("latest.fetch.timestamp")?.let { lastTime = it.jsonPrimitive.content.toLong() } val changed = mutableMapOf() - changedData.forEach { (k, v) -> changed[k] = tryDecrypt(v.jsonPrimitive.content) } + changedData.forEach { (k, v) -> changed[k] = v.jsonPrimitive.content } configs.putAll(changed) handlers.forEach { try { it(changed) } catch (e: Exception) { System.err.println("[ConfigClient] handler error: ${e.message}") } } } @@ -130,13 +101,7 @@ class ConfigClient(private val config: Config) { val data = json.parseToJsonElement(jsonStr).jsonObject data.forEach { (k, v) -> if (!k.startsWith("kenaito.") && k != "latest.fetch.timestamp") - configs[k] = tryDecrypt(v.jsonPrimitive.content) + configs[k] = v.jsonPrimitive.content } } - - private fun tryDecrypt(value: String): String { - var decrypted = Crypto.decryptConfig(secret, value) - if (decrypted == value && globalSecret.isNotEmpty()) decrypted = Crypto.decryptConfig(globalSecret, value) - return decrypted - } } diff --git a/kenaito-conf-lua-client/client.lua b/kenaito-conf-lua-client/client.lua index 1de572d..9488b2e 100644 --- a/kenaito-conf-lua-client/client.lua +++ b/kenaito-conf-lua-client/client.lua @@ -1,7 +1,6 @@ local http = require("socket.http") local ltn12 = require("ltn12") local cjson = require("cjson") -local openssl = require("openssl") local ConfigClient = {} ConfigClient.__index = ConfigClient @@ -17,10 +16,8 @@ function ConfigClient.new(config) self.config.syncInterval = self.config.syncInterval or 60 self.config.ignoreGlobal = self.config.ignoreGlobal or false self.config.cacheDir = self.config.cacheDir or "." - + self.configs = {} - self.secret = "" - self.globalSecret = "" self.lastTime = 0 self.handlers = {} return self @@ -38,30 +35,6 @@ function ConfigClient:get_all() return self.configs end -local function decrypt_config(secret, data) - local prefix = "{Cipher}" - if string.sub(data, 1, #prefix) ~= prefix then - return data - end - local encrypted = string.sub(data, #prefix + 1) - - -- MD5 hash - local md5_hash = openssl.digest("md5", secret) - local aes_key = string.sub(md5_hash, 17) - - -- Base64 decode - local cipher_text = openssl.base64(encrypted, false) - - -- AES-128-CBC decrypt - local iv = string.rep("\0", 16) - local cipher = openssl.cipher.new("aes-128-cbc") - local plain_text = cipher:decrypt(cipher_text, aes_key, iv) - - -- Remove PKCS7 padding - local padding = string.byte(plain_text, -1) - return string.sub(plain_text, 1, -padding - 1) -end - function ConfigClient:start() -- Fetch all configs local url = self.config.baseUrl .. "/api/fetchAllConfigs" @@ -87,28 +60,19 @@ function ConfigClient:start() if code == 200 then local data = cjson.decode(table.concat(response_body)) - - if data["kenaito.config.encrypt-secret"] then - self.secret = data["kenaito.config.encrypt-secret"] - end - if data["kenaito.config.global-encrypt-secret"] then - self.globalSecret = data["kenaito.config.global-encrypt-secret"] - end + if data["latest.fetch.timestamp"] then self.lastTime = tonumber(data["latest.fetch.timestamp"]) end - + for k, v in pairs(data) do if not string.match(k, "^kenaito%.") and k ~= "latest.fetch.timestamp" then if type(v) == "string" then - self.configs[k] = decrypt_config(self.secret, v) - if self.configs[k] == v and self.globalSecret ~= "" then - self.configs[k] = decrypt_config(self.globalSecret, v) - end + self.configs[k] = v end end end - + print("[ConfigClient] fetched " .. #self.configs .. " configs") end end diff --git a/kenaito-conf-nodejs-client/lib/client.js b/kenaito-conf-nodejs-client/lib/client.js index 32da4fe..257f2f4 100644 --- a/kenaito-conf-nodejs-client/lib/client.js +++ b/kenaito-conf-nodejs-client/lib/client.js @@ -1,7 +1,6 @@ import { Config } from './config.js'; import { Cache } from './cache.js'; import { Listener } from './listener.js'; -import { decryptConfig } from './crypto.js'; export class ConfigClient { constructor(config) { @@ -9,8 +8,6 @@ export class ConfigClient { this.cache = new Cache(config.cacheDir); this.listener = new Listener(); this.configs = {}; - this.secret = ''; - this.globalSecret = ''; this.lastTime = 0; this.started = false; this.timer = null; @@ -86,14 +83,6 @@ export class ConfigClient { throw new Error(`server error: ${data.msg || 'unknown'}`); } - const secret = data['kenaito.config.encrypt-secret']; - if (secret) { - this.secret = secret; - } - const globalSecret = data['kenaito.config.global-encrypt-secret']; - if (globalSecret) { - this.globalSecret = globalSecret; - } const ts = data['latest.fetch.timestamp']; if (ts) { this.lastTime = parseInt(ts, 10); @@ -140,7 +129,7 @@ export class ConfigClient { const changed = {}; for (const [k, v] of Object.entries(changedData)) { - changed[k] = this.tryDecrypt(v); + changed[k] = v; } Object.assign(this.configs, changed); @@ -156,17 +145,9 @@ export class ConfigClient { if (typeof v !== 'string') { continue; } - this.configs[k] = this.tryDecrypt(v); + this.configs[k] = v; } } - - tryDecrypt(value) { - let decrypted = decryptConfig(this.secret, value); - if (decrypted === value && this.globalSecret) { - decrypted = decryptConfig(this.globalSecret, value); - } - return decrypted; - } } export { Config } from './config.js'; diff --git a/kenaito-conf-nodejs-client/lib/crypto.js b/kenaito-conf-nodejs-client/lib/crypto.js deleted file mode 100644 index 2f0237e..0000000 --- a/kenaito-conf-nodejs-client/lib/crypto.js +++ /dev/null @@ -1,24 +0,0 @@ -import crypto from 'node:crypto'; - -const CRYPT_PREFIX = '{Cipher}'; - -export function decryptConfig(secret, data) { - if (!data.startsWith(CRYPT_PREFIX)) { - return data; - } - const encrypted = data.slice(CRYPT_PREFIX.length); - return decryptWithAES(secret, encrypted); -} - -export function decryptWithAES(key, data) { - const md5Hash = crypto.createHash('md5').update(key).digest('hex'); - const aesKey = md5Hash.slice(16); - const cipherText = Buffer.from(data, 'base64'); - const iv = Buffer.alloc(16, 0); - const decipher = crypto.createDecipheriv('aes-128-cbc', aesKey, iv); - decipher.setAutoPadding(false); - let plainText = Buffer.concat([decipher.update(cipherText), decipher.final()]); - const padding = plainText[plainText.length - 1]; - plainText = plainText.slice(0, plainText.length - padding); - return plainText.toString('utf8'); -} diff --git a/kenaito-conf-php-client/src/ConfigClient.php b/kenaito-conf-php-client/src/ConfigClient.php index 3103748..7f88dcb 100644 --- a/kenaito-conf-php-client/src/ConfigClient.php +++ b/kenaito-conf-php-client/src/ConfigClient.php @@ -7,8 +7,6 @@ class ConfigClient private Config $config; private Cache $cache; private array $configs = []; - private string $secret = ''; - private string $globalSecret = ''; private int $lastTime = 0; private bool $started = false; private array $handlers = []; @@ -107,12 +105,6 @@ class ConfigClient throw new \RuntimeException("server error: " . ($data['msg'] ?? 'unknown')); } - if (isset($data['kenaito.config.encrypt-secret'])) { - $this->secret = $data['kenaito.config.encrypt-secret']; - } - if (isset($data['kenaito.config.global-encrypt-secret'])) { - $this->globalSecret = $data['kenaito.config.global-encrypt-secret']; - } if (isset($data['latest.fetch.timestamp'])) { $this->lastTime = (int)$data['latest.fetch.timestamp']; } @@ -156,7 +148,7 @@ class ConfigClient $changed = []; foreach ($changedData as $k => $v) { - $changed[$k] = $this->tryDecrypt($v); + $changed[$k] = $v; } $this->configs = array_merge($this->configs, $changed); @@ -181,16 +173,7 @@ class ConfigClient if (!is_string($v)) { continue; } - $this->configs[$k] = $this->tryDecrypt($v); + $this->configs[$k] = $v; } } - - private function tryDecrypt(string $value): string - { - $decrypted = Crypto::decryptConfig($this->secret, $value); - if ($decrypted === $value && $this->globalSecret) { - $decrypted = Crypto::decryptConfig($this->globalSecret, $value); - } - return $decrypted; - } } diff --git a/kenaito-conf-php-client/src/Crypto.php b/kenaito-conf-php-client/src/Crypto.php deleted file mode 100644 index 5548b12..0000000 --- a/kenaito-conf-php-client/src/Crypto.php +++ /dev/null @@ -1,28 +0,0 @@ - str: - decrypted = decrypt_config(self._secret, value) - if decrypted == value and self._global_secret: - decrypted = decrypt_config(self._global_secret, value) - return decrypted + self._configs[k] = v def _sync_loop(self) -> None: while not self._stop_event.is_set(): diff --git a/kenaito-conf-python-client/kenaito_conf/crypto.py b/kenaito-conf-python-client/kenaito_conf/crypto.py deleted file mode 100644 index a6c207e..0000000 --- a/kenaito-conf-python-client/kenaito_conf/crypto.py +++ /dev/null @@ -1,29 +0,0 @@ -import base64 -import hashlib - -import pyaes - -CRYPT_PREFIX = "{Cipher}" - - -def decrypt_config(secret: str, data: str) -> str: - if not data.startswith(CRYPT_PREFIX): - return data - encrypted = data[len(CRYPT_PREFIX):] - return decrypt_with_aes(secret, encrypted) - - -def decrypt_with_aes(key: str, data: str) -> str: - md5_hash = hashlib.md5(key.encode()).hexdigest() - aes_key = md5_hash[16:].encode() - - cipher_text = base64.b64decode(data) - - iv = b"\0" * 16 - aes_cbc = pyaes.AESModeOfOperationCBC(aes_key, iv=iv) - plain_text = aes_cbc.decrypt(cipher_text) - - padding = plain_text[-1] - plain_text = plain_text[:-padding] - - return plain_text.decode() diff --git a/kenaito-conf-ruby-client/lib/kenaito_conf.rb b/kenaito-conf-ruby-client/lib/kenaito_conf.rb index f3f0e77..7cdde37 100644 --- a/kenaito-conf-ruby-client/lib/kenaito_conf.rb +++ b/kenaito-conf-ruby-client/lib/kenaito_conf.rb @@ -2,8 +2,6 @@ require 'net/http' require 'json' -require 'openssl' -require 'base64' module KenaitoConf class Config @@ -21,33 +19,6 @@ module KenaitoConf end end - module Crypto - CRYPT_PREFIX = '{Cipher}' - - def self.decrypt_config(secret, data) - return data unless data.start_with?(CRYPT_PREFIX) - encrypted = data[CRYPT_PREFIX.length..] - decrypt_with_aes(secret, encrypted) - end - - def self.decrypt_with_aes(key, data) - md5_hash = OpenSSL::Digest::MD5.hexdigest(key) - aes_key = md5_hash[16..] - cipher_text = Base64.decode64(data) - iv = "\0" * 16 - - cipher = OpenSSL::Cipher::AES128.new(:CBC) - cipher.decrypt - cipher.key = aes_key - cipher.iv = iv - cipher.padding = 0 - - plain_text = cipher.update(cipher_text) + cipher.final - padding = plain_text[-1].ord - plain_text[0...-padding] - end - end - class Cache CACHE_FILE = 'config-cache.json' @@ -73,8 +44,6 @@ module KenaitoConf @config = config @cache = Cache.new(config.cache_dir) @configs = {} - @secret = '' - @global_secret = '' @last_time = 0 @handlers = [] @started = false @@ -147,8 +116,6 @@ module KenaitoConf data = JSON.parse(response.body) raise "server error: #{data['msg']}" if data['code'] && data['code'] != 0 - @secret = data['kenaito.config.encrypt-secret'] if data['kenaito.config.encrypt-secret'] - @global_secret = data['kenaito.config.global-encrypt-secret'] if data['kenaito.config.global-encrypt-secret'] @last_time = data['latest.fetch.timestamp'].to_i if data['latest.fetch.timestamp'] @cache.write(data) @@ -180,7 +147,7 @@ module KenaitoConf changed = {} changed_data.each do |k, v| - changed[k] = try_decrypt(v) + changed[k] = v end @configs.merge!(changed) @@ -191,14 +158,8 @@ module KenaitoConf data.each do |k, v| next if k.start_with?('kenaito.') || k == 'latest.fetch.timestamp' next unless v.is_a?(String) - @configs[k] = try_decrypt(v) + @configs[k] = v end end - - def try_decrypt(value) - decrypted = Crypto.decrypt_config(@secret, value) - decrypted = Crypto.decrypt_config(@global_secret, value) if decrypted == value && !@global_secret.empty? - decrypted - end end end diff --git a/kenaito-conf-rust-client/src/lib.rs b/kenaito-conf-rust-client/src/lib.rs index 86cca66..db628ba 100644 --- a/kenaito-conf-rust-client/src/lib.rs +++ b/kenaito-conf-rust-client/src/lib.rs @@ -1,12 +1,8 @@ -use aes::cipher::{block_padding::NoPadding, BlockDecryptMut, KeyIvInit}; -use md5::{Digest, Md5}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::sync::{Arc, Mutex}; use std::time::Duration; -type Aes128CbcDec = cbc::Decryptor; - #[derive(Clone, Debug)] pub struct Config { pub base_url: String, @@ -53,40 +49,9 @@ struct FetchChangedRequest { token: String, } -fn decrypt_config(secret: &str, data: &str) -> String { - let prefix = "{Cipher}"; - if !data.starts_with(prefix) { - return data.to_string(); - } - let encrypted = &data[prefix.len()..]; - decrypt_with_aes(secret, encrypted) -} - -fn decrypt_with_aes(key: &str, data: &str) -> String { - let mut hasher = Md5::new(); - hasher.update(key.as_bytes()); - let result = hasher.finalize(); - let md5_hex = format!("{:x}", result); - let aes_key = &md5_hex[16..]; - - let cipher_text = base64::decode(data).unwrap_or_default(); - let iv = [0u8; 16]; - - let mut buf = cipher_text.clone(); - let decrypted = Aes128CbcDec::new_from_slices(aes_key.as_bytes(), &iv) - .unwrap() - .decrypt_padded_mut::(&mut buf) - .unwrap(); - - let padding = decrypted[decrypted.len() - 1] as usize; - String::from_utf8_lossy(&decrypted[..decrypted.len() - padding]).to_string() -} - pub struct ConfigClient { config: Config, configs: Arc>>, - secret: Arc>, - global_secret: Arc>, last_time: Arc>, handlers: Arc) + Send + Sync>>>>, } @@ -96,8 +61,6 @@ impl ConfigClient { Self { config, configs: Arc::new(Mutex::new(HashMap::new())), - secret: Arc::new(Mutex::new(String::new())), - global_secret: Arc::new(Mutex::new(String::new())), last_time: Arc::new(Mutex::new(0)), handlers: Arc::new(Mutex::new(Vec::new())), } @@ -122,8 +85,6 @@ impl ConfigClient { self.fetch_all().await?; let configs = self.configs.clone(); - let secret = self.secret.clone(); - let global_secret = self.global_secret.clone(); let last_time = self.last_time.clone(); let handlers = self.handlers.clone(); let config = self.config.clone(); @@ -159,30 +120,18 @@ impl ConfigClient { } } - if let Some(s) = data.get("kenaito.config.encrypt-secret") { - *self.secret.lock().unwrap() = s.as_str().unwrap_or("").to_string(); - } - if let Some(gs) = data.get("kenaito.config.global-encrypt-secret") { - *self.global_secret.lock().unwrap() = gs.as_str().unwrap_or("").to_string(); - } if let Some(ts) = data.get("latest.fetch.timestamp") { *self.last_time.lock().unwrap() = ts.as_i64().unwrap_or(0); } let mut configs = self.configs.lock().unwrap(); - let secret = self.secret.lock().unwrap().clone(); - let global_secret = self.global_secret.lock().unwrap().clone(); for (k, v) in data.iter() { if k.starts_with("kenaito.") || k == "latest.fetch.timestamp" { continue; } if let Some(s) = v.as_str() { - let mut decrypted = decrypt_config(&secret, s); - if decrypted == s && !global_secret.is_empty() { - decrypted = decrypt_config(&global_secret, s); - } - configs.insert(k.clone(), decrypted); + configs.insert(k.clone(), s.to_string()); } } diff --git a/server/bug20261015.md b/server/bug20261015.md new file mode 100644 index 0000000..de7858e --- /dev/null +++ b/server/bug20261015.md @@ -0,0 +1,206 @@ +# Server 代码缺陷清单 — 2026-10-15 + +## 🔴 严重缺陷(安全/数据风险) + +### 1. `encryptIfRequired` 是空实现 — 敏感数据未加密 +**文件**: `internal/handler/config.go` + +`CryptoService` 已完整实现了 AES 加解密,但 `Add` 和 `Update` 配置时调用的 `encryptIfRequired` 是空函数,所有敏感配置值以明文存储。 + +```go +func (h *ConfigHandler) encryptIfRequired(config *model.AppConfig) { + // simplified: check if contents contain sensitive keys + // In production, parse the config and encrypt values with {Cipher} prefix + // For now, skip encryption logic +} +``` + +### 2. 重置密码使用 `math/rand` — 可预测的伪随机数 +**文件**: `internal/handler/user.go` + +`ResetPassword` 用 `math/rand` 生成 8 位随机密码。`math/rand` 不是密码学安全的随机源,攻击者可以预测生成的密码。应使用 `crypto/rand`。 + +```go +import "math/rand" + +func randomString(n int) string { + const letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + b := make([]byte, n) + for i := range b { + b[i] = letters[rand.Intn(len(letters))] // ← 不安全 + } + return string(b) +} +``` + +### 3. JWT 验证未检查签名算法 — 存在 "none" 算法攻击风险 +**文件**: `internal/service/auth.go` + +没有检查 `token.Method` 是否为预期的 `jwt.SigningMethodHS256`,攻击者可以将算法改为 `none` 来伪造任意 token。 + +```go +func (s *AuthService) ValidateToken(tokenString string) (*model.LoginUserInfo, error) { + token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) { + return []byte(s.jwtSecret), nil // ← 未验证 token.Method + }) +``` + +### 4. 数据库真实凭据硬编码在配置文件中 +**文件**: `application.properties` + +外网 IP + 真实密码 + JWT 密钥 + 加密密钥全部明文提交到代码仓库。 + +```properties +database.host=103.236.98.135 +database.password=?uWxt,CX2>+#*Ij@-xa+ +jwt.secret=kenaito-conf-jwt-secret-2026 +crypto.secretKey=k9#mP2$vL7qR4xN8 +``` + +### 5. 加密注释声称 ECB 模式,实际使用 CBC + 零 IV +**文件**: `internal/service/crypto.go` + +注释写的是 ECB,代码用的是 CBC(IV 全零)。如果 Java 端真的是 ECB,则两端加解密不兼容。且 CBC + 零 IV 等于 ECB 的安全性。 + +```go +// ECB mode (compatible with Java version) +mode := cipher.NewCBCEncrypter(block, make([]byte, aes.BlockSize)) +``` + +--- + +## 🟠 重要缺陷(功能/逻辑错误) + +### 6. Logout 不做任何服务端失效处理 +**文件**: `internal/handler/auth.go` + +JWT 在过期前始终有效,登出后 token 仍可使用。 + +```go +func (h *AuthHandler Logout(c *gin.Context) { + c.JSON(http.StatusOK, model.WrapperResponse{Code: 0}) +} +``` + +### 9. `ValidateToken` 不检查用户是否已被禁用 +**文件**: `internal/service/auth.go` + +用户被管理员禁用后,已签发的 JWT 在过期前仍然有效。 + +```go +has, err := dao.Engine.ID(userId).Get(&user) +if err != nil || !has { + return nil, errors.New("user not found") +} +// ← 没有检查 user.Enabled +``` + +### 10. 占位符解析存在循环引用风险 +**文件**: `internal/service/config_parse.go` + +没有检测循环引用,恶意或错误的配置(如 A=${B}, B=${A})会导致 CPU 空转 100 次迭代。 + +```go +func (s *ConfigParseService) replacePlaceholder(...) string { + maxIterations := 100 + for i := 0; i < maxIterations; i++ { + // 循环引用时无限迭代 + } +} +``` + +### 11. `FetchAllConfigs` / `FetchChangedConfigs` 未强制要求 Token +**文件**: `internal/handler/config_api.go` + +当 `app.Token` 为空时,任何请求都能拉取配置,无需认证。如果应用未配置 Token,则配置接口完全开放。 + +```go +if app.Token != "" && app.Token != req.Token { + c.JSON(http.StatusForbidden, ...) + return +} +// Token 为空时直接放行 +``` + +--- + +## 🟡 一般缺陷(代码质量/健壮性) + +### 12. 数据库操作错误被大规模忽略 +整个代码库中大量数据库操作结果被忽略,包括关键的 Insert、Update、Count 操作: + +| 文件 | 示例 | +|------|------| +| `handler/app.go` | `dao.Engine.Insert(&app)` — 插入失败无感知 | +| `handler/config.go` | `dao.Engine.Insert(&history)` — 历史记录保存失败无感知 | +| `handler/user.go` | `dao.Engine.Insert(&user)` — 用户创建失败无感知 | +| `handler/config_api.go` | `dao.Engine.Where(...).Find(&globalConfigs)` — 查询失败返回空结果 | + +### 13. Token 校验使用字符串直接比较 — 时序攻击 +**文件**: `internal/handler/config_api.go` + +应使用 `crypto/subtle.ConstantTimeCompare` 防止时序攻击。 + +```go +if app.Token != "" && app.Token != req.Token { +``` + +### 14. `Delete` 用户是硬删除,与其他模块软删除不一致 +**文件**: `internal/handler/user.go` + +而 App、Config 等都是 `enabled = false` 软删除。硬删除用户后,关联的 `user_permissions` 记录和 `created_by` 引用会变成孤儿数据。 + +```go +dao.Engine.ID(id).Delete(&model.User{}) // 物理删除 +``` + +### 15. `AppHandler.List` 存在 N+1 查询 +**文件**: `internal/handler/app.go` + +加载 group names 时,每个 group 单独查询一次,应改为 `WHERE id IN (...)` 批量查询。 + +```go +for gid := range groupIds { + var group model.BusinessGroup + if has, _ := dao.Engine.ID(gid).Get(&group); has { // ← 每个 group 一次查询 +``` + +### 16. `ConfigHandler.List` 中 `app_code like ?` 可能过度匹配 +**文件**: `internal/handler/config.go` + +如果 `app.Code = "app"`,会匹配到 `app`、`app-web`、`app-api` 等所有以 `app` 开头的配置。 + +```go +query = query.And("app_code like ?", app.Code+"%") +``` + +### 17. `AppHandler.Tree` 返回已禁用的应用 +**文件**: `internal/handler/app.go` + +没有过滤 `enabled = 1`,禁用的应用也会出现在树中。 + +```go +func (h *AppHandler) Tree(c *gin.Context) { + var apps []model.Application + dao.Engine.Find(&apps) // ← 没有 WHERE enabled = 1 +``` + +### 18. `Profile` 模型缺少 `CreatedAt`/`UpdatedAt` 字段 +**文件**: `internal/model/profile.go` + +其他所有模型都有时间审计字段,Profile 没有,无法追踪环境的创建和修改时间。 + +### 19. DSN 构建逻辑重复 +**文件**: `internal/config/config.go` 的 `DSN()` 方法 vs `internal/dao/engine.go` 的 `Init()` 函数 + +两处各自拼接 DSN 字符串,且 `dao/engine.go` 没有复用 `config.DatabaseConfig.DSN()` 方法,还额外加了 `&loc=Local` 参数。 + +--- + +## 总结 + +| 等级 | 数量 | 关键问题 | +|------|------|----------| +| 🔴 严重 | 5 | 加密空实现、不安全随机数、JWT 算法未校验、凭据泄露、加密模式不一致 | +| 🟠 重要 | 6 | 回滚不加密、权限表缺失、登出无效、禁用用户仍可访问等 | +| 🟡 一般 | 8 | 错误忽略、N+1 查询、时序攻击、硬删除不一致等 | diff --git a/server/cmd/main.go b/server/cmd/main.go index d3da2c0..5517235 100644 --- a/server/cmd/main.go +++ b/server/cmd/main.go @@ -30,15 +30,14 @@ func main() { // init services authService := service.NewAuthService(config.Cfg.JWT.Secret, config.Cfg.JWT.ExpireHours) - cryptoService := service.NewCryptoService() parseService := service.NewConfigParseService() permService := service.NewPermissionService() // init handlers authHandler := handler.NewAuthHandler(authService) indexHandler := handler.NewIndexHandler() - configHandler := handler.NewConfigHandler(cryptoService, parseService, permService) - configApiHandler := handler.NewConfigApiHandler(cryptoService, parseService) + configHandler := handler.NewConfigHandler(parseService, permService) + configApiHandler := handler.NewConfigApiHandler(parseService) appHandler := handler.NewAppHandler() userHandler := handler.NewUserHandler(permService) groupHandler := handler.NewGroupHandler() diff --git a/server/internal/handler/auth.go b/server/internal/handler/auth.go index e274327..eaeed76 100644 --- a/server/internal/handler/auth.go +++ b/server/internal/handler/auth.go @@ -1,10 +1,13 @@ package handler import ( + "kenaito-conf/server/internal/dao" "kenaito-conf/server/internal/middleware" "kenaito-conf/server/internal/model" "kenaito-conf/server/internal/service" "net/http" + "strconv" + "time" "github.com/gin-gonic/gin" ) @@ -51,5 +54,11 @@ func (h *AuthHandler) GetLoginUserInfo(c *gin.Context) { } func (h *AuthHandler) Logout(c *gin.Context) { + userInfo := middleware.GetUserInfo(c) + if userInfo != nil { + userId, _ := strconv.Atoi(userInfo.Id) + now := time.Now() + dao.Engine.ID(userId).Cols("last_logout_at").Update(&model.User{LastLogoutAt: &now}) + } c.JSON(http.StatusOK, model.WrapperResponse{Code: 0}) } diff --git a/server/internal/handler/config.go b/server/internal/handler/config.go index f31397d..e0f0a72 100644 --- a/server/internal/handler/config.go +++ b/server/internal/handler/config.go @@ -13,13 +13,12 @@ import ( ) type ConfigHandler struct { - cryptoService *service.CryptoService parseService *service.ConfigParseService permService *service.PermissionService } -func NewConfigHandler(crypto *service.CryptoService, parse *service.ConfigParseService, perm *service.PermissionService) *ConfigHandler { - return &ConfigHandler{cryptoService: crypto, parseService: parse, permService: perm} +func NewConfigHandler(parse *service.ConfigParseService, perm *service.PermissionService) *ConfigHandler { + return &ConfigHandler{parseService: parse, permService: perm} } type ConfigListRequest struct { @@ -162,9 +161,6 @@ func (h *ConfigHandler) Add(c *gin.Context) { } } - // encrypt sensitive values - h.encryptIfRequired(&config) - if _, err := dao.Engine.Insert(&config); err != nil { c.JSON(http.StatusOK, model.WrapperResponse{Code: 500, Msg: "保存失败"}) return @@ -208,8 +204,6 @@ func (h *ConfigHandler) Update(c *gin.Context) { now := time.Now() config.UpdatedAt = &now - h.encryptIfRequired(&config) - dao.Engine.ID(config.Id).Cols("contents", "version", "updated_by", "updated_at").Update(&config) // TODO: publish config change event (ZK removed, could use webhook or other mechanism) @@ -312,9 +306,3 @@ func (h *ConfigHandler) saveHistory(config *model.AppConfig, operator string) { } dao.Engine.Insert(&history) } - -func (h *ConfigHandler) encryptIfRequired(config *model.AppConfig) { - // simplified: check if contents contain sensitive keys - // In production, parse the config and encrypt values with {Cipher} prefix - // For now, skip encryption logic -} diff --git a/server/internal/handler/config_api.go b/server/internal/handler/config_api.go index bcff633..f70ca2d 100644 --- a/server/internal/handler/config_api.go +++ b/server/internal/handler/config_api.go @@ -13,12 +13,11 @@ import ( ) type ConfigApiHandler struct { - cryptoService *service.CryptoService parseService *service.ConfigParseService } -func NewConfigApiHandler(crypto *service.CryptoService, parse *service.ConfigParseService) *ConfigApiHandler { - return &ConfigApiHandler{cryptoService: crypto, parseService: parse} +func NewConfigApiHandler(parse *service.ConfigParseService) *ConfigApiHandler { + return &ConfigApiHandler{parseService: parse} } type FetchAllRequest struct { @@ -104,11 +103,6 @@ func (h *ConfigApiHandler) FetchAllConfigs(c *gin.Context) { return } - // add encryption keys - globalCryptKey := h.cryptoService.GetCryptKey(0, req.Env) - result["kenaito.config.global-encrypt-secret"] = globalCryptKey - appCryptKey := h.cryptoService.GetCryptKey(app.Id, req.Env) - result["kenaito.config.encrypt-secret"] = appCryptKey result["latest.fetch.timestamp"] = strconv.FormatInt(time.Now().UnixMilli(), 10) c.JSON(http.StatusOK, result) diff --git a/server/internal/handler/user.go b/server/internal/handler/user.go index 052cd51..6d1f5db 100644 --- a/server/internal/handler/user.go +++ b/server/internal/handler/user.go @@ -6,7 +6,7 @@ import ( "kenaito-conf/server/internal/model" "kenaito-conf/server/internal/service" "kenaito-conf/server/internal/util" - "math/rand" + "crypto/rand" "net/http" "strconv" "time" @@ -341,8 +341,9 @@ func (h *UserHandler) Options(c *gin.Context) { func randomString(n int) string { const letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" b := make([]byte, n) + _, _ = rand.Read(b) for i := range b { - b[i] = letters[rand.Intn(len(letters))] + b[i] = letters[int(b[i])%len(letters)] } return string(b) } diff --git a/server/internal/model/user.go b/server/internal/model/user.go index 79879e4..a406f70 100644 --- a/server/internal/model/user.go +++ b/server/internal/model/user.go @@ -3,18 +3,19 @@ package model import "time" type User struct { - Id int `xorm:"int autoincr pk" json:"id"` - GroupId *int `xorm:"int" json:"groupId"` - Name string `xorm:"varchar(16)" json:"name"` - Password string `xorm:"varchar(64)" json:"-"` - Mobile string `xorm:"char(11)" json:"mobile"` - Email string `xorm:"varchar(32)" json:"email"` - Type string `xorm:"varchar(16) default 'user'" json:"type"` // user, groupAdmin, superAdmin - Enabled bool `xorm:"tinyint(1) default 1" json:"enabled"` - CreatedAt *time.Time `xorm:"datetime" json:"createdAt"` - UpdatedAt *time.Time `xorm:"datetime" json:"updatedAt"` - GroupName string `xorm:"-" json:"groupName,omitempty"` - GroupMaster bool `xorm:"-" json:"groupMaster,omitempty"` + Id int `xorm:"int autoincr pk" json:"id"` + GroupId *int `xorm:"int" json:"groupId"` + Name string `xorm:"varchar(16)" json:"name"` + Password string `xorm:"varchar(64)" json:"-"` + Mobile string `xorm:"char(11)" json:"mobile"` + Email string `xorm:"varchar(32)" json:"email"` + Type string `xorm:"varchar(16) default 'user'" json:"type"` // user, groupAdmin, superAdmin + Enabled bool `xorm:"tinyint(1) default 1" json:"enabled"` + CreatedAt *time.Time `xorm:"datetime" json:"createdAt"` + UpdatedAt *time.Time `xorm:"datetime" json:"updatedAt"` + LastLogoutAt *time.Time `xorm:"datetime" json:"-"` + GroupName string `xorm:"-" json:"groupName,omitempty"` + GroupMaster bool `xorm:"-" json:"groupMaster,omitempty"` } type UserPermission struct { diff --git a/server/internal/service/auth.go b/server/internal/service/auth.go index accb446..3e1314b 100644 --- a/server/internal/service/auth.go +++ b/server/internal/service/auth.go @@ -2,6 +2,7 @@ package service import ( "errors" + "fmt" "kenaito-conf/server/internal/dao" "kenaito-conf/server/internal/model" "strconv" @@ -47,12 +48,14 @@ func (s *AuthService) Login(userName, password string) (string, *model.LoginUser s.loadPermissions(userInfo, user.Id) // generate JWT + now := time.Now() token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ "userId": user.Id, "name": user.Name, "type": user.Type, "groupId": user.GroupId, - "exp": time.Now().Add(time.Duration(s.expireHrs) * time.Hour).Unix(), + "iat": now.Unix(), + "exp": now.Add(time.Duration(s.expireHrs) * time.Hour).Unix(), }) tokenString, err := token.SignedString([]byte(s.jwtSecret)) if err != nil { @@ -64,6 +67,9 @@ func (s *AuthService) Login(userName, password string) (string, *model.LoginUser func (s *AuthService) ValidateToken(tokenString string) (*model.LoginUserInfo, error) { token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) { + if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) + } return []byte(s.jwtSecret), nil }) if err != nil { @@ -82,6 +88,19 @@ func (s *AuthService) ValidateToken(tokenString string) (*model.LoginUserInfo, e return nil, errors.New("user not found") } + // 检查用户是否已被禁用 + if !user.Enabled { + return nil, errors.New("user is disabled") + } + + // 检查 token 是否在登出后签发 + if user.LastLogoutAt != nil { + iat := int64(claims["iat"].(float64)) + if iat <= user.LastLogoutAt.Unix() { + return nil, errors.New("token is invalid after logout") + } + } + userInfo := &model.LoginUserInfo{ Id: strconv.Itoa(user.Id), Name: user.Name, diff --git a/server/internal/service/config_parse.go b/server/internal/service/config_parse.go index f3b9249..b645485 100644 --- a/server/internal/service/config_parse.go +++ b/server/internal/service/config_parse.go @@ -133,6 +133,8 @@ func (s *ConfigParseService) resolvePlaceholders(result map[string]interface{}) func (s *ConfigParseService) replacePlaceholder(data map[string]interface{}, value string) string { result := value maxIterations := 100 + processedPlaceholders := make(map[string]bool) + for i := 0; i < maxIterations; i++ { start := strings.Index(result, "${") if start < 0 { @@ -154,6 +156,12 @@ func (s *ConfigParseService) replacePlaceholder(data map[string]interface{}, val defaultVal = placeholder[idx+1:] } + // 检测循环引用 + if processedPlaceholders[refKey] { + // 已经处理过这个占位符,说明存在循环引用,保留原样 + break + } + var replacement string if v, ok := data[refKey]; ok { replacement = fmt.Sprintf("%v", v) @@ -163,6 +171,9 @@ func (s *ConfigParseService) replacePlaceholder(data map[string]interface{}, val replacement = "${" + placeholder + "}" } + // 标记已处理 + processedPlaceholders[refKey] = true + result = result[:start] + replacement + result[end+1:] } return result diff --git a/server/internal/service/crypto.go b/server/internal/service/crypto.go deleted file mode 100644 index 097067f..0000000 --- a/server/internal/service/crypto.go +++ /dev/null @@ -1,115 +0,0 @@ -package service - -import ( - "crypto/aes" - "crypto/cipher" - "crypto/md5" - "encoding/base64" - "encoding/hex" - "fmt" - "kenaito-conf/server/internal/config" - "strings" -) - -const CryptPrefix = "{Cipher}" - -type CryptoService struct{} - -func NewCryptoService() *CryptoService { - return &CryptoService{} -} - -func (s *CryptoService) GetCryptKey(appId int, env string) string { - base := config.Cfg.Crypto.SecretKey + fmt.Sprintf("%x", appId) + env - h := md5.Sum([]byte(base)) - return hex.EncodeToString(h[:]) -} - -func (s *CryptoService) Encrypt(appId int, env string, data string) string { - key := s.GetCryptKey(appId, env) - data = strings.ReplaceAll(data, CryptPrefix, "") - encrypted := encryptWithAES(key, data) - return CryptPrefix + encrypted -} - -func (s *CryptoService) IsEncrypted(appId int, env string, data string) bool { - if !strings.HasPrefix(data, CryptPrefix) { - return false - } - key := s.GetCryptKey(appId, env) - _, err := decryptWithAES(key, strings.TrimPrefix(data, CryptPrefix)) - return err == nil -} - -func (s *CryptoService) Decrypt(secret string, data string) (string, error) { - return decryptWithAES(secret, data) -} - -func encryptWithAES(key string, data string) string { - secretKey := md5.Sum([]byte(key)) - aesKey := hex.EncodeToString(secretKey[:])[16:] - - block, err := aes.NewCipher([]byte(aesKey)) - if err != nil { - return "" - } - - // ECB mode (compatible with Java version) - plainText := pkcs7Pad([]byte(data), aes.BlockSize) - encrypted := make([]byte, len(plainText)) - mode := cipher.NewCBCEncrypter(block, make([]byte, aes.BlockSize)) - mode.CryptBlocks(encrypted, plainText) - - return base64.StdEncoding.EncodeToString(encrypted) -} - -func decryptWithAES(key string, data string) (string, error) { - secretKey := md5.Sum([]byte(key)) - aesKey := hex.EncodeToString(secretKey[:])[16:] - - cipherText, err := base64.StdEncoding.DecodeString(data) - if err != nil { - return "", err - } - - block, err := aes.NewCipher([]byte(aesKey)) - if err != nil { - return "", err - } - - if len(cipherText)%aes.BlockSize != 0 { - return "", fmt.Errorf("ciphertext is not a multiple of block size") - } - - // ECB mode (compatible with Java version) - plainText := make([]byte, len(cipherText)) - mode := cipher.NewCBCDecrypter(block, make([]byte, aes.BlockSize)) - mode.CryptBlocks(plainText, cipherText) - - plainText, err = pkcs7Unpad(plainText, aes.BlockSize) - if err != nil { - return "", err - } - - return string(plainText), nil -} - -func pkcs7Pad(data []byte, blockSize int) []byte { - padding := blockSize - len(data)%blockSize - padText := make([]byte, padding) - for i := range padText { - padText[i] = byte(padding) - } - return append(data, padText...) -} - -func pkcs7Unpad(data []byte, blockSize int) ([]byte, error) { - if len(data) == 0 { - return nil, fmt.Errorf("empty data") - } - padding := int(data[len(data)-1]) - if padding > blockSize || padding == 0 { - return nil, fmt.Errorf("invalid padding") - } - return data[:len(data)-padding], nil -} diff --git a/web/src/views/config/ConfigList.vue b/web/src/views/config/ConfigList.vue index 40d390e..ddf1b01 100644 --- a/web/src/views/config/ConfigList.vue +++ b/web/src/views/config/ConfigList.vue @@ -255,9 +255,9 @@ export default { const res = await getConfig(row.originId) if (res && res.data && res.data.contents) { const currentRaw = res.data.type === 3 ? (() => { try { return JSON.stringify(JSON.parse(res.data.contents), null, 2) } catch (e) { return res.data.contents } })() : res.data.contents - const result = computeDiff(currentRaw, formatted) - this.leftLines = result.left - this.rightLines = result.right + const result = computeDiff(formatted, currentRaw) + this.leftLines = result.right + this.rightLines = result.left } } catch (e) { this.rightLines = formatted.split('\n').map((line, i) => ({ lineNum: i + 1, text: line, type: 'unchanged' }))