forked from dsh/panel-auth
feat: styled login page + structured JSONL audit logging
- Browser navigation now gets a self-contained login page (no JS, CSP hardened, XSS-escaped, cross-origin POST rejected) instead of the native Basic dialog; API clients keep 401 + WWW-Authenticate. - Login/logout endpoints (/panel-auth/login, /panel-auth/logout) issue and revoke the signed cookie, then 303 back to the original target. - Structured audit log (login-ok/login-fail/logout/challenge/reject with username, IP, UA, reason), default $DSH_HOME/panel-auth-audit.jsonl, 5MB rotation; X-Forwarded-For honored behind the reverse proxy. - Function-plugin form, per-request config, fail-open when unconfigured. - Tests extended to 15 flows including login page, login POST, logout, audit assertions and XSS escaping.
This commit is contained in:
@@ -1,17 +1,39 @@
|
|||||||
# panel-auth — DSH 面板访问密码插件
|
# panel-auth — DSH 面板访问密码插件
|
||||||
|
|
||||||
给 `dsh web` 面板加一层原生 HTTP 认证(Basic + 签名 Cookie),挂载在
|
给 `dsh web` 面板加一层原生 HTTP 认证(自绘登录页 + Basic + 签名 Cookie),
|
||||||
`/root/.dsh/profiles/web/cordis.patch.yml` 的用户补丁层,升级 DSH 不会丢。
|
挂载在 `/root/.dsh/profiles/web/cordis.patch.yml` 的用户补丁层,升级 DSH 不会丢。
|
||||||
|
|
||||||
## 认证逻辑
|
## 认证逻辑
|
||||||
|
|
||||||
- 每个 HTTP 请求 / WebSocket 升级都必须携带以下二者之一:
|
- 浏览器访问(`Accept: text/html`):匿名时返回**内置登录页**(无 JS、CSP 收紧),
|
||||||
1. 有效的 `dsh_panel` 签名 Cookie(登录成功后签发,浏览器自动带上,WS 握手也带);
|
表单 POST 到 `/panel-auth/login`,成功后签发 Cookie 并跳回原目标地址。
|
||||||
2. 有效的 HTTP Basic 凭据(`config.users` 中的用户名 + scrypt 哈希)。
|
- API / 脚本(fetch、curl 等):保持经典 `401 + WWW-Authenticate: Basic` 契约,
|
||||||
- 匿名请求返回 `401` + `WWW-Authenticate`,浏览器弹原生密码框。
|
仍可用 Basic 凭据直接调用,成功后同样签发 Cookie。
|
||||||
- 凭 Basic 成功登录时签发 Cookie(默认 30 天),之后无需重复输入。
|
- WebSocket 升级:匿名一律 `401`,带 Cookie 放行(浏览器对 WS 不保证携带
|
||||||
- **Fail-open**:`users` 为空或 `secret` 缺失/过短时插件不拦截任何请求,
|
Authorization 头,因此依赖 Cookie)。
|
||||||
配置写错不会把面板锁死。
|
- `/panel-auth/logout`:清除 Cookie 并回到登录页。
|
||||||
|
- Cookie 默认 30 天(`cookieTtlSeconds`),HttpOnly + SameSite=Lax。
|
||||||
|
- **Fail-open**:`users` 为空或 `secret` 缺失/过短时不拦截任何请求,
|
||||||
|
配置写错不会把面板锁死。配置每次请求实时读取,改密码热生效。
|
||||||
|
|
||||||
|
## 登录日志(审计)
|
||||||
|
|
||||||
|
- 所有登录活动写入结构化 JSONL 文件,默认
|
||||||
|
`/root/.dsh/panel-auth-audit.jsonl`(可用 `auditLogPath` 配置修改)。
|
||||||
|
- 事件类型:
|
||||||
|
| event | 含义 |
|
||||||
|
| --- | --- |
|
||||||
|
| `login-ok` | 登录成功(含用户名、IP、User-Agent) |
|
||||||
|
| `login-fail` | 登录失败(含用户名、IP、UA、`reason`:`bad-credentials` / `missing-fields` / `cross-origin` / `body-too-large`) |
|
||||||
|
| `logout` | 主动登出 |
|
||||||
|
| `challenge` | 匿名浏览器导航被重定向到登录页 |
|
||||||
|
| `reject` | 匿名 API / WebSocket 请求被拒绝(含方法、路径) |
|
||||||
|
- 每条含 `ts`(ISO 时间)、`ip`(优先 `X-Forwarded-For`,见下)、`ua`。
|
||||||
|
- 文件超过 5MB 自动轮转为 `<path>.1`。
|
||||||
|
- 查看示例:`tail -f /root/.dsh/panel-auth-audit.jsonl | jq .`
|
||||||
|
|
||||||
|
> IP 说明:面板经 Caddy 反代时,直连 socket 是回环地址;插件在检测到回环
|
||||||
|
> 来源时会取 `X-Forwarded-For` 的首个值作为真实 IP(Caddy 默认会带上该头)。
|
||||||
|
|
||||||
## 修改密码
|
## 修改密码
|
||||||
|
|
||||||
@@ -20,11 +42,8 @@ cd /root/.dsh/profiles/web/panel-auth
|
|||||||
node hash.js admin 新密码 # 输出新 passwordHash
|
node hash.js admin 新密码 # 输出新 passwordHash
|
||||||
```
|
```
|
||||||
|
|
||||||
把输出的 `passwordHash` 替换进 `../cordis.patch.yml` 后:
|
把输出的 `passwordHash` 替换进 `../cordis.patch.yml` 后(loader HMR 热应用;
|
||||||
|
如未生效则 `systemctl restart dsh-web`)。
|
||||||
```bash
|
|
||||||
systemctl restart dsh-web # 或利用 loader 对 cordis.patch.yml 的 HMR 自动生效
|
|
||||||
```
|
|
||||||
|
|
||||||
## 新增用户
|
## 新增用户
|
||||||
|
|
||||||
@@ -37,7 +56,7 @@ node hash.js --secret # 生成新 secret
|
|||||||
```
|
```
|
||||||
|
|
||||||
替换 `cordis.patch.yml` 的 `secret` 后重启。注意:更换密钥会使所有已签发
|
替换 `cordis.patch.yml` 的 `secret` 后重启。注意:更换密钥会使所有已签发
|
||||||
Cookie 立即失效,所有人需重新输入密码。
|
Cookie 立即失效,所有人需重新登录。
|
||||||
|
|
||||||
## 应急解锁(忘记密码时)
|
## 应急解锁(忘记密码时)
|
||||||
|
|
||||||
@@ -45,10 +64,9 @@ Cookie 立即失效,所有人需重新输入密码。
|
|||||||
- 临时把 `users` 置为 `[]`(fail-open,面板恢复无密码状态),或
|
- 临时把 `users` 置为 `[]`(fail-open,面板恢复无密码状态),或
|
||||||
- 用上面的命令生成新哈希替换。
|
- 用上面的命令生成新哈希替换。
|
||||||
|
|
||||||
改动会由 loader 的用户补丁层 HMR 热应用,无需重启。
|
|
||||||
|
|
||||||
## 文件
|
## 文件
|
||||||
|
|
||||||
- `index.js` — 插件本体(webServer 包装 + 认证守卫)
|
- `index.js` — 插件本体(登录页、认证守卫、审计日志、webServer 包装)
|
||||||
- `crypto.js` — scrypt 哈希 / HMAC Cookie 签名(无依赖,纯 node:crypto)
|
- `crypto.js` — scrypt 哈希 / HMAC Cookie 签名(无依赖,纯 node:crypto)
|
||||||
- `hash.js` — 生成密码哈希与随机密钥的 CLI
|
- `hash.js` — 生成密码哈希与随机密钥的 CLI
|
||||||
|
- `test.mjs` — 单元测试(`node test.mjs`,覆盖 15 组流程)
|
||||||
@@ -3,11 +3,13 @@
|
|||||||
// A web-profile host plugin. It wraps the panel's node:http server
|
// A web-profile host plugin. It wraps the panel's node:http server
|
||||||
// (the `webServer` service) and requires one of, on EVERY request and
|
// (the `webServer` service) and requires one of, on EVERY request and
|
||||||
// WebSocket upgrade:
|
// WebSocket upgrade:
|
||||||
// 1. a valid signed session cookie (dsh_panel), issued below, or
|
// 1. a valid signed session cookie (dsh_panel), issued at login, or
|
||||||
// 2. a valid HTTP Basic credential from `config.users`.
|
// 2. a valid HTTP Basic credential from `config.users`.
|
||||||
//
|
//
|
||||||
// The cookie exists because browser WebSocket handshakes always carry
|
// Browsers get a self-contained login page instead of the native Basic
|
||||||
// cookies but do not reliably carry Authorization headers.
|
// dialog; API clients keep the classic 401 + WWW-Authenticate contract.
|
||||||
|
// Login activity (success, failure, logout, rejected requests) is written
|
||||||
|
// to a structured JSONL audit file.
|
||||||
//
|
//
|
||||||
// Fail-open by design: with no users or no secret configured the guard
|
// Fail-open by design: with no users or no secret configured the guard
|
||||||
// passes everything through, so a broken config can never lock the panel
|
// passes everything through, so a broken config can never lock the panel
|
||||||
@@ -24,17 +26,56 @@
|
|||||||
// secret: '<32+ chars of random text, keep private>'
|
// secret: '<32+ chars of random text, keep private>'
|
||||||
// cookieName: dsh_panel
|
// cookieName: dsh_panel
|
||||||
// cookieTtlSeconds: 2592000
|
// cookieTtlSeconds: 2592000
|
||||||
|
// auditLogPath: '/root/.dsh/panel-auth-audit.jsonl' # optional
|
||||||
|
// loginPath: '/panel-auth/login' # optional
|
||||||
|
// logoutPath: '/panel-auth/logout' # optional
|
||||||
// users:
|
// users:
|
||||||
// - username: admin
|
// - username: admin
|
||||||
// passwordHash: '<output of: node panel-auth/hash.js admin <password>>'
|
// passwordHash: '<output of: node panel-auth/hash.js admin <password>>'
|
||||||
|
import { appendFileSync, renameSync, statSync } from 'node:fs'
|
||||||
|
import { homedir } from 'node:os'
|
||||||
|
import { resolve } from 'node:path'
|
||||||
import { verifyPassword, signSession, verifySession } from './crypto.js'
|
import { verifyPassword, signSession, verifySession } from './crypto.js'
|
||||||
|
|
||||||
const DEFAULTS = {
|
const DEFAULTS = {
|
||||||
realm: 'DSH Panel',
|
realm: 'DSH Panel',
|
||||||
cookieName: 'dsh_panel',
|
cookieName: 'dsh_panel',
|
||||||
cookieTtlSeconds: 60 * 60 * 24 * 30, // 30 days
|
cookieTtlSeconds: 60 * 60 * 24 * 30, // 30 days
|
||||||
|
loginPath: '/panel-auth/login',
|
||||||
|
logoutPath: '/panel-auth/logout',
|
||||||
|
auditMaxBytes: 5 * 1024 * 1024,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── audit log ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function createAuditWriter(filePath) {
|
||||||
|
function rotateIfNeeded() {
|
||||||
|
try {
|
||||||
|
if (statSync(filePath).size > DEFAULTS.auditMaxBytes) renameSync(filePath, `${filePath}.1`)
|
||||||
|
} catch {
|
||||||
|
/* first write or concurrent rotation — ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
path: filePath,
|
||||||
|
write(entry) {
|
||||||
|
try {
|
||||||
|
appendFileSync(filePath, JSON.stringify({ ts: new Date().toISOString(), ...entry }) + '\n', { mode: 0o600 })
|
||||||
|
rotateIfNeeded()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[panel-auth] audit write failed:', error && error.message ? error.message : error)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function defaultAuditPath() {
|
||||||
|
const home = process.env.DSH_HOME || resolve(homedir(), '.dsh')
|
||||||
|
return resolve(home, 'panel-auth-audit.jsonl')
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── guard ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function createGuard({ getConfig, logger }) {
|
export function createGuard({ getConfig, logger }) {
|
||||||
function current() {
|
function current() {
|
||||||
const cfg = getConfig() ?? {}
|
const cfg = getConfig() ?? {}
|
||||||
@@ -103,6 +144,14 @@ export function createGuard({ getConfig, logger }) {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function checkCredentials(username, password) {
|
||||||
|
const cfg = current()
|
||||||
|
if (!cfg.enabled) return false
|
||||||
|
const found = cfg.users.find((u) => u.username === username)
|
||||||
|
if (found === undefined) return false
|
||||||
|
return verifyPassword(password, found.passwordHash)
|
||||||
|
}
|
||||||
|
|
||||||
function issueCookieValue(username) {
|
function issueCookieValue(username) {
|
||||||
const cfg = current()
|
const cfg = current()
|
||||||
return signSession(cfg.secret, username, Math.floor(Date.now() / 1000) + cfg.ttl)
|
return signSession(cfg.secret, username, Math.floor(Date.now() / 1000) + cfg.ttl)
|
||||||
@@ -117,29 +166,359 @@ export function createGuard({ getConfig, logger }) {
|
|||||||
return current().realm
|
return current().realm
|
||||||
}
|
}
|
||||||
|
|
||||||
return { authenticate, issueCookieValue, cookieSettings, realm }
|
return { authenticate, checkCredentials, issueCookieValue, cookieSettings, realm }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── login page ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function escapeHtml(value) {
|
||||||
|
return String(value)
|
||||||
|
.replaceAll('&', '&')
|
||||||
|
.replaceAll('<', '<')
|
||||||
|
.replaceAll('>', '>')
|
||||||
|
.replaceAll('"', '"')
|
||||||
|
.replaceAll("'", ''')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderLoginPage({ realm, next = '/', error = '', loginPath = DEFAULTS.loginPath }) {
|
||||||
|
const safeNext = escapeHtml(next)
|
||||||
|
const safeError = escapeHtml(error)
|
||||||
|
const safeRealm = escapeHtml(realm)
|
||||||
|
const safeAction = escapeHtml(loginPath)
|
||||||
|
return `<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; img-src data:; form-action 'self'; base-uri 'none'">
|
||||||
|
<meta name="referrer" content="no-referrer">
|
||||||
|
<title>${safeRealm} · 登录</title>
|
||||||
|
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='8' fill='%236d8dff'/%3E%3Cpath d='M16 8a5 5 0 0 0-5 5v3h-2a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-8a2 2 0 0 0-2-2h-2v-3a5 5 0 0 0-5-5Zm-3 8v-3a3 3 0 1 1 6 0v3h-6Z' fill='white'/%3E%3C/svg%3E">
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--accent: #6d8dff;
|
||||||
|
--accent-2: #8b5cf6;
|
||||||
|
--danger: #f87171;
|
||||||
|
--text: #e8eaf2;
|
||||||
|
--text-dim: #9aa1b5;
|
||||||
|
--field-bg: rgba(255, 255, 255, 0.06);
|
||||||
|
--field-border: rgba(255, 255, 255, 0.12);
|
||||||
|
--card-bg: rgba(22, 26, 40, 0.82);
|
||||||
|
--card-border: rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
html, body { height: 100%; }
|
||||||
|
body {
|
||||||
|
font-family: "Segoe UI", "PingFang SC", "Microsoft YaHei", system-ui, -apple-system, sans-serif;
|
||||||
|
color: var(--text);
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
min-height: 100%;
|
||||||
|
padding: 24px;
|
||||||
|
background:
|
||||||
|
radial-gradient(1200px 600px at 15% -10%, rgba(109, 141, 255, 0.28), transparent 60%),
|
||||||
|
radial-gradient(1000px 700px at 110% 15%, rgba(139, 92, 246, 0.22), transparent 55%),
|
||||||
|
radial-gradient(900px 900px at 50% 120%, rgba(45, 55, 90, 0.9), transparent 60%),
|
||||||
|
#0d1020;
|
||||||
|
background-attachment: fixed;
|
||||||
|
animation: bg-breathe 14s ease-in-out infinite alternate;
|
||||||
|
}
|
||||||
|
@keyframes bg-breathe {
|
||||||
|
from { filter: saturate(1); }
|
||||||
|
to { filter: saturate(1.25); }
|
||||||
|
}
|
||||||
|
.card {
|
||||||
|
width: min(400px, 100%);
|
||||||
|
background: var(--card-bg);
|
||||||
|
border: 1px solid var(--card-border);
|
||||||
|
border-radius: 18px;
|
||||||
|
padding: 40px 36px 30px;
|
||||||
|
backdrop-filter: blur(14px);
|
||||||
|
-webkit-backdrop-filter: blur(14px);
|
||||||
|
box-shadow: 0 24px 60px rgba(0, 0, 0, 0.45), 0 0 0 1px rgba(109, 141, 255, 0.06) inset;
|
||||||
|
animation: card-in 0.5s cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||||
|
}
|
||||||
|
@keyframes card-in {
|
||||||
|
from { opacity: 0; transform: translateY(14px) scale(0.985); }
|
||||||
|
to { opacity: 1; transform: none; }
|
||||||
|
}
|
||||||
|
.logo {
|
||||||
|
width: 60px; height: 60px;
|
||||||
|
margin: 0 auto 18px;
|
||||||
|
border-radius: 17px;
|
||||||
|
display: grid; place-items: center;
|
||||||
|
background: linear-gradient(135deg, var(--accent), var(--accent-2));
|
||||||
|
box-shadow: 0 10px 26px rgba(109, 141, 255, 0.35);
|
||||||
|
}
|
||||||
|
.logo svg { width: 30px; height: 30px; }
|
||||||
|
h1 {
|
||||||
|
font-size: 24px; font-weight: 650; text-align: center; letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
.subtitle {
|
||||||
|
text-align: center; color: var(--text-dim);
|
||||||
|
font-size: 13.5px; margin: 8px 0 26px;
|
||||||
|
}
|
||||||
|
.error {
|
||||||
|
display: flex; align-items: center; gap: 8px;
|
||||||
|
background: rgba(248, 113, 113, 0.1);
|
||||||
|
border: 1px solid rgba(248, 113, 113, 0.35);
|
||||||
|
color: var(--danger);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
font-size: 13.5px;
|
||||||
|
margin-bottom: 18px;
|
||||||
|
animation: card-in 0.3s ease both;
|
||||||
|
}
|
||||||
|
.error svg { flex: none; width: 16px; height: 16px; }
|
||||||
|
label {
|
||||||
|
display: block;
|
||||||
|
font-size: 12px; color: var(--text-dim);
|
||||||
|
letter-spacing: 0.06em; text-transform: uppercase;
|
||||||
|
margin: 0 0 6px 2px;
|
||||||
|
}
|
||||||
|
.field { margin-bottom: 16px; }
|
||||||
|
input {
|
||||||
|
width: 100%;
|
||||||
|
background: var(--field-bg);
|
||||||
|
border: 1px solid var(--field-border);
|
||||||
|
border-radius: 11px;
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 15px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.18s ease, box-shadow 0.18s ease, background 0.18s ease;
|
||||||
|
}
|
||||||
|
input::placeholder { color: #5a6178; }
|
||||||
|
input:hover { border-color: rgba(255, 255, 255, 0.22); }
|
||||||
|
input:focus {
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: 0 0 0 3px rgba(109, 141, 255, 0.22);
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 8px;
|
||||||
|
padding: 12px 0;
|
||||||
|
font-size: 15px; font-weight: 600; color: #fff;
|
||||||
|
border: none; border-radius: 11px; cursor: pointer;
|
||||||
|
background: linear-gradient(135deg, var(--accent), var(--accent-2));
|
||||||
|
box-shadow: 0 8px 22px rgba(109, 141, 255, 0.32);
|
||||||
|
transition: transform 0.15s ease, box-shadow 0.15s ease, filter 0.15s ease;
|
||||||
|
}
|
||||||
|
button:hover { transform: translateY(-1px); filter: brightness(1.08); box-shadow: 0 12px 26px rgba(109, 141, 255, 0.4); }
|
||||||
|
button:active { transform: translateY(0); filter: brightness(0.96); }
|
||||||
|
.foot {
|
||||||
|
margin-top: 22px; text-align: center;
|
||||||
|
font-size: 12px; color: #565d73; line-height: 1.7;
|
||||||
|
}
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
* { animation: none !important; transition: none !important; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<form class="card" method="post" action="${safeAction}" autocomplete="off">
|
||||||
|
<div class="logo" aria-hidden="true">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none">
|
||||||
|
<rect x="4" y="10" width="16" height="10" rx="2.5" stroke="white" stroke-width="1.8"/>
|
||||||
|
<path d="M8 10V7a4 4 0 0 1 8 0v3" stroke="white" stroke-width="1.8" stroke-linecap="round"/>
|
||||||
|
<circle cx="12" cy="15" r="1.6" fill="white"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h1>${safeRealm}</h1>
|
||||||
|
<p class="subtitle">登录以继续 · Sign in to continue</p>
|
||||||
|
${safeError ? `<div class="error" role="alert">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="9" stroke="currentColor" stroke-width="1.8"/><path d="M12 7.5v5.5" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/><circle cx="12" cy="16.6" r="1.2" fill="currentColor"/></svg>
|
||||||
|
<span>${safeError}</span>
|
||||||
|
</div>` : ''}
|
||||||
|
<div class="field">
|
||||||
|
<label for="username">用户名</label>
|
||||||
|
<input id="username" name="username" type="text" placeholder="admin" required autofocus>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="password">密码</label>
|
||||||
|
<input id="password" name="password" type="password" placeholder="••••••••" required>
|
||||||
|
</div>
|
||||||
|
<input type="hidden" name="next" value="${safeNext}">
|
||||||
|
<button type="submit">登 录</button>
|
||||||
|
<p class="foot">登录活动将被记录<br>Protected by panel-auth</p>
|
||||||
|
</form>
|
||||||
|
</body>
|
||||||
|
</html>`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── request helpers ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function clientIp(req) {
|
||||||
|
const remote = req.socket?.remoteAddress ?? ''
|
||||||
|
const loopback = remote === '127.0.0.1' || remote === '::1' || remote === '::ffff:127.0.0.1'
|
||||||
|
if (loopback) {
|
||||||
|
const forwarded = req.headers['x-forwarded-for']
|
||||||
|
if (typeof forwarded === 'string' && forwarded.length > 0) {
|
||||||
|
const first = forwarded.split(',')[0].trim()
|
||||||
|
if (first.length > 0) return first
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return remote
|
||||||
|
}
|
||||||
|
|
||||||
|
function acceptsHtml(req) {
|
||||||
|
const accept = req.headers.accept
|
||||||
|
return typeof accept === 'string' && accept.includes('text/html')
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeNext(next) {
|
||||||
|
if (typeof next !== 'string' || next.length === 0 || next.length > 2048) return '/'
|
||||||
|
if (!next.startsWith('/') || next.startsWith('//')) return '/'
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
function readBody(req, maxBytes) {
|
||||||
|
return new Promise((resolveBody) => {
|
||||||
|
let size = 0
|
||||||
|
const chunks = []
|
||||||
|
let done = false
|
||||||
|
const finish = () => {
|
||||||
|
if (done) return
|
||||||
|
done = true
|
||||||
|
resolveBody(Buffer.concat(chunks).toString('utf8'))
|
||||||
|
}
|
||||||
|
req.on('data', (chunk) => {
|
||||||
|
size += chunk.length
|
||||||
|
if (size > maxBytes) {
|
||||||
|
done = true
|
||||||
|
resolveBody(null)
|
||||||
|
req.destroy()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
chunks.push(chunk)
|
||||||
|
})
|
||||||
|
req.on('end', finish)
|
||||||
|
req.on('error', finish)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── server wrap ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Atomically wrap the panel's http.Server: replace its 'request' and
|
* Atomically wrap the panel's http.Server: replace its 'request' and
|
||||||
* 'upgrade' listeners with an auth gate that forwards to the originals.
|
* 'upgrade' listeners with an auth gate that forwards to the originals.
|
||||||
* Returns a disposer that restores the exact original listeners.
|
* Returns a disposer that restores the exact original listeners.
|
||||||
*/
|
*/
|
||||||
export function installGuard(server, guard, { logger }) {
|
export function installGuard(server, guard, { audit, loginPath, logoutPath }) {
|
||||||
const origRequest = server.listeners('request')
|
const origRequest = server.listeners('request')
|
||||||
const origUpgrade = server.listeners('upgrade')
|
const origUpgrade = server.listeners('upgrade')
|
||||||
|
|
||||||
const challenge = (res) => {
|
const challenge = (res) => {
|
||||||
res.statusCode = 401
|
res.statusCode = 401
|
||||||
res.setHeader('WWW-Authenticate', `Basic realm="${guard.realm()}", charset="UTF-8"`)
|
res.setHeader('WWW-Authenticate', `Basic realm="${guard.realm()}", charset="UTF-8"`)
|
||||||
|
res.setHeader('Content-Type', 'application/json; charset=utf-8')
|
||||||
|
res.setHeader('Cache-Control', 'no-store')
|
||||||
|
res.setHeader('Content-Length', String(Buffer.byteLength('{"error":"authentication required"}')))
|
||||||
|
res.end('{"error":"authentication required"}')
|
||||||
|
}
|
||||||
|
|
||||||
|
const serveLogin = (req, res, error, status = 200, next = '/') => {
|
||||||
|
const page = renderLoginPage({ realm: guard.realm(), next: sanitizeNext(next), error, loginPath })
|
||||||
|
res.statusCode = status
|
||||||
|
res.setHeader('Content-Type', 'text/html; charset=utf-8')
|
||||||
|
res.setHeader('Cache-Control', 'no-store')
|
||||||
|
res.setHeader('Content-Length', String(Buffer.byteLength(page)))
|
||||||
|
res.end(page)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleLogin = async (req, res) => {
|
||||||
|
let queryNext = '/'
|
||||||
|
try {
|
||||||
|
queryNext = new URL(req.url ?? '/', 'http://x').searchParams.get('next') ?? '/'
|
||||||
|
} catch {
|
||||||
|
/* malformed URL */
|
||||||
|
}
|
||||||
|
if (req.method !== 'POST') {
|
||||||
|
serveLogin(req, res, '', 200, queryNext)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Cross-origin form posts are rejected: the login form must come from this host.
|
||||||
|
const origin = req.headers.origin
|
||||||
|
const host = req.headers.host
|
||||||
|
if (typeof origin === 'string' && typeof host === 'string') {
|
||||||
|
try {
|
||||||
|
if (new URL(origin).host !== host) {
|
||||||
|
audit.write({ event: 'login-fail', username: '', ip: clientIp(req), ua: req.headers['user-agent'] ?? '', reason: 'cross-origin' })
|
||||||
|
serveLogin(req, res, '非法请求来源', 403, queryNext)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
audit.write({ event: 'login-fail', username: '', ip: clientIp(req), ua: req.headers['user-agent'] ?? '', reason: 'cross-origin' })
|
||||||
|
serveLogin(req, res, '非法请求来源', 403, queryNext)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const body = await readBody(req, 8192)
|
||||||
|
if (body === null) {
|
||||||
|
audit.write({ event: 'login-fail', username: '', ip: clientIp(req), ua: req.headers['user-agent'] ?? '', reason: 'body-too-large' })
|
||||||
|
serveLogin(req, res, '请求过大', 413, queryNext)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const fields = new URLSearchParams(body)
|
||||||
|
const username = fields.get('username') ?? ''
|
||||||
|
const password = fields.get('password') ?? ''
|
||||||
|
const next = sanitizeNext(fields.get('next') ?? queryNext)
|
||||||
|
const ip = clientIp(req)
|
||||||
|
const ua = req.headers['user-agent'] ?? ''
|
||||||
|
if (!username || !password) {
|
||||||
|
audit.write({ event: 'login-fail', username, ip, ua, reason: 'missing-fields' })
|
||||||
|
serveLogin(req, res, '请输入用户名和密码', 403, next)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (guard.checkCredentials(username, password)) {
|
||||||
|
const value = guard.issueCookieValue(username)
|
||||||
|
const settings = guard.cookieSettings()
|
||||||
|
audit.write({ event: 'login-ok', username, ip, ua })
|
||||||
|
res.statusCode = 303
|
||||||
|
res.setHeader('Set-Cookie', `${settings.name}=${value}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${settings.ttl}`)
|
||||||
|
res.setHeader('Location', next)
|
||||||
|
res.setHeader('Content-Length', '0')
|
||||||
|
res.end()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
audit.write({ event: 'login-fail', username, ip, ua, reason: 'bad-credentials' })
|
||||||
|
serveLogin(req, res, '用户名或密码错误', 403, next)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleLogout = (req, res) => {
|
||||||
|
const settings = guard.cookieSettings()
|
||||||
|
audit.write({ event: 'logout', username: '', ip: clientIp(req), ua: req.headers['user-agent'] ?? '' })
|
||||||
|
res.statusCode = 303
|
||||||
|
res.setHeader('Set-Cookie', `${settings.name}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`)
|
||||||
|
res.setHeader('Location', loginPath)
|
||||||
res.setHeader('Content-Length', '0')
|
res.setHeader('Content-Length', '0')
|
||||||
res.end()
|
res.end()
|
||||||
}
|
}
|
||||||
|
|
||||||
const onRequest = (req, res) => {
|
const onRequest = (req, res) => {
|
||||||
|
let pathname = '/'
|
||||||
|
try {
|
||||||
|
pathname = new URL(req.url ?? '/', 'http://x').pathname
|
||||||
|
} catch {
|
||||||
|
/* malformed URL → treat as anonymous */
|
||||||
|
}
|
||||||
|
if (pathname === loginPath) {
|
||||||
|
handleLogin(req, res)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (pathname === logoutPath) {
|
||||||
|
handleLogout(req, res)
|
||||||
|
return
|
||||||
|
}
|
||||||
const who = guard.authenticate(req)
|
const who = guard.authenticate(req)
|
||||||
if (who === null) {
|
if (who === null) {
|
||||||
logger?.warn?.(`[panel-auth] rejected anonymous request ${req.method ?? '?'} ${req.url ?? ''} from ${req.socket?.remoteAddress ?? '?'}`)
|
if (acceptsHtml(req)) {
|
||||||
|
audit.write({ event: 'challenge', username: '', ip: clientIp(req), ua: req.headers['user-agent'] ?? '', path: pathname })
|
||||||
|
serveLogin(req, res, '', 200, req.url ?? '/')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
audit.write({ event: 'reject', username: '', ip: clientIp(req), ua: req.headers['user-agent'] ?? '', method: req.method ?? '', path: pathname })
|
||||||
challenge(res)
|
challenge(res)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -154,9 +533,20 @@ export function installGuard(server, guard, { logger }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const onUpgrade = (req, socket, head) => {
|
const onUpgrade = (req, socket, head) => {
|
||||||
|
let pathname = '/'
|
||||||
|
try {
|
||||||
|
pathname = new URL(req.url ?? '/', 'http://x').pathname
|
||||||
|
} catch {
|
||||||
|
/* malformed URL → reject */
|
||||||
|
}
|
||||||
|
if (pathname === loginPath || pathname === logoutPath) {
|
||||||
|
socket.end('HTTP/1.1 405 Method Not Allowed\r\nConnection: close\r\nContent-Length: 0\r\n\r\n')
|
||||||
|
socket.destroy()
|
||||||
|
return
|
||||||
|
}
|
||||||
const who = guard.authenticate(req)
|
const who = guard.authenticate(req)
|
||||||
if (who === null) {
|
if (who === null) {
|
||||||
logger?.warn?.(`[panel-auth] rejected anonymous upgrade ${req.url ?? ''} from ${req.socket?.remoteAddress ?? '?'}`)
|
audit.write({ event: 'reject', username: '', ip: clientIp(req), ua: req.headers['user-agent'] ?? '', method: 'UPGRADE', path: pathname })
|
||||||
socket.end('HTTP/1.1 401 Unauthorized\r\nConnection: close\r\nContent-Length: 0\r\n\r\n')
|
socket.end('HTTP/1.1 401 Unauthorized\r\nConnection: close\r\nContent-Length: 0\r\n\r\n')
|
||||||
socket.destroy()
|
socket.destroy()
|
||||||
return
|
return
|
||||||
@@ -177,9 +567,16 @@ export function installGuard(server, guard, { logger }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── plugin ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export default function panelAuth(ctx, config) {
|
export default function panelAuth(ctx, config) {
|
||||||
const cfg = config ?? {}
|
const cfg = config ?? {}
|
||||||
const guard = createGuard({ getConfig: () => cfg, logger: null })
|
const guard = createGuard({ getConfig: () => cfg, logger: null })
|
||||||
|
const auditPath = typeof cfg.auditLogPath === 'string' && cfg.auditLogPath.length > 0 ? cfg.auditLogPath : defaultAuditPath()
|
||||||
|
const audit = createAuditWriter(auditPath)
|
||||||
|
const loginPath = typeof cfg.loginPath === 'string' && cfg.loginPath.length > 0 ? cfg.loginPath : DEFAULTS.loginPath
|
||||||
|
const logoutPath = typeof cfg.logoutPath === 'string' && cfg.logoutPath.length > 0 ? cfg.logoutPath : DEFAULTS.logoutPath
|
||||||
|
console.log('[panel-auth] audit log: ' + auditPath)
|
||||||
let disposer
|
let disposer
|
||||||
let timer
|
let timer
|
||||||
let stopped = false
|
let stopped = false
|
||||||
@@ -191,7 +588,7 @@ export default function panelAuth(ctx, config) {
|
|||||||
const tryWrap = () => {
|
const tryWrap = () => {
|
||||||
const server = ctx.webServer.server
|
const server = ctx.webServer.server
|
||||||
if (server === undefined) return false
|
if (server === undefined) return false
|
||||||
disposer = installGuard(server, guard, { logger: null })
|
disposer = installGuard(server, guard, { audit, loginPath, logoutPath })
|
||||||
console.log('[panel-auth] guard installed: panel now requires a password')
|
console.log('[panel-auth] guard installed: panel now requires a password')
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,9 +3,15 @@
|
|||||||
// 'upgrade' listener. Does not touch the running panel.
|
// 'upgrade' listener. Does not touch the running panel.
|
||||||
import { createServer } from 'node:http'
|
import { createServer } from 'node:http'
|
||||||
import { strict as assert } from 'node:assert'
|
import { strict as assert } from 'node:assert'
|
||||||
import { createGuard, installGuard } from './index.js'
|
import { mkdtempSync, readFileSync, rmSync } from 'node:fs'
|
||||||
|
import { tmpdir } from 'node:os'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
import { createGuard, installGuard, createAuditWriter, renderLoginPage } from './index.js'
|
||||||
import { hashPassword, verifyPassword } from './crypto.js'
|
import { hashPassword, verifyPassword } from './crypto.js'
|
||||||
|
|
||||||
|
const tmp = mkdtempSync(join(tmpdir(), 'panel-auth-test-'))
|
||||||
|
const audit = createAuditWriter(join(tmp, 'audit.jsonl'))
|
||||||
|
|
||||||
const cfg = {
|
const cfg = {
|
||||||
users: [{ username: 'admin', passwordHash: hashPassword('s3cret-pass') }],
|
users: [{ username: 'admin', passwordHash: hashPassword('s3cret-pass') }],
|
||||||
secret: 'unit-test-secret-0123456789abcdef',
|
secret: 'unit-test-secret-0123456789abcdef',
|
||||||
@@ -26,11 +32,13 @@ server.on('upgrade', (req, socket, head) => {
|
|||||||
socket.end('HTTP/1.1 101 Switching Protocols\r\nUpgrade: test\r\nConnection: Upgrade\r\n\r\n')
|
socket.end('HTTP/1.1 101 Switching Protocols\r\nUpgrade: test\r\nConnection: Upgrade\r\n\r\n')
|
||||||
})
|
})
|
||||||
|
|
||||||
const disposer = installGuard(server, guard, { logger: null })
|
const disposer = installGuard(server, guard, { audit, loginPath: '/panel-auth/login', logoutPath: '/panel-auth/logout' })
|
||||||
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve))
|
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve))
|
||||||
const base = `http://127.0.0.1:${server.address().port}`
|
const base = `http://127.0.0.1:${server.address().port}`
|
||||||
|
|
||||||
const raw = (path, headers = {}) => fetch(base + path, { headers, redirect: 'manual' })
|
const raw = (path, headers = {}, init = {}) => fetch(base + path, { headers, redirect: 'manual', ...init })
|
||||||
|
const post = (path, body, headers = {}) =>
|
||||||
|
raw(path, { 'Content-Type': 'application/x-www-form-urlencoded', ...headers }, { method: 'POST', body: String(body) })
|
||||||
const upgrade = async (headers = []) => {
|
const upgrade = async (headers = []) => {
|
||||||
const net = await import('node:net')
|
const net = await import('node:net')
|
||||||
const sock = net.connect(server.address().port, '127.0.0.1')
|
const sock = net.connect(server.address().port, '127.0.0.1')
|
||||||
@@ -47,54 +55,101 @@ const upgrade = async (headers = []) => {
|
|||||||
return response
|
return response
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. anonymous → 401 + challenge
|
// 1. anonymous API call → 401 + Basic challenge (curl/script contract kept)
|
||||||
let r = await raw('/')
|
let r = await raw('/api/x')
|
||||||
assert.equal(r.status, 401)
|
assert.equal(r.status, 401)
|
||||||
assert.match(r.headers.get('www-authenticate'), /^Basic realm="Test Realm"/)
|
assert.match(r.headers.get('www-authenticate'), /^Basic realm="Test Realm"/)
|
||||||
|
assert.equal(await r.text(), '{"error":"authentication required"}')
|
||||||
|
|
||||||
// 2. wrong password → 401
|
// 2. anonymous navigation → pretty login page, NO native challenge header
|
||||||
r = await raw('/', { authorization: 'Basic ' + Buffer.from('admin:wrong').toString('base64') })
|
r = await raw('/some/page', { accept: 'text/html' })
|
||||||
assert.equal(r.status, 401)
|
|
||||||
|
|
||||||
// 3. correct password → 200 + Set-Cookie
|
|
||||||
r = await raw('/', { authorization: 'Basic ' + Buffer.from('admin:s3cret-pass').toString('base64') })
|
|
||||||
assert.equal(r.status, 200)
|
assert.equal(r.status, 200)
|
||||||
assert.equal(await r.text(), 'panel')
|
assert.equal(r.headers.get('www-authenticate'), null)
|
||||||
|
const page = await r.text()
|
||||||
|
assert.match(page, /登录以继续/)
|
||||||
|
assert.match(page, /name="username"/)
|
||||||
|
assert.match(page, /value="\/some\/page"/) // next preserved
|
||||||
|
|
||||||
|
// 3. GET the login path itself → 200 page
|
||||||
|
r = await raw('/panel-auth/login', { accept: 'text/html' })
|
||||||
|
assert.equal(r.status, 200)
|
||||||
|
assert.match(await r.text(), /DSH|Test Realm/)
|
||||||
|
|
||||||
|
// 4. POST login with missing fields → 403 error page
|
||||||
|
r = await post('/panel-auth/login', 'username=admin')
|
||||||
|
assert.equal(r.status, 403)
|
||||||
|
assert.match(await r.text(), /请输入用户名和密码/)
|
||||||
|
|
||||||
|
// 5. POST login with wrong credentials → 403 error page
|
||||||
|
r = await post('/panel-auth/login', 'username=admin&password=wrong&next=%2Fsome%2Fpage')
|
||||||
|
assert.equal(r.status, 403)
|
||||||
|
assert.match(await r.text(), /用户名或密码错误/)
|
||||||
|
|
||||||
|
// 6. POST login with correct credentials → 303 + cookie + next
|
||||||
|
r = await post('/panel-auth/login', 'username=admin&password=s3cret-pass&next=%2Fsome%2Fpage')
|
||||||
|
assert.equal(r.status, 303)
|
||||||
|
assert.equal(r.headers.get('location'), '/some/page')
|
||||||
const setCookie = r.headers.get('set-cookie')
|
const setCookie = r.headers.get('set-cookie')
|
||||||
assert.match(setCookie, /^test_cookie=.+; Path=\/; HttpOnly; SameSite=Lax; Max-Age=3600$/)
|
assert.match(setCookie, /^test_cookie=.+; Path=\/; HttpOnly; SameSite=Lax; Max-Age=3600$/)
|
||||||
const cookieValue = setCookie.split(';')[0].slice('test_cookie='.length)
|
const cookieValue = setCookie.split(';')[0].slice('test_cookie='.length)
|
||||||
|
|
||||||
// 4. cookie alone → 200, no new cookie
|
// 7. cookie reuse → panel content, no new cookie
|
||||||
r = await raw('/', { cookie: `test_cookie=${cookieValue}` })
|
r = await raw('/', { cookie: `test_cookie=${cookieValue}` })
|
||||||
assert.equal(r.status, 200)
|
assert.equal(r.status, 200)
|
||||||
|
assert.equal(await r.text(), 'panel')
|
||||||
assert.equal(r.headers.get('set-cookie'), null)
|
assert.equal(r.headers.get('set-cookie'), null)
|
||||||
|
|
||||||
// 5. tampered cookie → 401
|
// 8. tampered cookie → 401 (API path)
|
||||||
r = await raw('/', { cookie: 'test_cookie=' + cookieValue.slice(0, -2) + 'xx' })
|
r = await raw('/api/x', { cookie: 'test_cookie=' + cookieValue.slice(0, -2) + 'xx' })
|
||||||
assert.equal(r.status, 401)
|
assert.equal(r.status, 401)
|
||||||
|
|
||||||
// 6. websocket upgrade anonymous → 401 on socket
|
// 9. websocket: anonymous → 401, with cookie → 101
|
||||||
assert.match(await upgrade(), /^HTTP\/1\.1 401 /)
|
assert.match(await upgrade(), /^HTTP\/1\.1 401 /)
|
||||||
|
|
||||||
// 7. websocket upgrade with cookie → passes through to original handler
|
|
||||||
assert.match(await upgrade([`Cookie: test_cookie=${cookieValue}`]), /^HTTP\/1\.1 101 /)
|
assert.match(await upgrade([`Cookie: test_cookie=${cookieValue}`]), /^HTTP\/1\.1 101 /)
|
||||||
assert.equal(upgradeCount, 1)
|
assert.equal(upgradeCount, 1)
|
||||||
|
|
||||||
// 8. hot-disable via config → anonymous passes through (fail-open)
|
// 10. logout → 303 + expired cookie, then anonymous navigation → login page again
|
||||||
|
r = await raw('/panel-auth/logout', { cookie: `test_cookie=${cookieValue}` })
|
||||||
|
assert.equal(r.status, 303)
|
||||||
|
assert.match(r.headers.get('set-cookie'), /Max-Age=0/)
|
||||||
|
r = await raw('/', { accept: 'text/html' })
|
||||||
|
assert.equal(r.status, 200)
|
||||||
|
assert.match(await r.text(), /登录以继续/)
|
||||||
|
|
||||||
|
// 11. audit log contains the expected events
|
||||||
|
const auditLines = readFileSync(join(tmp, 'audit.jsonl'), 'utf8').trim().split('\n').map((line) => JSON.parse(line))
|
||||||
|
const events = auditLines.map((e) => e.event)
|
||||||
|
for (const expected of ['reject', 'challenge', 'login-fail', 'login-ok', 'logout']) {
|
||||||
|
assert.ok(events.includes(expected), `audit missing event ${expected}`)
|
||||||
|
}
|
||||||
|
const ok = auditLines.find((e) => e.event === 'login-ok')
|
||||||
|
assert.equal(ok.username, 'admin')
|
||||||
|
const fail = auditLines.find((e) => e.event === 'login-fail' && e.reason === 'bad-credentials')
|
||||||
|
assert.equal(fail.username, 'admin')
|
||||||
|
assert.equal(auditLines.every((e) => typeof e.ip === 'string' && typeof e.ts === 'string'), true)
|
||||||
|
|
||||||
|
// 12. XSS: hostile `next` value is escaped in the page
|
||||||
|
const hostile = renderLoginPage({ realm: 'T', next: '/" onmouseover="alert(1)', error: '' })
|
||||||
|
assert.ok(!hostile.includes('onmouseover="alert(1)'), 'next not escaped')
|
||||||
|
const hostileError = renderLoginPage({ realm: 'T', next: '/', error: '<script>alert(1)</script>' })
|
||||||
|
assert.ok(!hostileError.includes('<script>alert(1)'), 'error not escaped')
|
||||||
|
|
||||||
|
// 13. hot-disable via config → anonymous passes through (fail-open)
|
||||||
cfg.users = []
|
cfg.users = []
|
||||||
r = await raw('/')
|
r = await raw('/')
|
||||||
assert.equal(r.status, 200)
|
assert.equal(r.status, 200)
|
||||||
cfg.users = [{ username: 'admin', passwordHash: hashPassword('s3cret-pass') }]
|
cfg.users = [{ username: 'admin', passwordHash: hashPassword('s3cret-pass') }]
|
||||||
|
|
||||||
// 9. dispose restores original behavior (anonymous → 200 again)
|
// 14. dispose restores original behavior
|
||||||
disposer()
|
disposer()
|
||||||
r = await raw('/')
|
r = await raw('/')
|
||||||
assert.equal(r.status, 200)
|
assert.equal(r.status, 200)
|
||||||
assert.equal(await r.text(), 'panel')
|
assert.equal(await r.text(), 'panel')
|
||||||
|
|
||||||
// 10. hash round-trip sanity
|
// 15. hash round-trip sanity
|
||||||
assert.equal(verifyPassword('s3cret-pass', cfg.users[0].passwordHash), true)
|
assert.equal(verifyPassword('s3cret-pass', cfg.users[0].passwordHash), true)
|
||||||
assert.equal(verifyPassword('other', cfg.users[0].passwordHash), false)
|
assert.equal(verifyPassword('other', cfg.users[0].passwordHash), false)
|
||||||
|
|
||||||
server.close()
|
server.close()
|
||||||
|
rmSync(tmp, { recursive: true, force: true })
|
||||||
console.log('ALL PANEL-AUTH TESTS PASSED')
|
console.log('ALL PANEL-AUTH TESTS PASSED')
|
||||||
Reference in New Issue
Block a user