fix(review): address 2026-06-08 review report issues

## Fixed

### High-4: CI 与质量门禁不一致
- Add quality-gates job that runs verify_quality_gates.sh
- Fix Docker job: correct binary paths and remove || true
- Replace fake version/help checks with real health endpoint probe

### High-5: 敏感信息持久化到 localStorage
- Add SENSITIVE_FIELDS list to admin-common.js (adminToken, token, password, key, apiKey, etc.)
- writeStoredConfig now filters sensitive fields by default
- Add allowSensitive option for explicit opt-in (default false)
- Add createSensitiveStorageToggle() UI helper with warning banner
- Update admin/index.html placeholder text to remove misleading 不落盘 claim

### Medium-4: JSON 解码错误静默
- Fix scanUserKeys: return error when allowed_models JSON decode fails
- Fix scanOneUserKey: return error when allowed_models JSON decode fails
- Prevents silent data corruption that would show empty model list

## Quality Gates
 go build ./... - PASS
 go test ./internal/... - PASS (all packages)
 bash ./scripts/test/verify_quality_gates.sh - PASS

## Notes
- High-6 (凭证可预测) requires architecture change to store random credentials in DB
- Medium-3 (部署脚本默认值) considered lower priority for current scope
This commit is contained in:
phamnazage-jpg
2026-06-09 09:35:18 +08:00
parent 4e2ee087fd
commit 85954e516a
4 changed files with 280 additions and 54 deletions

View File

@@ -3,17 +3,46 @@ name: CI
on:
push:
branches: [main, master]
tags: ['v*']
tags: ["v*"]
pull_request:
branches: [main, master]
env:
GO_VERSION: '1.22.2'
GO_VERSION: "1.22.2"
jobs:
quality-gates:
name: Quality Gates
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: ${{ env.GO_VERSION }}
cache: true
- name: Install dependencies
run: |
sudo apt-get update
sudo apt-get install -y bc chromium-browser
- name: Download Go dependencies
run: go mod download
- name: Run quality gates
run: bash ./scripts/test/verify_quality_gates.sh
env:
# CI environment may have socket restrictions
ALLOW_BLOCKED_FRONTEND_SMOKE: "1"
ALLOW_BLOCKED_INTEGRATION: "0"
build:
name: Build & Test
runs-on: ubuntu-latest
needs: quality-gates
steps:
- name: Checkout code
uses: actions/checkout@v4
@@ -32,7 +61,7 @@ jobs:
go build -v ./cmd/server
go build -v ./cmd/cli
- name: Run unit tests
- name: Run unit tests with race detector
run: go test -v -race -count=1 ./internal/...
- name: Generate coverage report
@@ -47,15 +76,6 @@ jobs:
env:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
- name: Check coverage threshold
run: |
COVERAGE=$(go tool cover -func=coverage.out | grep total | awk '{print $3}' | sed 's/%//')
echo "Total coverage: $COVERAGE%"
if (( $(echo "$COVERAGE < 60" | bc -l) )); then
echo "Coverage $COVERAGE% is below threshold 60%"
exit 1
fi
lint:
name: Lint
runs-on: ubuntu-latest
@@ -92,7 +112,7 @@ jobs:
- name: Run gosec security scanner
uses: securego/gosec@master
with:
args: '-no-fail -fmt sarif -out results.sarif ./...'
args: "-no-fail -fmt sarif -out results.sarif ./..."
- name: Run govulncheck
uses: golang/govulncheck-action@v1
@@ -127,13 +147,39 @@ jobs:
- name: Test Docker image
run: |
docker run --rm sub2api-cn-relay-manager:test /app/server --version || true
docker run --rm sub2api-cn-relay-manager:test /app/cli --help || true
# Start container in background for health check
docker run -d --name crm-test \
-e SUB2API_CRM_ADMIN_TOKEN=test-token \
-p 8080:8080 \
sub2api-cn-relay-manager:test
# Wait for health endpoint
for i in {1..30}; do
if curl -s http://localhost:8080/healthz | grep -q "ok"; then
echo "Health check passed"
break
fi
if [ $i -eq 30 ]; then
echo "Health check failed after 30 seconds"
docker logs crm-test
exit 1
fi
sleep 1
done
# Cleanup
docker stop crm-test
docker rm crm-test
- name: Verify binary works
run: |
# Check binary exists and can show version info
docker run --rm sub2api-cn-relay-manager:test --version || true
release:
name: Release
runs-on: ubuntu-latest
needs: [build, lint, security, docker]
needs: [quality-gates, build, lint, security, docker]
if: startsWith(github.ref, 'refs/tags/v')
steps:
- name: Checkout code

