Files
jkooe 3fde62040c fix(security): R11 双端 device_id 长度下限统一为 8(服务端收紧)
`server/validate.js` 是 `[A-Za-z0-9-]{1,64}`,launcher `core/config.ts` 是 `{8,64}` ——
与 D1 的 device_name 错位**完全同类**:服务端放行 3 字符 id,launcher 拒收后该 peer
证书永远落不了盘(D3 之后还会直接决定文件名,静默失效面更大)。

修法:
- `DEVICE_ID_PATTERN` → `/^[A-Za-z0-9-]{8,64}$/`,docstring 写明与 launcher 同源,
  并说明 D3 之后该值是证书文件名主体(`moonlight-<device_id>.pem`)。
- `docs/api.md`:heartbeat 的 `device_id` 字段补「不能为空、不能短于 8 位」及其后果。
- `server.js` **无需改**:grep 确认 `isValidDeviceId()` 只用于「非法则忽略」分支,
  没有引用该规则的 `details.reason` 文案(device_name 的两处已在 D1 修掉)。

## 按架构 §13.4 补「双端行为契约」(而不是代码级共享正则)

新增 `server/test/deviceIdContract.test.mjs`(4 例)+ `launcher/test/validate.test.ts`
的 `device_id 双端契约` describe(3 例),两侧使用**逐字相同的取值清单**:

- ACCEPT:`'aaaaaaaa'` / `'A'.repeat(64)` / 真实 UUID / `'-dash-01'`
  (最后一项刻意保留:字符集允许 `-` 开头,两端一致;它只出现在文件名中段,
  不进 argv 首位,无注入面)
- REJECT:`'-dash'` `'.dot'` `'dot.'` `'a b'` `'中文'` `'a'.repeat(65)` `'ok-name_1.2'`
  (以上 7 项取自架构 §13.4 指定清单)+ 7 位 + 空串

优于「比对正则字符串」:换一种写法不会误报,真错位必然失败。服务端额外钉住
`DEVICE_ID_PATTERN.source`,防止有人偷偷加字符集。

测试:server 56 例(52 + 4)全绿;launcher validate.test.ts 26 例(23 + 3)全绿。
2026-10-26 02:07:02 +08:00

