Compare commits

...
Author SHA1 Message Date
whyour 301efa9e72 fix(scripts): validate destinations immediately before file writes 2026-10-12 01:13:53 +08:00
whyour 5eaff6ba90 fix(storage): release file locks on failed writes 2026-10-12 00:23:20 +08:00
copilot-swe-agent[bot]andwhyour 855f591992 Improve type safety for fileHandle and release variables
- Add explicit FileHandle type annotation for fileHandle variable
- Change fileHandle check from truthy to explicit undefined check
- Change release type from null to undefined for better type safety

Co-authored-by: whyour <22700758+whyour@users.noreply.github.com>
2025-12-27 08:49:27 +00:00
copilot-swe-agent[bot]andwhyour d30eb2008c Add error handling for file handle close operation
- Wrap fileHandle.close() in try-catch to prevent masking original errors
- Log close errors without throwing to preserve error context

Co-authored-by: whyour <22700758+whyour@users.noreply.github.com>
2025-12-27 08:46:51 +00:00
copilot-swe-agent[bot]andwhyour e1ce0f3fa9 Improve error handling based on code review feedback
- Use finally block to ensure file handle is closed even on error
- Extract error messages properly using instanceof Error check
- Improve error message formatting for better debugging

Co-authored-by: whyour <22700758+whyour@users.noreply.github.com>
2025-12-27 08:45:34 +00:00
copilot-swe-agent[bot]andwhyour 82514e65e1 Add server timeout configuration and improve error handling for script save operations
- Configure HTTP server timeouts (requestTimeout: 5min, headersTimeout: 2min, keepAliveTimeout: 65s)
- Add better error logging in PUT /scripts endpoint
- Improve writeFileWithLock error handling with descriptive messages and proper cleanup
- Ensure lock is always released even on error

