fix: 闭环QQBot消息推送授权校验

This commit is contained in:
sunlei 2026-07-24 04:13:43 +08:00
parent 7ade7d6435
commit d0c4f29211
3 changed files with 113 additions and 16 deletions

View File

@ -647,3 +647,22 @@ INSERT INTO admin_menu (id, pid, name, path, component, redirect, auth_code, typ
(2041700000000120471,2041700000000100414,'QqBotMessageTemplateList',NULL,NULL,NULL,'QqBot:MessageTemplate:List','button','{"title":"common.list"}',1,0),(2041700000000120472,2041700000000100414,'QqBotMessageTemplateCreate',NULL,NULL,NULL,'QqBot:MessageTemplate:Create','button','{"title":"common.create"}',1,0),(2041700000000120473,2041700000000100414,'QqBotMessageTemplateUpdate',NULL,NULL,NULL,'QqBot:MessageTemplate:Update','button','{"title":"common.edit"}',1,0),(2041700000000120474,2041700000000100414,'QqBotMessageTemplateDelete',NULL,NULL,NULL,'QqBot:MessageTemplate:Delete','button','{"title":"common.delete"}',1,0),(2041700000000120475,2041700000000100414,'QqBotMessageTemplateToggle',NULL,NULL,NULL,'QqBot:MessageTemplate:Toggle','button','{"title":"启停"}',1,0),(2041700000000120476,2041700000000100414,'QqBotMessageTemplatePreview',NULL,NULL,NULL,'QqBot:MessageTemplate:Preview','button','{"title":"预览"}',1,0), (2041700000000120471,2041700000000100414,'QqBotMessageTemplateList',NULL,NULL,NULL,'QqBot:MessageTemplate:List','button','{"title":"common.list"}',1,0),(2041700000000120472,2041700000000100414,'QqBotMessageTemplateCreate',NULL,NULL,NULL,'QqBot:MessageTemplate:Create','button','{"title":"common.create"}',1,0),(2041700000000120473,2041700000000100414,'QqBotMessageTemplateUpdate',NULL,NULL,NULL,'QqBot:MessageTemplate:Update','button','{"title":"common.edit"}',1,0),(2041700000000120474,2041700000000100414,'QqBotMessageTemplateDelete',NULL,NULL,NULL,'QqBot:MessageTemplate:Delete','button','{"title":"common.delete"}',1,0),(2041700000000120475,2041700000000100414,'QqBotMessageTemplateToggle',NULL,NULL,NULL,'QqBot:MessageTemplate:Toggle','button','{"title":"启停"}',1,0),(2041700000000120476,2041700000000100414,'QqBotMessageTemplatePreview',NULL,NULL,NULL,'QqBot:MessageTemplate:Preview','button','{"title":"预览"}',1,0),
(2041700000000120481,2041700000000100410,'QqBotAccountMessagePushList',NULL,NULL,NULL,'QqBot:Account:MessagePush:List','button','{"title":"common.list"}',1,0),(2041700000000120482,2041700000000100410,'QqBotAccountMessagePushCreate',NULL,NULL,NULL,'QqBot:Account:MessagePush:Create','button','{"title":"common.create"}',1,0),(2041700000000120483,2041700000000100410,'QqBotAccountMessagePushUpdate',NULL,NULL,NULL,'QqBot:Account:MessagePush:Update','button','{"title":"common.edit"}',1,0),(2041700000000120484,2041700000000100410,'QqBotAccountMessagePushDelete',NULL,NULL,NULL,'QqBot:Account:MessagePush:Delete','button','{"title":"common.delete"}',1,0),(2041700000000120485,2041700000000100410,'QqBotAccountMessagePushToggle',NULL,NULL,NULL,'QqBot:Account:MessagePush:Toggle','button','{"title":"启停"}',1,0) (2041700000000120481,2041700000000100410,'QqBotAccountMessagePushList',NULL,NULL,NULL,'QqBot:Account:MessagePush:List','button','{"title":"common.list"}',1,0),(2041700000000120482,2041700000000100410,'QqBotAccountMessagePushCreate',NULL,NULL,NULL,'QqBot:Account:MessagePush:Create','button','{"title":"common.create"}',1,0),(2041700000000120483,2041700000000100410,'QqBotAccountMessagePushUpdate',NULL,NULL,NULL,'QqBot:Account:MessagePush:Update','button','{"title":"common.edit"}',1,0),(2041700000000120484,2041700000000100410,'QqBotAccountMessagePushDelete',NULL,NULL,NULL,'QqBot:Account:MessagePush:Delete','button','{"title":"common.delete"}',1,0),(2041700000000120485,2041700000000100410,'QqBotAccountMessagePushToggle',NULL,NULL,NULL,'QqBot:Account:MessagePush:Toggle','button','{"title":"启停"}',1,0)
ON DUPLICATE KEY UPDATE pid = VALUES(pid), path = VALUES(path), component = VALUES(component), redirect = VALUES(redirect), auth_code = VALUES(auth_code), type = VALUES(type), meta = VALUES(meta), status = VALUES(status), sort = VALUES(sort), is_deleted = 0; ON DUPLICATE KEY UPDATE pid = VALUES(pid), path = VALUES(path), component = VALUES(component), redirect = VALUES(redirect), auth_code = VALUES(auth_code), type = VALUES(type), meta = VALUES(meta), status = VALUES(status), sort = VALUES(sort), is_deleted = 0;
INSERT IGNORE INTO admin_role_menu (role_id, menu_id)
SELECT role.id, menu.id
FROM admin_role role
CROSS JOIN admin_menu menu
WHERE role.role_code IN ('super', 'admin')
AND role.status = 1
AND role.is_deleted = 0
AND menu.id IN (
2041700000000100413, 2041700000000100414,
2041700000000120461, 2041700000000120462, 2041700000000120463,
2041700000000120464, 2041700000000120465, 2041700000000120471,
2041700000000120472, 2041700000000120473, 2041700000000120474,
2041700000000120475, 2041700000000120476, 2041700000000120481,
2041700000000120482, 2041700000000120483, 2041700000000120484,
2041700000000120485
)
AND menu.status = 1
AND menu.is_deleted = 0;

View File

@ -66,8 +66,8 @@ SELECT 'seed_qqbot_message_push_menu_mismatch' AS check_name,
FROM expected_menu expected FROM expected_menu expected
LEFT JOIN admin_menu actual ON actual.id = expected.id LEFT JOIN admin_menu actual ON actual.id = expected.id
WHERE actual.id IS NULL WHERE actual.id IS NULL
OR actual.name <> expected.name OR NOT (BINARY actual.name <=> BINARY expected.name)
OR NOT (actual.auth_code <=> expected.auth_code) OR NOT (BINARY actual.auth_code <=> BINARY expected.auth_code)
OR actual.pid <> expected.pid OR actual.pid <> expected.pid
OR actual.sort <> expected.sort OR actual.sort <> expected.sort
OR actual.status <> 1 OR actual.status <> 1

View File

@ -117,6 +117,28 @@ const menuEntries: readonly MenuEntry[] = [
['2041700000000120485', '2041700000000100410', 'QqBotAccountMessagePushToggle', 'QqBot:Account:MessagePush:Toggle', '0'], ['2041700000000120485', '2041700000000100410', 'QqBotAccountMessagePushToggle', 'QqBot:Account:MessagePush:Toggle', '0'],
]; ];
/**
* Advances SQL single-quote state at one character, respecting SQL doubled
* quotes and MySQL backslash escaping. An odd number of preceding backslashes
* escapes a quote; an even number lets it open or close a string.
*
* @param sql SQL source being scanned.
* @param index Index of the current character in {@link sql}.
* @param quoted Whether the scanner is currently inside a single-quoted value.
* @returns The next quote state and whether the following doubled quote is consumed.
*/
const advanceSqlQuoteState = (sql: string, index: number, quoted: boolean) => {
if (sql[index] !== "'") return { quoted, skipNext: false };
if (quoted && sql[index + 1] === "'") return { quoted, skipNext: true };
let precedingBackslashes = 0;
for (let cursor = index - 1; sql[cursor] === '\\'; cursor -= 1) precedingBackslashes += 1;
return {
quoted: precedingBackslashes % 2 === 1 ? quoted : !quoted,
skipNext: false,
};
};
/** /**
* Normalizes SQL syntax while preserving the exact casing and whitespace inside * Normalizes SQL syntax while preserving the exact casing and whitespace inside
* single-quoted contract values. * single-quoted contract values.
@ -130,17 +152,17 @@ const normalizeSql = (sql: string) => {
const character = sql[index]; const character = sql[index];
if (quoted) { if (quoted) {
normalized += character; normalized += character;
if (character === "'" && sql[index + 1] === "'") { const quoteState = advanceSqlQuoteState(sql, index, quoted);
if (quoteState.skipNext) {
normalized += sql[index + 1]; normalized += sql[index + 1];
index += 1; index += 1;
} else if (character === "'" && sql[index - 1] !== '\\') {
quoted = false;
} }
quoted = quoteState.quoted;
previousWasWhitespace = false; previousWasWhitespace = false;
continue; continue;
} }
if (character === "'") { if (character === "'") {
quoted = true; quoted = advanceSqlQuoteState(sql, index, quoted).quoted;
normalized += character; normalized += character;
previousWasWhitespace = false; previousWasWhitespace = false;
continue; continue;
@ -180,12 +202,13 @@ const splitSqlTuple = (tuple: string) => {
let quoted = false; let quoted = false;
for (let index = 0; index < tuple.length; index += 1) { for (let index = 0; index < tuple.length; index += 1) {
const character = tuple[index]; const character = tuple[index];
if (character === "'" && tuple[index + 1] === "'") { const quoteState = advanceSqlQuoteState(tuple, index, quoted);
if (quoteState.skipNext) {
current += tuple[index + 1]; current += tuple[index + 1];
index += 1; index += 1;
continue; continue;
} }
if (character === "'" && tuple[index - 1] !== '\\') quoted = !quoted; quoted = quoteState.quoted;
if (character === ',' && !quoted) { if (character === ',' && !quoted) {
values.push(current.trim()); values.push(current.trim());
current = ''; current = '';
@ -208,12 +231,13 @@ const extractSqlTuples = (values: string) => {
let quoted = false; let quoted = false;
for (let index = 0; index < values.length; index += 1) { for (let index = 0; index < values.length; index += 1) {
const character = values[index]; const character = values[index];
if (character === "'" && values[index + 1] === "'") { const quoteState = advanceSqlQuoteState(values, index, quoted);
if (quoteState.skipNext) {
current += values[index + 1]; current += values[index + 1];
index += 1; index += 1;
continue; continue;
} }
if (character === "'" && values[index - 1] !== '\\') quoted = !quoted; quoted = quoteState.quoted;
if (!quoted && character === '(') { if (!quoted && character === '(') {
depth += 1; depth += 1;
if (depth === 1) current = ''; if (depth === 1) current = '';
@ -247,6 +271,24 @@ const extractMessagePushMenuRows = (sql: string) => {
] as MenuEntry); ] as MenuEntry);
}; };
/** Extracts the statement that grants the seeded message-push menus to active roles. */
const extractMessagePushMenuRoleGrantStatement = (sql: string) => {
const statements = splitSqlStatements(sql);
const menuSeedIndex = statements.findIndex((statement) =>
statement.startsWith('insert into admin_menu') && statement.includes(menuEntries[0][0]));
expect(menuSeedIndex).toBeGreaterThanOrEqual(0);
const roleGrantIndex = statements.findIndex((statement, index) =>
index > menuSeedIndex && statement.startsWith('insert ignore into admin_role_menu'));
expect(roleGrantIndex).toBeGreaterThan(menuSeedIndex);
return statements[roleGrantIndex] || '';
};
/** Extracts the explicit stable menu IDs from a menu-role grant statement. */
const extractMenuRoleGrantIds = (statement: string) => {
const ids = statement.match(/menu\.id in \(([^)]*)\)/)?.[1].match(/\d+/g) || [];
return ids;
};
/** Splits SQL statements only at semicolons outside single-quoted values. */ /** Splits SQL statements only at semicolons outside single-quoted values. */
const splitSqlStatements = (sql: string) => { const splitSqlStatements = (sql: string) => {
const statements: string[] = []; const statements: string[] = [];
@ -256,12 +298,13 @@ const splitSqlStatements = (sql: string) => {
for (let index = 0; index < sql.length; index += 1) { for (let index = 0; index < sql.length; index += 1) {
const character = sql[index]; const character = sql[index];
current += character; current += character;
if (quoted && character === "'" && sql[index + 1] === "'") { const quoteState = advanceSqlQuoteState(sql, index, quoted);
if (quoteState.skipNext) {
current += sql[index + 1]; current += sql[index + 1];
index += 1; index += 1;
continue; continue;
} }
if (character === "'" && sql[index - 1] !== '\\') quoted = !quoted; quoted = quoteState.quoted;
if (character === ';' && !quoted) { if (character === ';' && !quoted) {
statements.push(current.trim()); statements.push(current.trim());
current = ''; current = '';
@ -287,11 +330,12 @@ const extractExpectedMenuCte = (statement: string) => {
let quoted = false; let quoted = false;
for (let index = openIndex; index < statement.length; index += 1) { for (let index = openIndex; index < statement.length; index += 1) {
const character = statement[index]; const character = statement[index];
if (quoted && character === "'" && statement[index + 1] === "'") { const quoteState = advanceSqlQuoteState(statement, index, quoted);
if (quoteState.skipNext) {
index += 1; index += 1;
continue; continue;
} }
if (character === "'" && statement[index - 1] !== '\\') quoted = !quoted; quoted = quoteState.quoted;
if (quoted) continue; if (quoted) continue;
if (character === '(') depth += 1; if (character === '(') depth += 1;
if (character === ')') { if (character === ')') {
@ -325,6 +369,18 @@ describe('QQBot message-push SQL contract', () => {
.toBe("select auth_code, 'QqBot:MessageTemplate:List', '${{endpoint}}'"); .toBe("select auth_code, 'QqBot:MessageTemplate:List', '${{endpoint}}'");
}); });
it('recognizes SQL quote endings, doubled quotes, and odd or even backslashes in every scanner', () => {
expect(splitSqlTuple("'closed', next")).toEqual(["'closed'", 'next']);
expect(splitSqlTuple("'it''s closed', next")).toEqual(["'it's closed'", 'next']);
expect(splitSqlTuple(String.raw`'odd\' quote', next`)).toEqual([String.raw`'odd\' quote'`, 'next']);
expect(splitSqlTuple(String.raw`'even\\', next`)).toEqual([String.raw`'even\\'`, 'next']);
expect(extractSqlTuples(String.raw`('even\\'), ('next')`)).toEqual([String.raw`'even\\'`, "'next'"]);
expect(splitSqlStatements(String.raw`select 'even\\'; select 'next';`)).toEqual([
String.raw`select 'even\\';`,
"select 'next';",
]);
});
it('keeps each table DDL and index tuple exact in current and refactor schemas', () => { it('keeps each table DDL and index tuple exact in current and refactor schemas', () => {
for (const [table, expected] of Object.entries(messagePushTableDefinitions)) { for (const [table, expected] of Object.entries(messagePushTableDefinitions)) {
for (const sql of [bootstrapSql, schemaSql]) { for (const sql of [bootstrapSql, schemaSql]) {
@ -374,12 +430,23 @@ describe('QQBot message-push SQL contract', () => {
} }
}); });
it('grants only the seeded message-push menus to active super and admin roles after refactor seeding', () => {
const roleGrant = extractMessagePushMenuRoleGrantStatement(seedSql);
expect(roleGrant).toContain('insert ignore into admin_role_menu (role_id, menu_id)');
expect(extractMenuRoleGrantIds(roleGrant)).toEqual(menuEntries.map(([id]) => id));
expect(roleGrant).toContain("role.role_code in ('super', 'admin')");
expect(roleGrant).toContain('role.status = 1');
expect(roleGrant).toContain('role.is_deleted = 0');
expect(roleGrant).toContain('menu.status = 1');
expect(roleGrant).toContain('menu.is_deleted = 0');
});
it('uses an exact mismatch CTE and null-safe predicates for every stable menu tuple', () => { it('uses an exact mismatch CTE and null-safe predicates for every stable menu tuple', () => {
const mismatch = extractVerificationStatement(verifySql, 'seed_qqbot_message_push_menu_mismatch'); const mismatch = extractVerificationStatement(verifySql, 'seed_qqbot_message_push_menu_mismatch');
expect(extractMismatchMenuRows(mismatch)).toEqual(menuEntries); expect(extractMismatchMenuRows(mismatch)).toEqual(menuEntries);
expect(mismatch).toContain('left join admin_menu actual on actual.id = expected.id'); expect(mismatch).toContain('left join admin_menu actual on actual.id = expected.id');
expect(mismatch).toContain('actual.name <> expected.name'); expect(mismatch).toContain('not (binary actual.name <=> binary expected.name)');
expect(mismatch).toContain('not (actual.auth_code <=> expected.auth_code)'); expect(mismatch).toContain('not (binary actual.auth_code <=> binary expected.auth_code)');
expect(mismatch).toContain('actual.pid <> expected.pid'); expect(mismatch).toContain('actual.pid <> expected.pid');
expect(mismatch).toContain('actual.sort <> expected.sort'); expect(mismatch).toContain('actual.sort <> expected.sort');
expect(mismatch).toContain('actual.status <> 1'); expect(mismatch).toContain('actual.status <> 1');
@ -405,4 +472,15 @@ describe('QQBot message-push SQL contract', () => {
expect(roleGrant).toContain('role.is_deleted = 0'); expect(roleGrant).toContain('role.is_deleted = 0');
expect(roleGrant).toContain('role_menu.menu_id is null'); expect(roleGrant).toContain('role_menu.menu_id is null');
}); });
it('extracts the three message-push verification CTEs as separate statements', () => {
const checkNames = splitSqlStatements(verifySql)
.filter((statement) => statement.includes('seed_qqbot_message_push_menu_'))
.map((statement) => statement.match(/'([^']+)' as check_name/)?.[1]);
expect(checkNames).toEqual([
'seed_qqbot_message_push_menu_mismatch',
'seed_qqbot_message_push_menu_cardinality',
'seed_qqbot_message_push_menu_role_grant_missing',
]);
});
}); });