dsh-api-runner 0.3.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +35 -4
- package/client/client.js +673 -329
- package/package.json +6 -4
- package/scripts/check-leak.mjs +150 -0
- package/src/db.js +543 -37
- package/src/main.js +77 -13
- package/src/ops.js +46 -8
- package/test/client.mjs +41 -2
- package/test/db.mjs +190 -24
- package/test/plugin.mjs +76 -50
- package/test/security.mjs +200 -0
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 安全专项测试:SQL 危险操作必须被拦。
|
|
3
|
+
* 这是数据安全的核心防线,单独一份,改坏了一眼能看出来。
|
|
4
|
+
*/
|
|
5
|
+
import assert from 'node:assert/strict';
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import os from 'node:os';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
10
|
+
|
|
11
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
12
|
+
const root = path.resolve(here, '..');
|
|
13
|
+
const load = (rel) => import(pathToFileURL(path.join(root, rel)).href);
|
|
14
|
+
|
|
15
|
+
const { DbManager, classifySql, isDestructiveSql } = await load('src/db.js');
|
|
16
|
+
|
|
17
|
+
let passed = 0;
|
|
18
|
+
function ok(name, fn) {
|
|
19
|
+
return Promise.resolve()
|
|
20
|
+
.then(fn)
|
|
21
|
+
.then(() => { passed += 1; console.log(` ✓ ${name}`); })
|
|
22
|
+
.catch((error) => {
|
|
23
|
+
console.error(` ✗ ${name}\n ${error?.stack || error}`);
|
|
24
|
+
process.exitCode = 1;
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
console.log('classifySql:只读判定');
|
|
29
|
+
|
|
30
|
+
const READ_SQL = [
|
|
31
|
+
'SELECT 1',
|
|
32
|
+
'select * from users',
|
|
33
|
+
'SELECT * FROM users WHERE name = ?',
|
|
34
|
+
' SELECT 1 ',
|
|
35
|
+
'SELECT * FROM t WHERE note = \'delete\'', // 字符串里的关键字不算
|
|
36
|
+
'SELECT * FROM t WHERE note = \'drop table\'',
|
|
37
|
+
'SHOW TABLES',
|
|
38
|
+
'DESCRIBE users',
|
|
39
|
+
'DESC users',
|
|
40
|
+
'EXPLAIN SELECT * FROM users',
|
|
41
|
+
'PRAGMA table_info(users)',
|
|
42
|
+
'WITH x AS (SELECT 1 AS a) SELECT * FROM x',
|
|
43
|
+
'-- 注释\nSELECT 1',
|
|
44
|
+
'/* 块注释 */ SELECT 1',
|
|
45
|
+
];
|
|
46
|
+
|
|
47
|
+
for (const sql of READ_SQL) {
|
|
48
|
+
await ok(`只读: ${sql.replace(/\n/g, ' ').slice(0, 46)}`, () => {
|
|
49
|
+
assert.equal(classifySql(sql).kind, 'read', `应判定为只读,实际:${JSON.stringify(classifySql(sql))}`);
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
console.log('\nclassifySql:写操作必须识别');
|
|
54
|
+
|
|
55
|
+
const WRITE_SQL = [
|
|
56
|
+
'INSERT INTO t VALUES (1)',
|
|
57
|
+
'UPDATE t SET a = 1 WHERE id = 2',
|
|
58
|
+
'DELETE FROM t WHERE id = 1',
|
|
59
|
+
'DROP TABLE t',
|
|
60
|
+
'TRUNCATE TABLE t',
|
|
61
|
+
'ALTER TABLE t ADD COLUMN c INT',
|
|
62
|
+
'CREATE TABLE t (a INT)',
|
|
63
|
+
'CREATE INDEX i ON t(a)',
|
|
64
|
+
'RENAME TABLE a TO b',
|
|
65
|
+
'GRANT SELECT ON t TO u',
|
|
66
|
+
'REVOKE SELECT ON t FROM u',
|
|
67
|
+
'WITH x AS (SELECT 1) DELETE FROM t', // 以 WITH 开头的写
|
|
68
|
+
'WITH x AS (SELECT 1) INSERT INTO t SELECT * FROM x',
|
|
69
|
+
'SELECT * FROM t FOR UPDATE', // 加锁
|
|
70
|
+
'SELECT * FROM t FOR SHARE',
|
|
71
|
+
'SELECT * INTO OUTFILE \'/tmp/x\' FROM t', // 写文件
|
|
72
|
+
'SELECT load_file(\'/etc/passwd\')', // 读服务器文件
|
|
73
|
+
'EXPLAIN ANALYZE DELETE FROM t', // 真的会执行
|
|
74
|
+
'BEGIN',
|
|
75
|
+
'COMMIT',
|
|
76
|
+
'ROLLBACK',
|
|
77
|
+
'SET autocommit = 0',
|
|
78
|
+
'CALL some_proc()',
|
|
79
|
+
'EXEC sp_who',
|
|
80
|
+
'VACUUM',
|
|
81
|
+
'ATTACH DATABASE \'x\' AS y',
|
|
82
|
+
'PRAGMA journal_mode = WAL', // PRAGMA 也可能写
|
|
83
|
+
];
|
|
84
|
+
|
|
85
|
+
for (const sql of WRITE_SQL) {
|
|
86
|
+
await ok(`写: ${sql.slice(0, 46)}`, () => {
|
|
87
|
+
const v = classifySql(sql);
|
|
88
|
+
assert.notEqual(v.kind, 'read', `不应判定为只读:${sql} → ${JSON.stringify(v)}`);
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
console.log('\nclassifySql:多语句与未知');
|
|
93
|
+
|
|
94
|
+
await ok('多语句一律拒绝(即使每条都只读)', () => {
|
|
95
|
+
const v = classifySql('SELECT 1; SELECT 2');
|
|
96
|
+
assert.equal(v.kind, 'write');
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
await ok('空 SQL 判为 unknown', () => {
|
|
100
|
+
assert.equal(classifySql('').kind, 'unknown');
|
|
101
|
+
assert.equal(classifySql(' ').kind, 'unknown');
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
console.log('\nisDestructiveSql:毁灭性操作');
|
|
105
|
+
|
|
106
|
+
const DESTRUCTIVE = [
|
|
107
|
+
'DROP TABLE t',
|
|
108
|
+
'DROP DATABASE d',
|
|
109
|
+
'TRUNCATE TABLE t',
|
|
110
|
+
'DELETE FROM t', // 没有 WHERE
|
|
111
|
+
'UPDATE t SET a = 1', // 没有 WHERE
|
|
112
|
+
'GRANT ALL ON t TO u',
|
|
113
|
+
'REVOKE ALL ON t FROM u',
|
|
114
|
+
];
|
|
115
|
+
for (const sql of DESTRUCTIVE) {
|
|
116
|
+
await ok(`毁灭性: ${sql}`, () => {
|
|
117
|
+
assert.equal(isDestructiveSql(sql), true, `应判为毁灭性:${sql}`);
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const NOT_DESTRUCTIVE = [
|
|
122
|
+
'DELETE FROM t WHERE id = 1',
|
|
123
|
+
'UPDATE t SET a = 1 WHERE id = 2',
|
|
124
|
+
'INSERT INTO t VALUES (1)',
|
|
125
|
+
'SELECT 1',
|
|
126
|
+
'CREATE TABLE t (a INT)',
|
|
127
|
+
];
|
|
128
|
+
for (const sql of NOT_DESTRUCTIVE) {
|
|
129
|
+
await ok(`非毁灭性: ${sql}`, () => {
|
|
130
|
+
assert.equal(isDestructiveSql(sql), false, `不应判为毁灭性:${sql}`);
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
console.log('\n端到端:授权 + 安全拦截');
|
|
135
|
+
|
|
136
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'api-runner-sec-'));
|
|
137
|
+
const dbFile = path.join(tmp, 'sec.db');
|
|
138
|
+
const manager = new DbManager({ homeDir: tmp });
|
|
139
|
+
const P = 'default';
|
|
140
|
+
|
|
141
|
+
const saved = manager.saveConnection({ name: 'sec', type: 'sqlite', database: dbFile });
|
|
142
|
+
assert.equal(saved.ok, true);
|
|
143
|
+
const cid = saved.connection.id;
|
|
144
|
+
|
|
145
|
+
await ok('未授权:连 SELECT 都拒绝', async () => {
|
|
146
|
+
const r = await manager.query(cid, 'SELECT 1', [], { projectKey: P });
|
|
147
|
+
assert.equal(r.ok, false);
|
|
148
|
+
assert.equal(r.code, 'NOT_AUTHORIZED');
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
manager.setAccess(P, cid, 'readonly');
|
|
152
|
+
|
|
153
|
+
await ok('只读授权:写操作被 PERMISSION_DENIED 拦住', async () => {
|
|
154
|
+
const r = await manager.query(cid, 'INSERT INTO t VALUES (1)', [], { projectKey: P, readonly: false });
|
|
155
|
+
assert.equal(r.ok, false);
|
|
156
|
+
assert.equal(r.code, 'PERMISSION_DENIED');
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
manager.setAccess(P, cid, 'readwrite');
|
|
160
|
+
|
|
161
|
+
await ok('读写授权:建表可以,DROP 仍被 DESTRUCTIVE_BLOCKED 拦住', async () => {
|
|
162
|
+
const create = await manager.query(cid, 'CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT)', [], { projectKey: P, readonly: false });
|
|
163
|
+
assert.equal(create.ok, true, JSON.stringify(create));
|
|
164
|
+
|
|
165
|
+
const drop = await manager.query(cid, 'DROP TABLE t', [], { projectKey: P, readonly: false });
|
|
166
|
+
assert.equal(drop.ok, false);
|
|
167
|
+
assert.equal(drop.code, 'DESTRUCTIVE_BLOCKED', JSON.stringify(drop));
|
|
168
|
+
|
|
169
|
+
// 确认表还在
|
|
170
|
+
const check = await manager.query(cid, "SELECT name FROM sqlite_master WHERE type='table' AND name='t'", [], { projectKey: P });
|
|
171
|
+
assert.equal(check.rowCount, 1, '表不应被删掉');
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
await ok('读写授权:无 WHERE 的 DELETE 被拦,带 WHERE 的放行', async () => {
|
|
175
|
+
await manager.query(cid, "INSERT INTO t (name) VALUES ('a')", [], { projectKey: P, readonly: false });
|
|
176
|
+
await manager.query(cid, "INSERT INTO t (name) VALUES ('b')", [], { projectKey: P, readonly: false });
|
|
177
|
+
|
|
178
|
+
const noWhere = await manager.query(cid, 'DELETE FROM t', [], { projectKey: P, readonly: false });
|
|
179
|
+
assert.equal(noWhere.ok, false);
|
|
180
|
+
assert.equal(noWhere.code, 'DESTRUCTIVE_BLOCKED');
|
|
181
|
+
|
|
182
|
+
const withWhere = await manager.query(cid, 'DELETE FROM t WHERE name = ?', ['a'], { projectKey: P, readonly: false });
|
|
183
|
+
assert.equal(withWhere.ok, true, JSON.stringify(withWhere));
|
|
184
|
+
|
|
185
|
+
const count = await manager.query(cid, 'SELECT COUNT(*) AS n FROM t', [], { projectKey: P });
|
|
186
|
+
assert.equal(count.rows[0].n, 1, '只应删掉 name=a 那条');
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
await ok('模型侧永远只读(即使请求里写 readonly:false 也被强制为 true)', async () => {
|
|
190
|
+
// 模拟 main.js 的做法:固定 readonly: true
|
|
191
|
+
const r = await manager.query(cid, 'DELETE FROM t WHERE name = ?', ['b'], { projectKey: P, readonly: true });
|
|
192
|
+
assert.equal(r.ok, false);
|
|
193
|
+
// readonly:true 时,写操作在授权层之后仍会被 classifySql 拦
|
|
194
|
+
assert.ok(['PERMISSION_DENIED', 'DESTRUCTIVE_BLOCKED', 'READONLY_VIOLATION'].includes(r.code), JSON.stringify(r));
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
manager.dispose();
|
|
198
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
199
|
+
|
|
200
|
+
console.log(`\n${process.exitCode ? 'FAILED' : 'PASSED'}: ${passed} checks`);
|