`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)全绿。
359 lines
12 KiB
JavaScript
359 lines
12 KiB
JavaScript
// 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,
|
||
};
|