Co-authored-by: whyour <22700758+whyour@users.noreply.github.com>
2025-12-27 08:43:11 +00:00
copilot-swe-agent[bot] 31261190f0 Initial plan 2025-12-27 08:35:20 +00:00
4 changed files with 89 additions and 19 deletions
+23 -2
View File
@@ -35,6 +35,23 @@ function isPathAllowed(targetPath: string): boolean {
); );
} }
async function writeScriptFile(
targetPath: string,
content: string,
): Promise<boolean> {
const target = resolve(targetPath);
for (const writableRoot of config.writePathList) {
const root = resolve(writableRoot);
const prefix = root.endsWith(sep) ? root : root + sep;
// Check the actual write target, including filename, at the write boundary.
if (!target.startsWith(prefix)) continue;
if (!isPathAllowed(target)) return false;
await writeFileWithLock(target, content);
return true;
}
return false;
}
async function existingWritableRealPath(targetPath: string): Promise<string> { async function existingWritableRealPath(targetPath: string): Promise<string> {
if (!isPathAllowed(targetPath)) { if (!isPathAllowed(targetPath)) {
throw new ScriptHistoryError(403, '暂无权限'); throw new ScriptHistoryError(403, '暂无权限');
@@ -373,7 +390,9 @@ export default (app: Router) => {
await rmPath(originFilePath); await rmPath(originFilePath);
} }
} }
await writeFileWithLock(filePath, content); if (!(await writeScriptFile(filePath, content))) {
return res.send({ code: 403, message: t('暂无权限') });
}
return res.send({ code: 200 }); return res.send({ code: 200 });
} catch (e) { } catch (e) {
return historyError(e, res, next); return historyError(e, res, next);
@@ -513,7 +532,9 @@ export default (app: Router) => {
if (!isPathAllowed(filePath)) { if (!isPathAllowed(filePath)) {
return res.send({ code: 403, message: t('暂无权限') }); return res.send({ code: 403, message: t('暂无权限') });
} }
await writeFileWithLock(filePath, content || ''); if (!(await writeScriptFile(filePath, content || ''))) {
return res.send({ code: 403, message: t('暂无权限') });
}
const scriptService = Container.get(ScriptService); const scriptService = Container.get(ScriptService);
const result = await scriptService.runScript(filePath); const result = await scriptService.runScript(filePath);
+28 -12
View File
@@ -12,7 +12,7 @@ export class HttpServerService {
async initialize(expressApp: express.Application, port: number) { async initialize(expressApp: express.Application, port: number) {
const hostsToTry = [ const hostsToTry = [
config.bindHost, config.bindHost,
...(config.bindHost === '::' ? ['0.0.0.0'] : []) ...(config.bindHost === '::' ? ['0.0.0.0'] : []),
]; ];
let lastError: Error | null = null; let lastError: Error | null = null;
@@ -20,16 +20,21 @@ export class HttpServerService {
for (const host of hostsToTry) { for (const host of hostsToTry) {
try { try {
const server = await this.tryListen(expressApp, port, host); const server = await this.tryListen(expressApp, port, host);
Logger.debug(`[boot] HTTP service started successfully on ${host}:${port}`); Logger.debug(
`[boot] HTTP service started successfully on ${host}:${port}`,
);
metricsService.record('http_service_start', 1, { metricsService.record('http_service_start', 1, {
port: port.toString(), port: port.toString(),
host host,
}); });
this.server = server; this.server = server;
return server; return server;
} catch (err) { } catch (err) {
lastError = err as Error; lastError = err as Error;
Logger.warn(`Failed to bind HTTP on ${host}:${port}, trying next...`, err); Logger.warn(
`Failed to bind HTTP on ${host}:${port}, trying next...`,
err,
);
} }
} }
@@ -37,17 +42,28 @@ export class HttpServerService {
throw lastError || new Error('Failed to start HTTP service'); throw lastError || new Error('Failed to start HTTP service');
} }
private async tryListen(expressApp: express.Application, port: number, host: string): Promise<Server> { private async tryListen(
expressApp: express.Application,
port: number,
host: string,
): Promise<Server> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
// There is one HTTP worker; accepting here avoids primary IPC handoff // There is one HTTP worker; accepting here avoids primary IPC handoff
// for every connection. Restore shared listening for custom clusters. // for every connection. Restore shared listening for custom clusters.
const server = expressApp.listen({ const server = expressApp.listen(
port, {
host, port,
exclusive: process.env.QL_HTTP_SHARED_LISTEN !== 'true', host,
}, () => { exclusive: process.env.QL_HTTP_SHARED_LISTEN !== 'true',
resolve(server); },
}); () => {
resolve(server);
},
);
server.requestTimeout = 300000;
server.headersTimeout = 120000;
server.keepAliveTimeout = 65000;
server.on('error', (err: Error) => { server.on('error', (err: Error) => {
server.close(); server.close();
+8 -5
View File
@@ -21,7 +21,7 @@ export async function writeFileWithLock(
} }
if (!(await fileExist(filePath))) { if (!(await fileExist(filePath))) {
const fileHandle = await open(filePath, 'w'); const fileHandle = await open(filePath, 'w');
fileHandle.close(); await fileHandle.close();
} }
const lockfilePath = getUniqueLockPath(filePath); const lockfilePath = getUniqueLockPath(filePath);
@@ -34,9 +34,12 @@ export async function writeFileWithLock(
}, },
lockfilePath, lockfilePath,
}); });
await writeFile(filePath, content, { encoding: 'utf8', ...options }); try {
if (options?.mode) { await writeFile(filePath, content, { encoding: 'utf8', ...options });
await chmod(filePath, options.mode); if (options?.mode) {
await chmod(filePath, options.mode);
}
} finally {
await release();
} }
await release();
} }
@@ -0,0 +1,30 @@
const test = require('node:test'),
assert = require('node:assert/strict'),
fs = require('node:fs'),
os = require('node:os'),
path = require('node:path');
const load = require('../helpers/load-security-module.cjs');
const { writeFileWithLock, getUniqueLockPath } = load('back/shared/utils.ts', {
'../config/util': { fileExist: async (p) => fs.existsSync(p) },
});
test('write failure releases the lock and preserves the original error', async (t) => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-lock-review-'));
t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
const p = path.join(dir, 'script');
fs.writeFileSync(p, 'original');
await assert.rejects(
writeFileWithLock(p, 'bad', { encoding: 'invalid-encoding' }),
(e) => e.code === 'ERR_INVALID_ARG_VALUE',
);
assert.equal(fs.existsSync(getUniqueLockPath(p)), false);
await writeFileWithLock(p, 'retry');
assert.equal(fs.readFileSync(p, 'utf8'), 'retry');
});
test('first write closes its file handle and keeps requested permissions', async (t) => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-lock-review-'));
t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
const p = path.join(dir, 'new');
await writeFileWithLock(p, 'created', { mode: 0o600 });
assert.equal(fs.readFileSync(p, 'utf8'), 'created');
assert.equal(fs.statSync(p).mode & 0o777, 0o600);
});