feat(GOV-012): 建立纯 Node 导出器安全契约
This commit is contained in:
@@ -0,0 +1,74 @@
|
||||
import { createRequire } from 'node:module';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const mysql2Version = require('mysql2/package.json').version;
|
||||
|
||||
export const MYSQL2_EXPORTER_METADATA = Object.freeze({
|
||||
name: 'mysql2-show-create',
|
||||
version: '1.0.0',
|
||||
driverName: 'mysql2',
|
||||
driverVersion: mysql2Version,
|
||||
});
|
||||
|
||||
export const DEFAULT_EXPORT_LIMITS = Object.freeze({
|
||||
connectTimeoutMs: 5_000,
|
||||
queryTimeoutMs: 5_000,
|
||||
totalTimeoutMs: 30_000,
|
||||
maxObjects: 5_000,
|
||||
maxDefinitionBytes: 1_048_576,
|
||||
});
|
||||
|
||||
export class SchemaExportError extends Error {
|
||||
constructor(code, message) {
|
||||
super(message);
|
||||
this.name = 'SchemaExportError';
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 配置在 Schema 治理入口已完成 development、主机白名单和固定库名校验;这里仅映射
|
||||
* 连接所需字段,固定禁用多语句以防止未来元数据查询被拼接为任意 SQL。
|
||||
*/
|
||||
export function buildConnectionOptions(config) {
|
||||
return {
|
||||
host: config.MYSQL_HOST,
|
||||
port: Number(config.MYSQL_PORT),
|
||||
user: config.MYSQL_USERNAME,
|
||||
password: config.MYSQL_PASSWORD,
|
||||
database: config.MYSQL_DATABASE,
|
||||
charset: 'utf8mb4',
|
||||
timezone: '+08:00',
|
||||
connectTimeout: DEFAULT_EXPORT_LIMITS.connectTimeoutMs,
|
||||
multipleStatements: false,
|
||||
enableKeepAlive: true,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 元数据对象名仍需防御性转义,避免控制字符破坏 `SHOW CREATE` 查询边界;名称不合法时
|
||||
* 不回显输入,确保异常路径不会把潜在敏感值写入日志。
|
||||
*/
|
||||
export function quoteIdentifier(identifier) {
|
||||
if (typeof identifier !== 'string' || identifier.length === 0 || /[\u0000-\u001F\u007F]/.test(identifier)) {
|
||||
throw new SchemaExportError('INVALID_IDENTIFIER', 'Schema 标识符不能为空且不得包含控制字符');
|
||||
}
|
||||
|
||||
return `\`${identifier.replaceAll('`', '``')}\``;
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出器只接受项目数据库兼容基线,拒绝 MariaDB 和 MySQL 5.7 之外的服务器,避免相同
|
||||
* `SHOW CREATE` 文本在不兼容语义下被误认为可恢复的 Schema 快照。
|
||||
*/
|
||||
export function assertMysql57ServerMetadata(metadata) {
|
||||
if (!/^5\.7\.\d+(?:[-+].*)?$/.test(metadata?.serverVersion ?? '')) {
|
||||
throw new SchemaExportError('UNSUPPORTED_SERVER_VERSION', 'MySQL 服务端必须为 5.7.x');
|
||||
}
|
||||
if (metadata.databaseCharset !== 'utf8mb4') {
|
||||
throw new SchemaExportError('INVALID_DATABASE_CHARSET', '数据库字符集必须为 utf8mb4');
|
||||
}
|
||||
if (metadata.databaseCollation !== 'utf8mb4_general_ci') {
|
||||
throw new SchemaExportError('INVALID_DATABASE_COLLATION', '数据库排序规则必须为 utf8mb4_general_ci');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
DEFAULT_EXPORT_LIMITS,
|
||||
MYSQL2_EXPORTER_METADATA,
|
||||
SchemaExportError,
|
||||
assertMysql57ServerMetadata,
|
||||
buildConnectionOptions,
|
||||
quoteIdentifier,
|
||||
} from './mysql2-schema-exporter.mjs';
|
||||
|
||||
const config = Object.freeze({
|
||||
MYSQL_HOST: '127.0.0.1',
|
||||
MYSQL_PORT: '3306',
|
||||
MYSQL_DATABASE: 'a_share_analysis_dev',
|
||||
MYSQL_USERNAME: 'schema_reader',
|
||||
MYSQL_PASSWORD: 'not-for-logs',
|
||||
});
|
||||
|
||||
test('连接配置固定单连接、utf8mb4、东八区且禁用多语句', () => {
|
||||
const options = buildConnectionOptions(config);
|
||||
assert.deepEqual(options, {
|
||||
host: '127.0.0.1',
|
||||
port: 3306,
|
||||
user: 'schema_reader',
|
||||
password: 'not-for-logs',
|
||||
database: 'a_share_analysis_dev',
|
||||
charset: 'utf8mb4',
|
||||
timezone: '+08:00',
|
||||
connectTimeout: 5_000,
|
||||
multipleStatements: false,
|
||||
enableKeepAlive: true,
|
||||
});
|
||||
});
|
||||
|
||||
test('标识符转义反引号并拒绝空值和控制字符', () => {
|
||||
assert.equal(quoteIdentifier('market`view'), '`market``view`');
|
||||
for (const value of ['', 'line\nbreak', 'zero\0byte']) {
|
||||
assert.throws(() => quoteIdentifier(value), SchemaExportError);
|
||||
}
|
||||
});
|
||||
|
||||
test('只接受 MySQL 5.7.x 与固定字符规则', () => {
|
||||
const valid = {
|
||||
serverVersion: '5.7.42-log',
|
||||
databaseCharset: 'utf8mb4',
|
||||
databaseCollation: 'utf8mb4_general_ci',
|
||||
};
|
||||
assert.doesNotThrow(() => assertMysql57ServerMetadata(valid));
|
||||
for (const serverVersion of ['5.6.51', '8.0.40', '10.6.18-MariaDB']) {
|
||||
assert.throws(() => assertMysql57ServerMetadata({ ...valid, serverVersion }));
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user