diff --git a/app/lib/password-2.js b/app/lib/password-2.js index 40a8615..2e84bed 100644 --- a/app/lib/password-2.js +++ b/app/lib/password-2.js @@ -1,10 +1,20 @@ /** - * 生成便于辨认的随机密码建议:至少含小写、大写和数字各一位,排除易混字符。 - * 现有实现使用 Math.random(非密码学随机源),不能当作高强度密钥/令牌生成器; - * 即使 e 小于 3,三个必选字符也会保留。此处注释如实说明现有行为,不改变协议。 + * 使用浏览器安全随机源生成密码:至少含小写、大写和数字各一位,排除易混字符。 + * 即使要求长度小于 3,三个必选字符也会保留;安全随机源不可用时明确失败,不降级。 */ function e(e = 12) { - let t = (e) => e[Math.floor(Math.random() * e.length)], + const source = globalThis.crypto; + if (typeof source?.getRandomValues !== `function`) + throw Error(`当前环境不支持安全随机密码生成`); + const buffer = new Uint32Array(1); + // 只接受能均分为 upperBound 份的区间,避免直接取模让某些字符/洗牌位置概率偏高。 + function randomIndex(upperBound) { + const limit = 2 ** 32 - (2 ** 32 % upperBound); + do source.getRandomValues(buffer); + while (buffer[0] >= limit); + return buffer[0] % upperBound; + } + let t = (e) => e[randomIndex(e.length)], n = [ t(`abcdefghjkmnpqrstuvwxyz`), t(`ABCDEFGHJKMNPQRSTUVWXYZ`), @@ -18,7 +28,8 @@ function e(e = 12) { ), i = [...n, ...r]; for (let e = i.length - 1; e > 0; e--) { - let t = Math.floor(Math.random() * (e + 1)); + // Fisher–Yates 与字符选取共用无偏采样,不能在打乱阶段重新引入非安全随机源。 + let t = randomIndex(e + 1); [i[e], i[t]] = [i[t], i[e]]; } return i.join(``); diff --git a/app/lib/product-fields.js b/app/lib/product-fields.js index 893ee53..75f4abc 100644 --- a/app/lib/product-fields.js +++ b/app/lib/product-fields.js @@ -36,8 +36,8 @@ function r(n) { .filter((e) => e.value !== ``); } /** - * 将后端 PCRE 风格 regexpr 的可表达部分转换为 JS RegExp。 - * 不支持的高级构造标记 patternServerOnly,不能假装浏览器已经完成等价验证。 + * 只转换能确认等价的 ASCII PCRE 子集,不能把“JS 能编译”当作跨引擎语义相同。 + * Unicode、换行模式、未知 escape、POSIX/补集及高级分组均交给后端最终校验。 */ function i(e) { let n = t(e).trim(); @@ -74,70 +74,56 @@ function i(e) { } if (!/^[imsuxADSUXJnr]*$/.test(r)) throw Error(`自定义字段验证规则修饰符无效`); - let o = /^\(\?([imsx]+)\)/.exec(n); - o && ((r += o[1]), (n = n.slice(o[0].length))); - let s = [...new Set(r.replace(/[^imsu]/g, ``))].join(``); - if ( - /\(\?(?:[>('|&R0-9]|P[=>]|[imsx-]+:)|\(\*|\\[KCG]|(?:[+*?]|\{\d+(?:,\d*)?\})\+/.test( - n, - ) || - r.includes(`r`) || - (r.includes(`J`) && /\(\? 127) return serverOnly; if (t === `\\`) { - let t = n[++e], - r = { - A: `(? 0 && n[e - 1] !== `(`) || - (t === `}` && /\{\d+(?:,\d*)?\}$/.test(c))) && - (n[e + 1] === `?` ? e++ : (c += `?`))); + if (t === `{`) { + const quantifier = /^\{\d+(?:,\d*)?\}/.exec(n.slice(e)); + if (!quantifier || n[e + quantifier[0].length] === `+`) return serverOnly; + c += quantifier[0]; + e += quantifier[0].length - 1; + continue; + } + if (t === `}` || (`*+?`.includes(t) && n[e + 1] === `+`)) return serverOnly; + // PCRE 默认 $ 只允许最终 LF 前;JS 原生 $ 还会放行 CR/Unicode 行分隔符。 + c += t === `$` ? `(?=\\n?(?![\\s\\S]))` : t; } - r.includes(`A`) && (c = `(? { + const request = route.request(); + const path = new URL(request.url()).pathname.replace( + /^\/console\/v1/, + "", + ); + const reply = (data, extra = {}) => { + assert.ok( + request.headers().authorization, + "Scenario APIs require the real login session", + ); + return route.fulfill({ + json: { status: 200, msg: "", data, ...extra }, + headers: { "Access-Control-Allow-Origin": "*" }, + }); + }; + if (path === "/product/123") + return reply({ + product: { + id: 123, + name: "本地字段验收商品", + module: "idcsmart_common", + description: "", + }, + }); + if (path === "/idcsmart_common/product/123/configoption") + return reply({ + common_product: { pay_type: "free" }, + configoptions: [], + }); + if (path === "/product/123/self_defined_field/order_page") + return reply({ data: fields }); + if (path === "/product/123/config_option") + return reply({ price_total: "0.00" }); + if (path === "/purchase_limit/check") + return reply({ allowed: true, message: "" }); + if (path === "/product/settle") { + state.settlements.push(request.postDataJSON()); + if (state.rejectFields) + return reply({}, { status: 400, msg: "字段内容未通过服务端校验" }); + return reply({ order_id: 42, amount: "0.00" }); + } + if (path === "/host/5") + return reply({ + host: { + id: 5, + product_id: 123, + module: "mf_cloud", + name: "本地密码验收实例", + status: "Active", + billing_cycle: "monthly", + upgrade_allowed: false, + }, + }); + if (path === "/host/5/view") return reply({ module: "mf_cloud" }); + if (path === "/mf_cloud/5") + return reply({ + image: { id: 6, name: "Ubuntu 24.04" }, + port: 22, + username: "root", + cpu: 2, + memory: 4, + config: { reinstall_sms_verify: 0, reset_password_sms_verify: 0 }, + }); + if (path === "/mf_cloud/5/status") + return reply({ status: "on", rescue: false }); + if (path === "/mf_cloud/5/disk") return reply({ list: [] }); + if (path === "/mf_cloud/5/flow") + return reply({ total: 100, used: 0, leave: 100 }); + if (path === "/mf_cloud/5/real_data") + return reply({ cpu: 0, memory: 0, disk: 0 }); + if (path === "/mf_cloud/5/chart") return reply({ list: [] }); + if (path === "/product/123/mf_cloud/image") return reply({ list: [] }); + if (path === "/mf_cloud/5/reset_password") { + state.resets.push(request.postDataJSON()); + return reply({}); + } + return route.fallback(); + }); + return state; +} + +test( + "product fields preserve PCRE-only values through the real checkout", + { timeout: 45000 }, + (t) => + scenario(t, {}, async (page) => { + const cases = [ + ["ASCII编号", "/^[A-Z]+$/", "ABC"], + ["POSIX字母", "/^[[:alpha:]]+$/", "ABC"], + ["引用块", "/^\\Qa+b\\E$/", "a+b"], + ["水平空白", "/^a\\hb$/", "a b"], + ["垂直空白", "/^a\\vb$/", "a\nb"], + ["换行约定", "/^a\\Rb$/", "a\nb"], + ["八进制字符", "/^\\o{101}$/", "A"], + ["十六进制字符", "/^\\x{41}$/", "A"], + ["PCRE空白", "/^a\\sb$/", "a b"], + ]; + const fields = cases.map(([field_name, regexpr], index) => ({ + id: index + 1, + field_type: "textarea", + field_name, + regexpr, + is_required: 1, + })); + const state = await boundary(page, fields); + await login(page, "/products/123"); + for (const [index, [, , value]] of cases.entries()) + await page.locator(`#product-field-${index + 1}`).fill(value); + const buy = page.getByRole("button", { name: "立即购买", exact: true }); + const reprice = page.getByRole("button", { + name: /^(计算报价|重新获取报价)$/, + }); + if (await reprice.isVisible()) await reprice.click(); + // 单纯 ASCII 规则仍在浏览器拒绝,不能为了解决 PCRE 误判而关闭所有验证。 + await page.locator("#product-field-1").fill("abc"); + if (await reprice.isVisible()) await reprice.click(); + await buy.click(); + await page.getByText("ASCII编号格式不正确", { exact: true }).waitFor(); + assert.equal(state.settlements.length, 0); + await page.locator("#product-field-1").fill("ABC"); + if (await reprice.isVisible()) await reprice.click(); + await buy.click(); + await eventually( + () => state.settlements.length, + (n) => n === 1, + "Valid PCRE values must reach the real settlement request", + ); + assert.deepEqual( + state.settlements[0].self_defined_field, + Object.fromEntries( + cases.map(([, , value], index) => [String(index + 1), value]), + ), + ); + return { + asciiInvalidBlocked: true, + pcreFieldValuesPreserved: true, + fieldCount: cases.length, + }; + }), +); + +test( + "server-only field rules preserve the backend validation result", + { timeout: 30000 }, + (t) => + scenario(t, {}, async (page) => { + const state = await boundary(page, [ + { + id: 1, + field_type: "text", + field_name: "水平空白", + regexpr: "/^\\h+$/", + is_required: 1, + }, + ]); + state.rejectFields = true; + await login(page, "/products/123"); + await page.locator("#product-field-1").fill("h"); + const reprice = page.getByRole("button", { + name: /^(计算报价|重新获取报价)$/, + }); + if (await reprice.isVisible()) await reprice.click(); + await page.getByRole("button", { name: "立即购买", exact: true }).click(); + await page + .getByText("字段内容未通过服务端校验", { exact: true }) + .waitFor(); + assert.equal(state.settlements.length, 1); + assert.equal(state.settlements[0].self_defined_field["1"], "h"); + return { serverValidationRequired: true, backendRejectionVisible: true }; + }), +); + +test( + "instance password generation uses browser CSPRNG and reaches reset HTTP", + { timeout: 35000 }, + (t) => + scenario(t, {}, async (page) => { + const state = await boundary(page); + // 系统随机源是外部边界。只计数真实 WebCrypto,不替换产品内部函数,也不保存密码。 + await page.addInitScript(() => { + const original = crypto.getRandomValues.bind(crypto); + window.randomSourceCalls = 0; + crypto.getRandomValues = (array) => { + window.randomSourceCalls++; + return original(array); + }; + }); + await login(page, "/servers/server?id=5"); + await page.getByRole("tab", { name: "访问控制", exact: true }).click(); + const before = await page.evaluate(() => window.randomSourceCalls); + await page.getByRole("button", { name: "重置密码", exact: true }).click(); + const dialog = page.getByRole("dialog", { + name: "重置密码", + exact: true, + }); + const input = dialog.getByLabel("新密码", { exact: true }); + assertPassword(await input.inputValue(), 12); + const afterOpen = await page.evaluate(() => window.randomSourceCalls); + assert.equal( + afterOpen > before, + true, + "Opening reset must use WebCrypto", + ); + await dialog.getByRole("button", { name: "随机", exact: true }).click(); + const generated = await input.inputValue(); + assertPassword(generated, 12); + assert.equal( + (await page.evaluate(() => window.randomSourceCalls)) > afterOpen, + true, + "Regenerating the password must use WebCrypto", + ); + await dialog.getByRole("button", { name: "确定", exact: true }).click(); + await eventually( + () => state.resets.length, + (n) => n === 1, + "Generated password must reach reset HTTP", + ); + assert.equal( + state.resets[0].password === generated, + true, + "Reset must use the displayed password", + ); + return { + length: 12, + originalAlphabet: true, + allCharacterClasses: true, + secureRandomUsed: true, + resetMatchesInput: true, + }; + }), +); + +test("password algorithm rejects biased draws and fails closed without secure randomness", () => { + const descriptor = Object.getOwnPropertyDescriptor(globalThis, "crypto"); + const originalRandom = Math.random; + const insecureSource = new Error( + "Non-cryptographic randomness must not be used", + ); + try { + Math.random = () => { + throw insecureSource; + }; + // UINT32_MAX 不属于 23 字符集和三位洗牌的等分区间;8/2 等整除区间无需拒绝。 + let draws = [ + 0xffffffff, 0, 0xffffffff, 0, 0xffffffff, 0xffffffff, 1, 0xffffffff, + ]; + Object.defineProperty(globalThis, "crypto", { + configurable: true, + value: { + getRandomValues(array) { + for (let index = 0; index < array.length; index++) + array[index] = draws.shift() ?? 0; + return array; + }, + }, + }); + const short = password(3); + assertPassword(short, 3); + assert.equal( + short === "a9A", + true, + "Biased character/shuffle draws must be retried", + ); + draws = [0, 0, 0, 0xffffffff, 0, 0xffffffff, 0xffffffff, 0, 0xffffffff]; + const longer = password(4); + assertPassword(longer, 4); + assert.equal( + longer === "2Aaa", + true, + "The combined alphabet must also reject biased draws", + ); + for (const length of [undefined, 0, 2, 64]) { + const expected = length === undefined ? 12 : Math.max(3, length); + assertPassword(password(length), expected); + } + Object.defineProperty(globalThis, "crypto", { + configurable: true, + value: undefined, + }); + assert.throws( + () => password(), + (error) => error instanceof Error && error !== insecureSource, + ); + Object.defineProperty(globalThis, "crypto", { + configurable: true, + value: {}, + }); + assert.throws( + () => password(), + (error) => error instanceof Error && error !== insecureSource, + ); + const unavailable = new Error("Secure source unavailable"); + Object.defineProperty(globalThis, "crypto", { + configurable: true, + value: { + getRandomValues() { + throw unavailable; + }, + }, + }); + assert.throws( + () => password(), + (error) => error === unavailable, + ); + } finally { + Math.random = originalRandom; + if (descriptor) Object.defineProperty(globalThis, "crypto", descriptor); + else delete globalThis.crypto; + } +}); + +test("field adapter delegates unproven PCRE semantics and retains equivalent ASCII subset", () => { + for (const rule of [ + "/^[[:alpha:]]+$/", + "/^\\Qa+b\\E$/", + "/^\\h+$/", + "/^\\H+$/", + "/^\\v+$/", + "/^\\V+$/", + "/^\\R+$/", + "/^\\X+$/", + "/^\\s+$/", + "/^\\S+$/", + "/^\\w+$/", + "/^\\W+$/", + "/^\\D+$/", + "/^\\q+$/", + "/^\\o{101}$/", + "/^\\x{41}$/", + "/^[^a]+$/", + "/^.+$/", + "/^a$/m", + "/^a$/u", + "/^a$/x", + "/^(?i)a$/", + "/^(?>a)$/", + "/^(a)\\1$/", + "/^\\p{L}+$/u", + "/^é+$/", + "/^(?=a)a$/", + ]) + assert.equal(fieldPattern(rule).patternServerOnly, true, rule); + for (const [rule, valid, invalid] of [ + ["/^[A-Z][A-Z0-9_-]{1,8}$/i", "aB_2", "éé"], + ["/^\\d{2}$/", "23", "ab"], + ["/\\A(?:abc|def)\\z/", "abc", "abc\n"], + ["/^[abc]+$/", "abc\n", "abc\r"], + ]) { + const normalized = fieldPattern(rule); + assert.notEqual(normalized.patternServerOnly, true, rule); + const regex = new RegExp(normalized.pattern, normalized.patternFlags); + assert.equal(regex.test(valid), true, rule); + assert.equal(regex.test(invalid), false, rule); + } +});