ffp-sql-sandbox 0.1.0
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/INTEGRATION.md +37 -0
- package/LICENSE +21 -0
- package/README.md +144 -0
- package/dist/docker-executor.d.ts +6 -0
- package/dist/docker-executor.d.ts.map +1 -0
- package/dist/docker-executor.js +192 -0
- package/dist/docker-executor.js.map +1 -0
- package/dist/docker-types.d.ts +37 -0
- package/dist/docker-types.d.ts.map +1 -0
- package/dist/docker-types.js +50 -0
- package/dist/docker-types.js.map +1 -0
- package/dist/execute-sql.d.ts +9 -0
- package/dist/execute-sql.d.ts.map +1 -0
- package/dist/execute-sql.js +89 -0
- package/dist/execute-sql.js.map +1 -0
- package/dist/host-allowlist.d.ts +2 -0
- package/dist/host-allowlist.d.ts.map +1 -0
- package/dist/host-allowlist.js +9 -0
- package/dist/host-allowlist.js.map +1 -0
- package/dist/image.d.ts +16 -0
- package/dist/image.d.ts.map +1 -0
- package/dist/image.js +19 -0
- package/dist/image.js.map +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +9 -0
- package/dist/index.js.map +1 -0
- package/dist/limits.d.ts +11 -0
- package/dist/limits.d.ts.map +1 -0
- package/dist/limits.js +25 -0
- package/dist/limits.js.map +1 -0
- package/dist/password-tar.d.ts +6 -0
- package/dist/password-tar.d.ts.map +1 -0
- package/dist/password-tar.js +29 -0
- package/dist/password-tar.js.map +1 -0
- package/dist/rewrite-host.d.ts +6 -0
- package/dist/rewrite-host.d.ts.map +1 -0
- package/dist/rewrite-host.js +16 -0
- package/dist/rewrite-host.js.map +1 -0
- package/dist/stream-consumer.d.ts +31 -0
- package/dist/stream-consumer.d.ts.map +1 -0
- package/dist/stream-consumer.js +139 -0
- package/dist/stream-consumer.js.map +1 -0
- package/dist/types.d.ts +50 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +8 -0
- package/dist/types.js.map +1 -0
- package/dist/validate-sql.d.ts +8 -0
- package/dist/validate-sql.d.ts.map +1 -0
- package/dist/validate-sql.js +30 -0
- package/dist/validate-sql.js.map +1 -0
- package/package.json +45 -0
- package/sandbox/Dockerfile +13 -0
- package/sandbox/events.d.ts +12 -0
- package/sandbox/events.js +14 -0
- package/sandbox/execute.js +235 -0
- package/sandbox/package.json +10 -0
- package/sandbox/secrets.d.ts +5 -0
- package/sandbox/secrets.js +16 -0
- package/sandbox/session-setup.d.ts +4 -0
- package/sandbox/session-setup.js +20 -0
- package/sandbox/stream-limit.d.ts +16 -0
- package/sandbox/stream-limit.js +35 -0
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import { stdin } from 'node:process';
|
|
2
|
+
import pg from 'pg';
|
|
3
|
+
import mysql from 'mysql2/promise';
|
|
4
|
+
import { createStreamLimiter } from './stream-limit.js';
|
|
5
|
+
import { sessionSetupStatements } from './session-setup.js';
|
|
6
|
+
import { readPassword } from './secrets.js';
|
|
7
|
+
import { writeEvent, writeRunnerError } from './events.js';
|
|
8
|
+
|
|
9
|
+
async function readStdin() {
|
|
10
|
+
const chunks = [];
|
|
11
|
+
for await (const chunk of stdin) {
|
|
12
|
+
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
13
|
+
}
|
|
14
|
+
return Buffer.concat(chunks).toString('utf8');
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function envInt(name, fallback) {
|
|
18
|
+
const raw = process.env[name];
|
|
19
|
+
if (raw === undefined || raw === '') {
|
|
20
|
+
return fallback;
|
|
21
|
+
}
|
|
22
|
+
const value = Number.parseInt(raw, 10);
|
|
23
|
+
if (!Number.isInteger(value) || value <= 0) {
|
|
24
|
+
throw new Error(`${name} must be a positive integer`);
|
|
25
|
+
}
|
|
26
|
+
return value;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function dialectFromEnv() {
|
|
30
|
+
const raw = (process.env.DB_TYPE ?? 'postgres').toLowerCase();
|
|
31
|
+
if (raw === 'postgres' || raw === 'postgresql') {
|
|
32
|
+
return 'postgres';
|
|
33
|
+
}
|
|
34
|
+
if (raw === 'mysql') {
|
|
35
|
+
return 'mysql';
|
|
36
|
+
}
|
|
37
|
+
throw new Error(`Unsupported DB_TYPE: ${raw}`);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function runPostgres({ host, port, user, password, database, sql, timeoutMs, limiter }) {
|
|
41
|
+
const client = new pg.Client({
|
|
42
|
+
host,
|
|
43
|
+
port,
|
|
44
|
+
user,
|
|
45
|
+
password,
|
|
46
|
+
database,
|
|
47
|
+
statement_timeout: timeoutMs,
|
|
48
|
+
connectionTimeoutMillis: 5_000,
|
|
49
|
+
});
|
|
50
|
+
await client.connect();
|
|
51
|
+
try {
|
|
52
|
+
for (const statement of sessionSetupStatements('postgres', timeoutMs)) {
|
|
53
|
+
await client.query(statement);
|
|
54
|
+
}
|
|
55
|
+
await streamPg(client, sql, limiter);
|
|
56
|
+
} finally {
|
|
57
|
+
try {
|
|
58
|
+
await client.end();
|
|
59
|
+
} catch {
|
|
60
|
+
// ignore
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function streamPg(client, sql, limiter) {
|
|
66
|
+
return new Promise((resolve, reject) => {
|
|
67
|
+
const query = new pg.Query({ text: sql, rowMode: 'array' });
|
|
68
|
+
let settled = false;
|
|
69
|
+
|
|
70
|
+
const finish = () => {
|
|
71
|
+
if (settled) {
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
settled = true;
|
|
75
|
+
resolve();
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
query.on('fields', (fields) => {
|
|
79
|
+
writeEvent({ type: 'meta', columns: fields.map((field) => field.name) });
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
query.on('row', (row) => {
|
|
83
|
+
if (settled) {
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
const decision = limiter.push(row);
|
|
87
|
+
if (!decision.accept) {
|
|
88
|
+
writeEvent({
|
|
89
|
+
type: 'end',
|
|
90
|
+
truncated: true,
|
|
91
|
+
reason: decision.reason,
|
|
92
|
+
});
|
|
93
|
+
finish();
|
|
94
|
+
client.connection?.stream?.destroy?.();
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
writeEvent({ type: 'row', values: row });
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
query.on('end', () => {
|
|
101
|
+
if (settled) {
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
writeEvent({ type: 'end', truncated: false });
|
|
105
|
+
finish();
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
query.on('error', (err) => {
|
|
109
|
+
if (settled) {
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
settled = true;
|
|
113
|
+
reject(err);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
client.query(query);
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async function runMysql({ host, port, user, password, database, sql, timeoutMs, limiter }) {
|
|
121
|
+
const conn = await mysql.createConnection({
|
|
122
|
+
host,
|
|
123
|
+
port,
|
|
124
|
+
user,
|
|
125
|
+
password,
|
|
126
|
+
database,
|
|
127
|
+
connectTimeout: 5_000,
|
|
128
|
+
});
|
|
129
|
+
try {
|
|
130
|
+
for (const statement of sessionSetupStatements('mysql', timeoutMs)) {
|
|
131
|
+
await conn.query(statement);
|
|
132
|
+
}
|
|
133
|
+
await streamMysql(conn, sql, limiter);
|
|
134
|
+
} finally {
|
|
135
|
+
try {
|
|
136
|
+
await conn.end();
|
|
137
|
+
} catch {
|
|
138
|
+
conn.destroy();
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function streamMysql(conn, sql, limiter) {
|
|
144
|
+
return new Promise((resolve, reject) => {
|
|
145
|
+
const connection = conn.connection;
|
|
146
|
+
const query = connection.query({ sql, rowsAsArray: true });
|
|
147
|
+
let settled = false;
|
|
148
|
+
|
|
149
|
+
const finish = () => {
|
|
150
|
+
if (settled) {
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
settled = true;
|
|
154
|
+
resolve();
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
query.on('fields', (fields) => {
|
|
158
|
+
const columns = (fields ?? []).map((field) => field.name);
|
|
159
|
+
writeEvent({ type: 'meta', columns });
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
query.on('result', (row) => {
|
|
163
|
+
if (settled) {
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
const values = Array.isArray(row) ? row : Object.values(row);
|
|
167
|
+
const decision = limiter.push(values);
|
|
168
|
+
if (!decision.accept) {
|
|
169
|
+
writeEvent({
|
|
170
|
+
type: 'end',
|
|
171
|
+
truncated: true,
|
|
172
|
+
reason: decision.reason,
|
|
173
|
+
});
|
|
174
|
+
connection.destroy();
|
|
175
|
+
finish();
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
writeEvent({ type: 'row', values });
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
query.on('end', () => {
|
|
182
|
+
if (!settled) {
|
|
183
|
+
writeEvent({ type: 'end', truncated: false });
|
|
184
|
+
finish();
|
|
185
|
+
}
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
query.on('error', (err) => {
|
|
189
|
+
if (settled) {
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
settled = true;
|
|
193
|
+
reject(err);
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
async function main() {
|
|
199
|
+
const raw = await readStdin();
|
|
200
|
+
let payload = {};
|
|
201
|
+
if (raw.trim().length > 0) {
|
|
202
|
+
payload = JSON.parse(raw);
|
|
203
|
+
}
|
|
204
|
+
const sql = payload.sql;
|
|
205
|
+
if (typeof sql !== 'string' || sql.trim().length === 0) {
|
|
206
|
+
throw new Error('No SQL provided on stdin');
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const timeoutMs = envInt('QUERY_TIMEOUT', 10_000);
|
|
210
|
+
const maxRows = envInt('MAX_ROWS', 1_000);
|
|
211
|
+
const maxBytes = envInt('MAX_BYTES', 1_000_000);
|
|
212
|
+
const limiter = createStreamLimiter({ maxRows, maxBytes });
|
|
213
|
+
const dialect = dialectFromEnv();
|
|
214
|
+
const connection = {
|
|
215
|
+
host: process.env.DB_HOST,
|
|
216
|
+
port: envInt('DB_PORT', dialect === 'mysql' ? 3306 : 5432),
|
|
217
|
+
user: process.env.DB_USER,
|
|
218
|
+
password: readPassword(),
|
|
219
|
+
database: process.env.DB_NAME,
|
|
220
|
+
sql,
|
|
221
|
+
timeoutMs,
|
|
222
|
+
limiter,
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
if (dialect === 'mysql') {
|
|
226
|
+
await runMysql(connection);
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
await runPostgres(connection);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
main().catch((err) => {
|
|
233
|
+
writeRunnerError(err);
|
|
234
|
+
process.exit(1);
|
|
235
|
+
});
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
|
|
3
|
+
export const DEFAULT_SECRET_PATH = '/run/secrets/db_password';
|
|
4
|
+
|
|
5
|
+
/** Official runner reads the tmpfs secret only. No stdin/payload password fallback. */
|
|
6
|
+
export function readPassword(
|
|
7
|
+
secretPath = DEFAULT_SECRET_PATH,
|
|
8
|
+
payload,
|
|
9
|
+
) {
|
|
10
|
+
void payload;
|
|
11
|
+
try {
|
|
12
|
+
return readFileSync(secretPath, 'utf8');
|
|
13
|
+
} catch {
|
|
14
|
+
throw new Error('Database password missing (expected tmpfs secret file)');
|
|
15
|
+
}
|
|
16
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export function sessionSetupStatements(type, timeoutMs) {
|
|
2
|
+
if (!Number.isInteger(timeoutMs) || timeoutMs <= 0 || timeoutMs > 1_000_000_000) {
|
|
3
|
+
throw new Error('timeoutMs must be a positive integer');
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
switch (type) {
|
|
7
|
+
case 'postgres':
|
|
8
|
+
return [
|
|
9
|
+
'SET SESSION CHARACTERISTICS AS TRANSACTION READ ONLY',
|
|
10
|
+
`SET statement_timeout = ${timeoutMs}`,
|
|
11
|
+
];
|
|
12
|
+
case 'mysql':
|
|
13
|
+
return [
|
|
14
|
+
'SET SESSION TRANSACTION READ ONLY',
|
|
15
|
+
`SET SESSION MAX_EXECUTION_TIME = ${timeoutMs}`,
|
|
16
|
+
];
|
|
17
|
+
default:
|
|
18
|
+
throw new Error(`unsupported dialect: ${type}`);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export function createStreamLimiter(opts: {
|
|
2
|
+
maxRows: number;
|
|
3
|
+
maxBytes: number;
|
|
4
|
+
}): {
|
|
5
|
+
push(values: unknown): {
|
|
6
|
+
accept: boolean;
|
|
7
|
+
truncated: boolean;
|
|
8
|
+
reason?: string;
|
|
9
|
+
};
|
|
10
|
+
readonly state: {
|
|
11
|
+
rows: number;
|
|
12
|
+
bytes: number;
|
|
13
|
+
truncated: boolean;
|
|
14
|
+
reason?: string;
|
|
15
|
+
};
|
|
16
|
+
};
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Apply maxRows / maxBytes as rows arrive. Callers must stop producing
|
|
3
|
+
* remaining rows when accept is false — do not buffer then truncate.
|
|
4
|
+
*/
|
|
5
|
+
export function createStreamLimiter({ maxRows, maxBytes }) {
|
|
6
|
+
let rows = 0;
|
|
7
|
+
let bytes = 0;
|
|
8
|
+
let truncated = false;
|
|
9
|
+
let reason;
|
|
10
|
+
|
|
11
|
+
return {
|
|
12
|
+
push(values) {
|
|
13
|
+
if (truncated) {
|
|
14
|
+
return { accept: false, truncated: true, reason };
|
|
15
|
+
}
|
|
16
|
+
const size = Buffer.byteLength(JSON.stringify(values), 'utf8');
|
|
17
|
+
if (rows + 1 > maxRows) {
|
|
18
|
+
truncated = true;
|
|
19
|
+
reason = 'maxRows';
|
|
20
|
+
return { accept: false, truncated: true, reason };
|
|
21
|
+
}
|
|
22
|
+
if (bytes + size > maxBytes) {
|
|
23
|
+
truncated = true;
|
|
24
|
+
reason = 'maxBytes';
|
|
25
|
+
return { accept: false, truncated: true, reason };
|
|
26
|
+
}
|
|
27
|
+
rows += 1;
|
|
28
|
+
bytes += size;
|
|
29
|
+
return { accept: true, truncated: false };
|
|
30
|
+
},
|
|
31
|
+
get state() {
|
|
32
|
+
return { rows, bytes, truncated, reason };
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
}
|