fix(GOV-012): 修复 Schema 定义安全与错误语义
This commit is contained in:
@@ -136,9 +136,9 @@ async function queryCatalogRows(connection, sql, database, limits) {
|
||||
values: [database],
|
||||
timeout: limits.queryTimeoutMs,
|
||||
});
|
||||
} catch {
|
||||
} catch (error) {
|
||||
// mysql2 错误可能包含连接 URL、用户名或密码,目录读取的公共接口一律不透传原始文本。
|
||||
throw new SchemaExportError('DATABASE_QUERY_FAILED', '开发库 Schema 查询失败');
|
||||
sanitizeDatabaseQueryError(error);
|
||||
}
|
||||
if (!Array.isArray(result) || !Array.isArray(result[0])) {
|
||||
throw new SchemaExportError('CATALOG_SHAPE_INVALID', 'Schema 对象目录查询结果无效');
|
||||
@@ -302,6 +302,19 @@ function maskStringLiterals(sql) {
|
||||
return String(sql).replace(/'(?:''|\\.|[^'\\])*'/gs, "''");
|
||||
}
|
||||
|
||||
function removeTableAutoIncrementOutsideLiterals(sql) {
|
||||
const literals = [];
|
||||
const masked = String(sql).replace(/'(?:''|\\.|[^'\\])*'/gs, (literal) => {
|
||||
const index = literals.push(literal) - 1;
|
||||
return `\u0001${index}\u0002`;
|
||||
});
|
||||
const cleaned = masked.replace(
|
||||
/\sAUTO_INCREMENT=\d+(?=\s(?:DEFAULT\s+)?CHARSET=)/i,
|
||||
'',
|
||||
);
|
||||
return cleaned.replace(/\u0001(\d+)\u0002/g, (_match, index) => literals[Number(index)]);
|
||||
}
|
||||
|
||||
/**
|
||||
* SHOW CREATE 的格式由服务端决定,这里只移除与结构无关的账号绑定、当前表自增值、
|
||||
* 行尾分号和换行差异,不重排对象体,也不删除字符串字面量中的业务文本。
|
||||
@@ -313,20 +326,26 @@ export function normalizeCreateDefinition(definition) {
|
||||
/^(CREATE\s+(?:ALGORITHM=\S+\s+)?)DEFINER=`(?:``|[^`])+`@`(?:`(?:``|[^`])+`|'(?:''|[^'])+'|\S+)\s+/i,
|
||||
'$1',
|
||||
)
|
||||
.replace(/\sAUTO_INCREMENT=\d+(?=\s(?:DEFAULT\s+)?CHARSET=)/i, '')
|
||||
.trim()
|
||||
.replace(/;+\s*$/, '');
|
||||
const inspectable = maskStringLiterals(normalized);
|
||||
const withoutCurrentAutoIncrement = removeTableAutoIncrementOutsideLiterals(normalized);
|
||||
const inspectable = maskStringLiterals(withoutCurrentAutoIncrement);
|
||||
if (
|
||||
/\bDEFINER\s*=/i.test(inspectable) ||
|
||||
/\b(?:password|passwd|pwd|api[_-]?key|api[_-]?token|access[_-]?token|client[_-]?secret|secret)\s*[:=]/i.test(
|
||||
inspectable,
|
||||
) ||
|
||||
/\b(?:mysql|mariadb):\/\/[^\s'"`]+/i.test(inspectable)
|
||||
/\b(?:mysql|mariadb):\/\/[^\s'"`]+/i.test(inspectable) ||
|
||||
/(?:--|\/\*)\s*(?:host|hostname|server)\s*[:=]\s*[^\s*]+/i.test(inspectable)
|
||||
) {
|
||||
throw new SchemaExportError('UNSAFE_DEFINITION', 'Schema 对象定义包含禁止内容');
|
||||
}
|
||||
return normalized;
|
||||
return withoutCurrentAutoIncrement;
|
||||
}
|
||||
|
||||
function sanitizeDatabaseQueryError(error) {
|
||||
if (error instanceof SchemaExportError) throw error;
|
||||
throw new SchemaExportError('DATABASE_QUERY_FAILED', '开发库 Schema 查询失败');
|
||||
}
|
||||
|
||||
export async function readMysql57ServerMetadata(
|
||||
@@ -334,11 +353,16 @@ export async function readMysql57ServerMetadata(
|
||||
database,
|
||||
limits = DEFAULT_EXPORT_LIMITS,
|
||||
) {
|
||||
const [rows] = await connection.query({
|
||||
sql: SERVER_METADATA_QUERY,
|
||||
values: [database],
|
||||
timeout: limits.queryTimeoutMs,
|
||||
});
|
||||
let rows;
|
||||
try {
|
||||
[rows] = await connection.query({
|
||||
sql: SERVER_METADATA_QUERY,
|
||||
values: [database],
|
||||
timeout: limits.queryTimeoutMs,
|
||||
});
|
||||
} catch (error) {
|
||||
sanitizeDatabaseQueryError(error);
|
||||
}
|
||||
if (!Array.isArray(rows) || rows.length !== 1) {
|
||||
throw new SchemaExportError('DATABASE_METADATA_INVALID', '开发库元数据查询结果无效');
|
||||
}
|
||||
@@ -365,7 +389,12 @@ function getCreateDescriptors(catalog) {
|
||||
|
||||
async function readCreateDefinition(connection, descriptor, limits) {
|
||||
const sql = `SHOW CREATE ${descriptor.type} ${quoteIdentifier(DEVELOPMENT_DATABASE)}.${quoteIdentifier(descriptor.name)}`;
|
||||
const [rows] = await connection.query({ sql, timeout: limits.queryTimeoutMs });
|
||||
let rows;
|
||||
try {
|
||||
[rows] = await connection.query({ sql, timeout: limits.queryTimeoutMs });
|
||||
} catch (error) {
|
||||
sanitizeDatabaseQueryError(error);
|
||||
}
|
||||
if (!Array.isArray(rows) || rows.length === 0) {
|
||||
throw new SchemaExportError('SHOW_CREATE_EMPTY', 'SHOW CREATE 未返回对象定义');
|
||||
}
|
||||
|
||||
@@ -494,6 +494,20 @@ test('定义规范化只移除账号绑定和当前自增值', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('规范化保留例程中的自增文本并拒绝主机信息', () => {
|
||||
const routine = normalizeCreateDefinition(
|
||||
"CREATE PROCEDURE `sample`() SELECT ' AUTO_INCREMENT=123 DEFAULT CHARSET=utf8mb4'",
|
||||
);
|
||||
assert.equal(
|
||||
routine,
|
||||
"CREATE PROCEDURE `sample`() SELECT ' AUTO_INCREMENT=123 DEFAULT CHARSET=utf8mb4'",
|
||||
);
|
||||
assert.throws(
|
||||
() => normalizeCreateDefinition('CREATE TABLE `sample` (`id` int) /* Host: prod-db.internal */'),
|
||||
(error) => error.code === 'UNSAFE_DEFINITION',
|
||||
);
|
||||
});
|
||||
|
||||
test('完整导出双重读取一致并始终关闭连接', async () => {
|
||||
const connection = createExportConnection();
|
||||
let receivedOptions;
|
||||
@@ -592,6 +606,29 @@ test('SHOW CREATE 拒绝空结果、错误形状和过大定义', async () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('直接元数据和 SHOW CREATE 查询错误统一脱敏且保留既有业务错误', async () => {
|
||||
const rawFailure = new Error('host=prod-db.internal password=not-for-logs');
|
||||
await assert.rejects(
|
||||
() => readMysql57ServerMetadata({ query: async () => { throw rawFailure; } }, 'a_share_analysis_dev'),
|
||||
(error) =>
|
||||
error.code === 'DATABASE_QUERY_FAILED' &&
|
||||
!error.message.includes('prod-db.internal') &&
|
||||
!error.message.includes('not-for-logs'),
|
||||
);
|
||||
await assert.rejects(
|
||||
() => readDefinitions({ query: async () => { throw rawFailure; } }, completeCatalog),
|
||||
(error) =>
|
||||
error.code === 'DATABASE_QUERY_FAILED' &&
|
||||
!error.message.includes('prod-db.internal') &&
|
||||
!error.message.includes('not-for-logs'),
|
||||
);
|
||||
const timeout = new SchemaExportError('EXPORT_TIMEOUT', 'Schema 导出超过总时长限制');
|
||||
await assert.rejects(
|
||||
() => readDefinitions({ query: async () => { throw timeout; } }, completeCatalog),
|
||||
(error) => error === timeout,
|
||||
);
|
||||
});
|
||||
|
||||
test('查询失败和总超时只返回脱敏错误并关闭连接', async () => {
|
||||
const failed = createExportConnection({
|
||||
queryError: new Error('connect 127.0.0.1 password=not-for-logs'),
|
||||
|
||||
Reference in New Issue
Block a user