359 lines
12 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Only in One - 服务端白名单校验(纯函数,无 IO,无第三方依赖)。
//
// 设计原则(架构 §2 中心思想):所有跨越边界的数据(用户名、密码、设备名、
// IP、busid、证书 PEM)在跨越点上做**白名单**校验,而不是黑名单过滤。
//
// 本文件必须保持「纯」:不读 process.env、不碰 fs、不抛异常(除注明者),
// 便于单测覆盖攻击载荷。
//
// 安全红线:任何正则校验前先剔除含控制字符的输入 —— JS 正则的 `$` 会容忍
// 结尾换行,这是命令注入的经典绕过点(`"1.2.3.4\n"` 能骗过 `^\d+\.…$`)。
/** 用户名规则:3–32 位字母/数字/下划线/短横线(R-P1-02)。 */
export const USERNAME_PATTERN = /^[a-zA-Z0-9_-]{3,32}$/;
/** 用户名长度上下界。 */
export const USERNAME_MIN_LENGTH = 3;
export const USERNAME_MAX_LENGTH = 32;
/** 密码最小长度(R-P1-02)。 */
export const PASSWORD_MIN_LENGTH = 10;
/** 密码最大长度上限(防止 scrypt 被超长输入拖慢)。 */
export const PASSWORD_MAX_LENGTH = 200;
/**
* device_name 白名单:**首字符必须是字母或数字**,其后 `[A-Za-z0-9._-]`,总长 1–64。
*
* 为什么首字符要收紧(D1 缺陷修复):launcher 侧要把 device_name 拼成磁盘文件名
* (`moonlight-<name>.pem`),其白名单是 `SAFE_NAME_RE`(首字符禁止 `.` 与 `-`)。
* 两端规则若不一致,服务端会**接受**一个 launcher 永远写不出文件的设备名
* (如 `-dash` / `.hidden`)⇒ 该设备每次串流都要手输 PIN,现场几乎排查不出来。
*
* 首字符禁止 `-` 还顺带堵住 `--flag` 形式的参数注入;禁止 `.` 则防 `.` / `..`
* 与隐藏文件。**收紧方向只能是"服务端对齐客户端"**,不能反过来放宽客户端。
*/
export const DEVICE_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
/**
* 客户端稳定设备标识(UUID)白名单。
*
* 长度下限 **8** 与 launcher `core/config.ts#DEVICE_ID_PATTERN` 严格一致(R11)。
* 两端不一致时会出现「服务端收下、客户端永远用不了」的静默失效 —— 与 D1 的
* `device_name` 错位是同一类缺陷(服务端放行 3 字符 id,launcher 拒收后该 peer
* 证书永远落不了盘)。
*
* 用途(D3 之后):该值是**客户端证书文件名的主体**(`moonlight-<device_id>.pem`),
* 因此字符集只有 `[A-Za-z0-9-]` 一套 —— 这是 D1(服务端/客户端两套白名单必须永久
* 手工对齐)与 D4(大小写不敏感文件系统上 `Peer-A`/`peer-a` 互相覆盖)的共同根治点。
*/
export const DEVICE_ID_PATTERN = /^[A-Za-z0-9-]{8,64}$/;
/** busid 规则:`^\d{1,3}-\d{1,3}$`(R-P0-07)。 */
export const BUSID_PATTERN = /^\d{1,3}-\d{1,3}$/;
/** 共享名规则:`^[A-Za-z0-9_$-]{1,64}$`(R-P0-07)。 */
export const SHARE_NAME_PATTERN = /^[A-Za-z0-9_$-]{1,64}$/;
/** 设备平台枚举(与启动器 Platform 类型一致)。 */
export const PLATFORMS = Object.freeze(['win32', 'darwin', 'linux']);
/** 证书 PEM 体积上限:8KB(R-P1-03)。 */
export const MAX_CERT_BYTES = 8 * 1024;
/** 常见弱口令表(内置,宁可误拒也不放过)。 */
const WEAK_PASSWORDS = Object.freeze(
new Set([
'1234567890',
'12345678910',
'password',
'password1',
'password123',
'passw0rd',
'qwertyuiop',
'qwerty123',
'administrator',
'iloveyou',
'letmein123',
'onlyinone',
'onlyinone123',
'changeme123',
'abcdefghij',
'aaaaaaaaaa',
'1111111111',
'admin12345',
])
);
/** 控制字符(含 \n \r \t \0 及 DEL),出现在任何标识符里都直接拒绝。 */
const CONTROL_CHAR_PATTERN = /[\u0000-\u001f\u007f]/;
/**
* 判断字符串是否含控制字符或不可见格式字符。
* @param {unknown} value
* @returns {boolean} true 表示含控制字符(应拒绝)
*/
export function hasControlChars(value) {
if (typeof value !== 'string') return true;
return CONTROL_CHAR_PATTERN.test(value);
}
/**
* 通用白名单匹配:先做类型与控制字符前置检查,再匹配正则。
* @param {unknown} value
* @param {RegExp} pattern
* @returns {boolean}
*/
function matchesWhitelist(value, pattern) {
if (typeof value !== 'string') return false;
if (value.length === 0) return false;
if (hasControlChars(value)) return false;
return pattern.test(value);
}
/**
* 用户名是否合法。
* @param {unknown} value
* @returns {boolean}
*/
export function isValidUsername(value) {
return matchesWhitelist(value, USERNAME_PATTERN);
}
/**
* 用户名归一化:trim 后转小写,仅用于**查询/去重**(入库仍存原文)。
* @param {unknown} value
* @returns {string} 归一化结果;非法输入返回空串
*/
export function normalizeUsername(value) {
if (typeof value !== 'string') return '';
return value.trim().toLowerCase();
}
/**
* 判断密码是否为纯数字。
* @param {string} value
* @returns {boolean}
*/
function isAllDigits(value) {
return /^[0-9]+$/.test(value);
}
/**
* 判断密码是否为纯字母。
* @param {string} value
* @returns {boolean}
*/
function isAllLetters(value) {
return /^[A-Za-z]+$/.test(value);
}
/**
* 密码强度校验(R-P1-02):
* - 长度 ≥10 且 ≤200;
* - 不得为纯数字;
* - 不得为纯字母;
* - 不得为内置弱口令表成员;
* - 不得为全同字符。
* @param {unknown} value
* @returns {boolean}
*/
export function isStrongPassword(value) {
if (typeof value !== 'string') return false;
if (value.length < PASSWORD_MIN_LENGTH) return false;
if (value.length > PASSWORD_MAX_LENGTH) return false;
if (isAllDigits(value)) return false;
if (isAllLetters(value)) return false;
if (new Set(value.split('')).size === 1) return false;
if (WEAK_PASSWORDS.has(value.toLowerCase())) return false;
return true;
}
/**
* 给出密码不合格的具体原因(用于 400 响应的 details,便于前端提示)。
* @param {unknown} value
* @returns {string|null} null 表示合格
*/
export function describePasswordProblem(value) {
if (typeof value !== 'string' || value.length === 0) return '密码不能为空';
if (value.length < PASSWORD_MIN_LENGTH) return `密码长度需 ≥${PASSWORD_MIN_LENGTH} 位`;
if (value.length > PASSWORD_MAX_LENGTH) return `密码长度需 ≤${PASSWORD_MAX_LENGTH} 位`;
if (isAllDigits(value)) return '密码不能为纯数字';
if (isAllLetters(value)) return '密码不能为纯字母';
if (new Set(value.split('')).size === 1) return '密码不能为全同字符';
if (WEAK_PASSWORDS.has(value.toLowerCase())) return '密码过于常见,请更换';
return null;
}
/**
* device_name 白名单校验(R-P0-06)。
* 规则:`[A-Za-z0-9][A-Za-z0-9._-]{0,63}` —— **首字符必须是字母或数字**,
* 其后可含 `.` `_` `-`,总长 1–64。
*
* 首字符收紧的动机见 `DEVICE_NAME_PATTERN` 上方注释(D1:必须与 launcher 把
* 设备名拼成磁盘文件名时的白名单对齐,否则服务端会接受一个客户端永远写不出
* 文件的设备名)。
* @param {unknown} value
* @returns {boolean}
*/
export function isValidDeviceName(value) {
if (!matchesWhitelist(value, DEVICE_NAME_PATTERN)) return false;
if (value.startsWith('.')) return false;
if (value === '.' || value === '..') return false;
return true;
}
/**
* 客户端稳定设备标识(UUID)校验;可为空(老数据兼容)。
* @param {unknown} value
* @returns {boolean}
*/
export function isValidDeviceId(value) {
return matchesWhitelist(value, DEVICE_ID_PATTERN);
}
/**
* 平台枚举校验。
* @param {unknown} value
* @returns {boolean}
*/
export function isValidPlatform(value) {
return typeof value === 'string' && PLATFORMS.includes(value);
}
/**
* 严格 IPv4 校验:四段十进制,每段 0–255,禁止前导零与控制字符。
* @param {unknown} value
* @returns {boolean}
*/
export function isIPv4(value) {
if (typeof value !== 'string') return false;
if (value.length < 7 || value.length > 15) return false;
if (hasControlChars(value)) return false;
const parts = value.split('.');
if (parts.length !== 4) return false;
for (const part of parts) {
if (!/^(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])$/.test(part)) return false;
}
return true;
}
/**
* 把 IPv4 转为 4 个数字段;非法返回 null。
* @param {unknown} value
* @returns {number[]|null}
*/
export function ipv4ToOctets(value) {
if (!isIPv4(value)) return null;
return /** @type {string} */ (value).split('.').map((n) => Number.parseInt(n, 10));
}
/**
* 「可路由 IPv4」校验(A-04):在 isIPv4 基础上拒绝
* - `0.0.0.0`(未指定)
* - `127.0.0.0/8`(回环)
* - `169.254.0.0/16`(链路本地 / APIPA)
* - `224.0.0.0/4` 及以上(组播 / 保留)
* - `255.255.255.255`(受限广播)
* - 末位为 0 或 255(常见 /24 网络号与广播地址)
*
* 说明:末位 0/255 在 /23 等超网下可能是合法主机地址,但设备互连场景
* 几乎总是 /24,宁可保守拒绝,避免数据面目标被污染。
* @param {unknown} value
* @returns {boolean}
*/
export function isRoutableIPv4(value) {
const octets = ipv4ToOctets(value);
if (!octets) return false;
const [a, b, _c, d] = octets;
if (a === 0) return false;
if (a === 127) return false;
if (a === 169 && b === 254) return false;
if (a >= 224) return false;
if (a === 255 && b === 255 && octets[2] === 255 && d === 255) return false;
if (d === 0 || d === 255) return false;
return true;
}
/**
* busid 校验(`^\d{1,3}-\d{1,3}$`)。
* @param {unknown} value
* @returns {boolean}
*/
export function isBusId(value) {
return matchesWhitelist(value, BUSID_PATTERN);
}
/**
* SMB 共享名校验(`^[A-Za-z0-9_$-]{1,64}$`)。
* @param {unknown} value
* @returns {boolean}
*/
export function isShareName(value) {
return matchesWhitelist(value, SHARE_NAME_PATTERN);
}
/**
* 解析可选的 email:空/缺省视为合法(返回 null),非法返回 undefined。
* @param {unknown} value
* @returns {string|null|undefined} null=未提供;string=合法值;undefined=非法
*/
export function normalizeEmail(value) {
if (value === undefined || value === null || value === '') return null;
if (typeof value !== 'string') return undefined;
const trimmed = value.trim();
if (trimmed.length === 0) return null;
if (trimmed.length > 254) return undefined;
if (hasControlChars(trimmed)) return undefined;
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(trimmed)) return undefined;
return trimmed;
}
/**
* 证书 PEM 的**边界**校验(R-P1-03):
* - 必须是字符串,长度 1B–8KB;
* - 必须含 `-----BEGIN CERTIFICATE-----` 起始与 `-----END CERTIFICATE-----` 结束;
* - **显式拒绝任何 `PRIVATE KEY` 块**(含 RSA/EC/ENCRYPTED 变体)。
*
* 仅做边界判断,不解析 X509(解析在 crypto.js,保持本文件纯字符串逻辑)。
* @param {unknown} pem
* @returns {boolean}
*/
export function isPublicCertPem(pem) {
if (typeof pem !== 'string') return false;
if (pem.length === 0) return false;
if (Buffer.byteLength(pem, 'utf8') > MAX_CERT_BYTES) return false;
if (/PRIVATE KEY/i.test(pem)) return false;
const trimmed = pem.trim();
if (!trimmed.startsWith('-----BEGIN CERTIFICATE-----')) return false;
if (!trimmed.endsWith('-----END CERTIFICATE-----')) return false;
return true;
}
export default {
USERNAME_PATTERN,
DEVICE_NAME_PATTERN,
DEVICE_ID_PATTERN,
BUSID_PATTERN,
SHARE_NAME_PATTERN,
PLATFORMS,
MAX_CERT_BYTES,
PASSWORD_MIN_LENGTH,
PASSWORD_MAX_LENGTH,
hasControlChars,
isValidUsername,
normalizeUsername,
isStrongPassword,
describePasswordProblem,
isValidDeviceName,
isValidDeviceId,
isValidPlatform,
isIPv4,
ipv4ToOctets,
isRoutableIPv4,
isBusId,
isShareName,
normalizeEmail,
isPublicCertPem,
};