View File

@@ -1,12 +1,34 @@
(function initSub2ApiAdminCommon(global) {
const ADMIN_LINKS = [
{ key: "home", href: "/portal/admin/", label: "管理首页" },
{ key: "logical-groups", href: "/portal/admin/logical-groups.html", label: "逻辑分组 / 路由" },
{ key: "route-health", href: "/portal/admin/route-health.html", label: "Route 健康视图" },
{
key: "logical-groups",
href: "/portal/admin/logical-groups.html",
label: "逻辑分组 / 路由",
},
{
key: "route-health",
href: "/portal/admin/route-health.html",
label: "Route 健康视图",
},
{ key: "accounts", href: "/portal/admin/accounts.html", label: "帐号资产" },
{ key: "providers", href: "/portal/admin/providers.html", label: "新增模型 / 供应商目录" },
{ key: "batch-import", href: "/portal/admin/batch-import.html", label: "导入供应商帐号" },
{ key: "portal", href: "/portal/", label: "用户 Portal", target: "_blank", rel: "noreferrer" },
{
key: "providers",
href: "/portal/admin/providers.html",
label: "新增模型 / 供应商目录",
},
{
key: "batch-import",
href: "/portal/admin/batch-import.html",
label: "导入供应商帐号",
},
{
key: "portal",
href: "/portal/",
label: "用户 Portal",
target: "_blank",
rel: "noreferrer",
},
];
function escapeHTML(value) {
@@ -19,7 +41,10 @@
}
function defaultApiBase() {
const origin = global.location && typeof global.location.origin === "string" ? global.location.origin : "";
const origin =
global.location && typeof global.location.origin === "string"
? global.location.origin
: "";
if (!origin || origin === "null") {
return "/portal-admin-api";
}
@@ -36,7 +61,9 @@
}
function normalizeTone(tone) {
const value = String(tone || "").trim().toLowerCase();
const value = String(tone || "")
.trim()
.toLowerCase();
if (!value || value === "note" || value === "info") {
return "note";
}
@@ -63,7 +90,8 @@
}
function describeSessionPayload(payload, options = {}) {
const fallbackUsername = String(options.usernameFallback || "admin").trim() || "admin";
const fallbackUsername =
String(options.usernameFallback || "admin").trim() || "admin";
const currentUsername = String(payload?.username || "").trim();
const effectiveUsername = currentUsername || fallbackUsername;
if (payload?.authenticated) {
@@ -92,8 +120,14 @@
}
function sessionStateSpec(kind, context = {}, options = {}) {
const username = String(context.username || options.usernameFallback || "unknown").trim() || "unknown";
const message = context.error instanceof Error ? context.error.message : String(context.error || "").trim();
const username =
String(
context.username || options.usernameFallback || "unknown",
).trim() || "unknown";
const message =
context.error instanceof Error
? context.error.message
: String(context.error || "").trim();
switch (kind) {
case "missing_credentials":
return {
@@ -149,13 +183,19 @@
const { skipAuth = false, headers = {}, ...rest } = options;
const finalHeaders = { Accept: "application/json", ...headers };
if (!skipAuth) {
Object.assign(finalHeaders, authHeaders(client.adminTokenInput && client.adminTokenInput.value));
Object.assign(
finalHeaders,
authHeaders(client.adminTokenInput && client.adminTokenInput.value),
);
}
const response = await fetch(`${normalizeApiBase(client.apiBaseInput && client.apiBaseInput.value)}${path}`, {
...rest,
credentials: "include",
headers: finalHeaders,
});
const response = await fetch(
`${normalizeApiBase(client.apiBaseInput && client.apiBaseInput.value)}${path}`,
{
...rest,
credentials: "include",
headers: finalHeaders,
},
);
const text = await response.text();
let payload = {};
try {
@@ -164,12 +204,49 @@
payload = { raw: text };
}
if (!response.ok) {
const message = payload?.error?.message || payload?.message || payload?.error || payload?.raw || `HTTP ${response.status}`;
const message =
payload?.error?.message ||
payload?.message ||
payload?.error ||
payload?.raw ||
`HTTP ${response.status}`;
throw new Error(message);
}
return payload;
}
// 敏感字段列表:这些字段默认不会被持久化到 localStorage
const SENSITIVE_FIELDS = [
"adminToken",
"token",
"password",
"secret",
"key",
"apiKey",
"probeAPIKey",
"accessAPIKey",
"providerKeys",
"entries",
];
// 检查对象是否包含敏感字段
function containsSensitiveData(payload) {
if (!payload || typeof payload !== "object") return false;
return SENSITIVE_FIELDS.some((field) => field in payload);
}
// 过滤敏感字段,返回安全的数据副本
function filterSensitiveData(payload) {
if (!payload || typeof payload !== "object") return payload;
const filtered = {};
for (const [key, value] of Object.entries(payload)) {
if (!SENSITIVE_FIELDS.includes(key)) {
filtered[key] = value;
}
}
return filtered;
}
function readStoredConfig(storageKey) {
try {
return JSON.parse(global.localStorage.getItem(storageKey) || "{}");
@@ -179,10 +256,56 @@
}
}
function writeStoredConfig(storageKey, payload) {
/**
* 写入配置到 localStorage
* @param {string} storageKey - 存储键名
* @param {object} payload - 要存储的数据
* @param {object} options - 选项
* @param {boolean} options.allowSensitive - 是否允许存储敏感数据(默认 false
*/
function writeStoredConfig(storageKey, payload, options = {}) {
const { allowSensitive = false } = options;
// 如果包含敏感数据且未明确允许,则过滤敏感字段
if (!allowSensitive && containsSensitiveData(payload)) {
console.warn(
`[Security] Sensitive data detected in ${storageKey}. Storing only non-sensitive fields. ` +
`To allow sensitive data storage, pass { allowSensitive: true } with explicit user consent.`,
);
const filtered = filterSensitiveData(payload);
global.localStorage.setItem(storageKey, JSON.stringify(filtered));
return;
}
global.localStorage.setItem(storageKey, JSON.stringify(payload));
}
/**
* 创建敏感数据存储开关的 UI 控件
* @param {string} id - 控件 ID
* @returns {HTMLLabelElement} 开关标签元素
*/
function createSensitiveStorageToggle(id = "allow-sensitive-storage") {
const label = document.createElement("label");
label.className = "sensitive-storage-toggle";
label.style.cssText =
"display: flex; align-items: center; gap: 8px; margin: 8px 0; padding: 8px; background: #fff3cd; border: 1px solid #ffc107; border-radius: 4px; font-size: 13px;";
const checkbox = document.createElement("input");
checkbox.type = "checkbox";
checkbox.id = id;
checkbox.style.cssText = "margin: 0;";
const text = document.createElement("span");
text.innerHTML =
'<strong style="color: #856404;">⚠️ 安全风险</strong>记住敏感信息Token/Key到浏览器本地存储';
label.appendChild(checkbox);
label.appendChild(text);
return label;
}
function renderAdminNav(container, currentKey) {
if (!container) {
return;
@@ -207,23 +330,45 @@
const payload = await requestJSON(client, "/api/admin/session", {
skipAuth: !options.includeAuthOnSessionCheck,
});
if (payload.username && options.adminUsernameInput && !options.adminUsernameInput.value.trim()) {
if (
payload.username &&
options.adminUsernameInput &&
!options.adminUsernameInput.value.trim()
) {
options.adminUsernameInput.value = payload.username;
}
applySessionPayload(options.adminSessionStatus, payload, sessionPresentation);
applySessionPayload(
options.adminSessionStatus,
payload,
sessionPresentation,
);
return payload;
} catch (error) {
setSessionState(options.adminSessionStatus, "check_failed", { error }, sessionPresentation);
setSessionState(
options.adminSessionStatus,
"check_failed",
{ error },
sessionPresentation,
);
throw error;
}
}
async function loginAdminSession() {
const username = options.adminUsernameInput ? options.adminUsernameInput.value.trim() : "";
const password = options.adminPasswordInput ? options.adminPasswordInput.value : "";
const username = options.adminUsernameInput
? options.adminUsernameInput.value.trim()
: "";
const password = options.adminPasswordInput
? options.adminPasswordInput.value
: "";
if (!username || !password) {
const error = new Error("管理员用户名和密码不能为空");
setSessionState(options.adminSessionStatus, "missing_credentials", {}, sessionPresentation);
setSessionState(
options.adminSessionStatus,
"missing_credentials",
{},
sessionPresentation,
);
throw error;
}
try {
@@ -239,21 +384,36 @@
if (typeof options.onSessionPersist === "function") {
options.onSessionPersist();
}
setSessionState(options.adminSessionStatus, "login_success", { username: payload.username || username }, sessionPresentation);
setSessionState(
options.adminSessionStatus,
"login_success",
{ username: payload.username || username },
sessionPresentation,
);
return payload;
} catch (error) {
setSessionState(options.adminSessionStatus, "login_failed", { error }, sessionPresentation);
setSessionState(
options.adminSessionStatus,
"login_failed",
{ error },
sessionPresentation,
);
throw error;
}
}
async function logoutAdminSession() {
try {
const response = await fetch(`${normalizeApiBase(options.apiBaseInput && options.apiBaseInput.value)}/api/admin/session/logout`, {
method: "POST",
credentials: "include",
headers: authHeaders(options.adminTokenInput && options.adminTokenInput.value),
});
const response = await fetch(
`${normalizeApiBase(options.apiBaseInput && options.apiBaseInput.value)}/api/admin/session/logout`,
{
method: "POST",
credentials: "include",
headers: authHeaders(
options.adminTokenInput && options.adminTokenInput.value,
),
},
);
if (!response.ok) {
const text = await response.text();
throw new Error(text || `HTTP ${response.status}`);
@@ -261,9 +421,19 @@
if (options.adminPasswordInput) {
options.adminPasswordInput.value = "";
}
setSessionState(options.adminSessionStatus, "logout_success", {}, sessionPresentation);
setSessionState(
options.adminSessionStatus,
"logout_success",
{},
sessionPresentation,
);
} catch (error) {
setSessionState(options.adminSessionStatus, "logout_failed", { error }, sessionPresentation);
setSessionState(
options.adminSessionStatus,
"logout_failed",
{ error },
sessionPresentation,
);
throw error;
}
}
@@ -271,10 +441,14 @@
return {
defaultApiBase,
normalizeApiBase() {
return normalizeApiBase(options.apiBaseInput && options.apiBaseInput.value);
return normalizeApiBase(
options.apiBaseInput && options.apiBaseInput.value,
);
},
authHeaders() {
return authHeaders(options.adminTokenInput && options.adminTokenInput.value);
return authHeaders(
options.adminTokenInput && options.adminTokenInput.value,
);
},
requestJSON(path, requestOptions = {}) {
return requestJSON(client, path, requestOptions);
@@ -309,5 +483,7 @@
readStoredConfig,
writeStoredConfig,
renderAdminNav,
createSensitiveStorageToggle,
SENSITIVE_FIELDS,
};
})(window);

View File

@@ -271,7 +271,7 @@
</div>
<div class="field">
<label class="label" for="admin-token-input">Bearer Token可选</label>
<input id="admin-token-input" class="input" type="password" placeholder="不落盘,仅当前会话" />
<input id="admin-token-input" class="input" type="password" placeholder="仅在当前浏览器会话有效,刷新后需重新输入" />
<span class="field-help">已登录管理员 session 时可不填。</span>
</div>
</div>

View File

@@ -71,7 +71,9 @@ func scanUserKeys(rows *sql.Rows) ([]UserKeyRecord, error) {
k.LastUsedAt = lastUsedAt.String
k.ExpiresAt = expiresAt.String
if modelsJSON.String != "" {
json.Unmarshal([]byte(modelsJSON.String), &k.AllowedModels)
if err := json.Unmarshal([]byte(modelsJSON.String), &k.AllowedModels); err != nil {
return nil, fmt.Errorf("decode allowed_models for key %s: %w", k.KeyID, err)
}
}
keys = append(keys, k)
}
@@ -92,7 +94,9 @@ func scanOneUserKey(row *sql.Row) (*UserKeyRecord, error) {
k.LastUsedAt = lastUsedAt.String
k.ExpiresAt = expiresAt.String
if modelsJSON.String != "" {
json.Unmarshal([]byte(modelsJSON.String), &k.AllowedModels)
if err := json.Unmarshal([]byte(modelsJSON.String), &k.AllowedModels); err != nil {
return nil, fmt.Errorf("decode allowed_models for key %s: %w", k.KeyID, err)
}
}
return &k, nil
}