diff --git a/.env.example b/.env.example index eeec6373..7d4bc115 100644 --- a/.env.example +++ b/.env.example @@ -166,6 +166,10 @@ WEBHOOK_SSRF_PROTECT=true # Server-side media size/time limits: # MEDIA_DOWNLOAD_MAX_BYTES=52428800 # cap remote-URL sends AND inbound media (default 50 MiB; oversized inbound media is dropped, message kept) # MEDIA_DOWNLOAD_TIMEOUT_MS=30000 # abort a slow media download (default 30s) +# Storage import/export limits (ADMIN /infra/storage/* endpoints): +# STORAGE_IMPORT_MAX_BYTES=209715200 # per-entry cap for a tar.gz import; aborts on overflow (default 200 MiB) +# STORAGE_IMPORT_MAX_ENTRIES=100000 # max entries in an import archive; aborts beyond this (default 100000) +# STORAGE_EXPORT_TTL_MS=3600000 # auto-delete an export archive after this long (default 1h) # ============================================================================= # RATE LIMITING diff --git a/CHANGELOG.md b/CHANGELOG.md index 66bcc8f6..58500781 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Security + +- **Storage import is bounded against decompression bombs.** A `tar.gz` import buffered each entry in + memory with no size or entry-count limit, so a crafted archive could exhaust process memory. Imports + now enforce a per-entry byte cap (`STORAGE_IMPORT_MAX_BYTES`, default 200 MiB) and a maximum entry + count (`STORAGE_IMPORT_MAX_ENTRIES`, default 100000), aborting the whole import on breach. +- **Storage-key containment is enforced for the S3 backend too.** The local backend already rejected + tar entry names that traversed the storage root; the S3 path did not. Containment is now checked at the + backend-agnostic `putFile` boundary, so an object key can't escape the intended `media/` prefix. +- **Plugin storage keys are sandbox-contained.** A plugin's `ctx.storage` get/set/delete built a file + path from the raw key, so a key containing `..` could read/write/delete outside the plugin's own data + directory. Keys that escape the sandbox are now rejected (ordinary JID-style keys are preserved). + +### Fixed + +- **Storage export no longer accumulates copies on the data volume.** `GET /infra/storage/export` wrote + a timestamped `tar.gz` of all media into `data/` (alongside the live databases and session state) and + never deleted it, so repeated exports could fill the disk and destabilize the gateway. The export now + writes to the OS temp directory and is removed after a TTL (`STORAGE_EXPORT_TTL_MS`, default 1h), and the + export read yields the event loop per file instead of blocking it with a synchronous read. + ## [0.4.2] - 2026-06-19 Bug-fix and hardening release: access-control tightening, session-lifecycle resilience, data-migration diff --git a/docs/14-migration-guide.md b/docs/14-migration-guide.md index 96c08bfe..902f8d84 100644 --- a/docs/14-migration-guide.md +++ b/docs/14-migration-guide.md @@ -156,7 +156,9 @@ curl -s 'http://localhost:2785/api/infra/storage/files/count' \ # Step 2: Export all files as tar.gz curl -s 'http://localhost:2785/api/infra/storage/export' \ -H 'X-API-Key: YOUR_KEY' -# Response: { "message": "Storage export completed", "download": "/app/data/storage-export-xxx.tar.gz" } +# Response: { "message": "Storage export completed", "download": "/app/data/exports/storage-export-xxx.tar.gz" } +# The archive is auto-removed after STORAGE_EXPORT_TTL_MS (default 1h), so re-import it before then. +# It is written under data/ so it survives the restart in Step 4 and stays import-able. # Step 3: Change storage configuration # From: STORAGE_TYPE=local @@ -170,7 +172,7 @@ docker compose up -d curl -X POST 'http://localhost:2785/api/infra/storage/import' \ -H 'X-API-Key: YOUR_KEY' \ -H 'Content-Type: application/json' \ - -d '{"filePath": "/app/data/storage-export-xxx.tar.gz"}' + -d '{"filePath": "/app/data/exports/storage-export-xxx.tar.gz"}' ``` | Scenario | Support | Method | diff --git a/src/common/storage/storage.service.spec.ts b/src/common/storage/storage.service.spec.ts index cce9ef57..89349b78 100644 --- a/src/common/storage/storage.service.spec.ts +++ b/src/common/storage/storage.service.spec.ts @@ -88,3 +88,95 @@ describe('StorageService (local) path traversal protection', () => { expect(count).toBe(1); }); }); + +function makeLocalService(): { service: StorageService; baseDir: string; localPath: string } { + const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'owa-storage-')); + const localPath = path.join(baseDir, 'media'); + const configService = { + get: (key: string) => (key === 'storage.type' ? 'local' : key === 'storage.localPath' ? localPath : undefined), + } as unknown as ConfigService; + return { service: new StorageService(configService), baseDir, localPath }; +} + +describe('StorageService put/getFile containment is backend-agnostic', () => { + // Force S3 routing with a stub client so the assertion proves the guard runs BEFORE any S3 call + // (i.e. it lives in put/getFile, so the otherwise-unguarded S3 backend is contained too). + function s3Stub(service: StorageService): jest.Mock { + const sendMock = jest.fn(); + const internal = service as unknown as { storageType: string; s3Client: unknown; s3Available: boolean }; + internal.storageType = 's3'; + internal.s3Client = { send: sendMock }; + internal.s3Available = true; + return sendMock; + } + + it('putFile rejects an unsafe key before reaching the S3 backend', async () => { + const { service, baseDir } = makeLocalService(); + const sendMock = s3Stub(service); + + await expect(service.putFile('../evil', Buffer.from('x'))).rejects.toThrow(); + expect(sendMock).not.toHaveBeenCalled(); + + fs.rmSync(baseDir, { recursive: true, force: true }); + }); + + it('getFile rejects an unsafe key before reaching the S3 backend', async () => { + const { service, baseDir } = makeLocalService(); + const sendMock = s3Stub(service); + + await expect(service.getFile('../../etc/passwd')).rejects.toThrow(); + expect(sendMock).not.toHaveBeenCalled(); + + fs.rmSync(baseDir, { recursive: true, force: true }); + }); +}); + +describe('StorageService import resource caps (decompression-bomb defense)', () => { + let baseDir: string; + let localPath: string; + let service: StorageService; + + beforeEach(() => { + ({ service, baseDir, localPath } = makeLocalService()); + }); + + afterEach(() => { + fs.rmSync(baseDir, { recursive: true, force: true }); + delete process.env.STORAGE_IMPORT_MAX_BYTES; + delete process.env.STORAGE_IMPORT_MAX_ENTRIES; + }); + + it('aborts an entry that exceeds the per-entry byte cap, writing nothing', async () => { + process.env.STORAGE_IMPORT_MAX_BYTES = '8'; + const gz = await makeTarGz([{ name: 'bomb.bin', data: 'far-more-than-eight-bytes' }]); + + await expect(service.importFromStream(Readable.from(gz))).rejects.toThrow(/byte|cap|exceed|large/i); + expect(fs.existsSync(path.join(localPath, 'bomb.bin'))).toBe(false); + }); + + it('aborts when the archive exceeds the max entry count', async () => { + process.env.STORAGE_IMPORT_MAX_ENTRIES = '1'; + const gz = await makeTarGz([ + { name: 'a.txt', data: 'a' }, + { name: 'b.txt', data: 'b' }, + ]); + + await expect(service.importFromStream(Readable.from(gz))).rejects.toThrow(/entr/i); + }); + + it('aborts a large multi-chunk entry mid-stream (the payload spans several stream chunks)', async () => { + process.env.STORAGE_IMPORT_MAX_BYTES = '1024'; + // 256 KiB easily spans multiple 64 KiB stream chunks, so this proves the running accumulator + // aborts mid-stream rather than only after the whole entry is buffered. + const gz = await makeTarGz([{ name: 'big.bin', data: 'x'.repeat(256 * 1024) }]); + + await expect(service.importFromStream(Readable.from(gz))).rejects.toThrow(/byte|cap|exceed|large/i); + expect(fs.existsSync(path.join(localPath, 'big.bin'))).toBe(false); + }); + + it('imports normally within the (generous default) caps', async () => { + const gz = await makeTarGz([{ name: 'ok.txt', data: 'fine' }]); + const count = await service.importFromStream(Readable.from(gz)); + expect(count).toBe(1); + }); +}); diff --git a/src/common/storage/storage.service.ts b/src/common/storage/storage.service.ts index f0b6622b..fda78d59 100644 --- a/src/common/storage/storage.service.ts +++ b/src/common/storage/storage.service.ts @@ -15,7 +15,7 @@ import { CreateBucketCommand, } from '@aws-sdk/client-s3'; import { createLogger } from '../services/logger.service'; -import { isPathWithin } from '../utils/path-safety'; +import { isPathWithin, isSafeStorageKey } from '../utils/path-safety'; interface S3Config { endpoint?: string; @@ -25,6 +25,16 @@ interface S3Config { bucket?: string; } +/** Per-entry buffer cap for an import (200 MiB — 4× the inbound media cap). Bounds a decompression bomb. */ +const DEFAULT_IMPORT_MAX_BYTES = 200 * 1024 * 1024; +/** Max number of entries an import archive may contain. Bounds an entry-count DoS. */ +const DEFAULT_IMPORT_MAX_ENTRIES = 100_000; + +function positiveIntFromEnv(name: string, fallback: number): number { + const parsed = Number.parseInt(process.env[name] ?? '', 10); + return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback; +} + @Injectable() export class StorageService { private readonly logger = createLogger('StorageService'); @@ -114,6 +124,11 @@ export class StorageService { } async getFile(filePath: string): Promise { + // Mirror putFile: getLocalFile has its own isPathWithin guard, but getS3File builds + // `media/${filePath}` with none — contain both read backends at this boundary. + if (!isSafeStorageKey(filePath)) { + throw new Error(`Refusing to read an unsafe storage key: ${filePath}`); + } if (this.storageType === 's3' && this.s3Client && this.s3Available) { return this.getS3File(filePath); } @@ -121,6 +136,11 @@ export class StorageService { } async putFile(filePath: string, data: Buffer): Promise { + // Centralized containment so BOTH backends inherit it: putLocalFile has its own isPathWithin + // guard, but putS3File builds `media/${filePath}` with none — reject a traversing key here. + if (!isSafeStorageKey(filePath)) { + throw new Error(`Refusing to store an unsafe storage key: ${filePath}`); + } if (this.storageType === 's3' && this.s3Client && this.s3Available) { return this.putS3File(filePath, data); } @@ -191,18 +211,59 @@ export class StorageService { // Import - Extract tar.gz stream to current storage // ============================================================================ + // Best-effort, NOT atomic: a single bad/traversing entry is skipped and the rest still import, and a + // resource-cap breach aborts the rest but KEEPS the entries already written (no rollback). Callers + // re-running an import is safe (putFile overwrites). A staging-dir + atomic promote would make it + // transactional, but is out of scope here. async importFromStream(inputStream: Readable): Promise { let importedCount = 0; + let entryCount = 0; + const maxEntryBytes = positiveIntFromEnv('STORAGE_IMPORT_MAX_BYTES', DEFAULT_IMPORT_MAX_BYTES); + const maxEntries = positiveIntFromEnv('STORAGE_IMPORT_MAX_ENTRIES', DEFAULT_IMPORT_MAX_ENTRIES); const extract = tar.extract(); const gunzip = createGunzip(); return new Promise((resolve, reject) => { + let settled = false; + // Abort the whole import: a per-entry overflow or too many entries is a (zip-bomb) attack, not + // a per-file skip — tear down the pipeline and reject so nothing further is buffered or written. + const fail = (err: Error): void => { + if (settled) return; + settled = true; + extract.destroy(); + reject(err); + }; + extract.on('entry', (header, stream, next) => { + if (settled) { + stream.resume(); + return; + } + if (++entryCount > maxEntries) { + stream.resume(); + fail(new Error(`Import aborted: archive exceeds the ${maxEntries}-entry limit`)); + return; + } + const chunks: Buffer[] = []; + let entryBytes = 0; + let entryAborted = false; + + stream.on('data', (chunk: Buffer) => { + if (entryAborted || settled) return; + entryBytes += chunk.length; + if (entryBytes > maxEntryBytes) { + entryAborted = true; + stream.resume(); // drain the remainder so the source can end + fail(new Error(`Import aborted: entry "${header.name}" exceeds the ${maxEntryBytes}-byte per-entry cap`)); + } else { + chunks.push(chunk); + } + }); - stream.on('data', (chunk: Buffer) => chunks.push(chunk)); stream.on('end', () => { + if (entryAborted || settled) return; const data = Buffer.concat(chunks); this.putFile(header.name, data) .then(() => { @@ -219,13 +280,15 @@ export class StorageService { }); extract.on('finish', () => { + if (settled) return; + settled = true; this.logger.log(`Import completed: ${importedCount} files`); resolve(importedCount); }); extract.on('error', (err: Error) => { this.logger.error('Import failed', String(err)); - reject(err); + fail(err); }); inputStream.pipe(gunzip).pipe(extract); @@ -264,7 +327,9 @@ export class StorageService { throw new Error(`Refusing to read outside storage root: ${filePath}`); } const fullPath = path.join(this.localPath, filePath); - return Promise.resolve(fs.readFileSync(fullPath)); + // Async read so the export loop (the only caller) yields the event loop per file instead of + // blocking it with a synchronous read for every media file. + return fs.promises.readFile(fullPath); } private putLocalFile(filePath: string, data: Buffer): Promise { diff --git a/src/common/utils/path-safety.spec.ts b/src/common/utils/path-safety.spec.ts index de83e937..4d6049af 100644 --- a/src/common/utils/path-safety.spec.ts +++ b/src/common/utils/path-safety.spec.ts @@ -1,5 +1,5 @@ import * as path from 'path'; -import { isPathWithin } from './path-safety'; +import { isPathWithin, isSafeStorageKey } from './path-safety'; describe('isPathWithin', () => { const root = path.resolve('/srv/app/data'); @@ -25,3 +25,32 @@ describe('isPathWithin', () => { expect(isPathWithin('/srv/app/data', '/srv/app/data-evil/x')).toBe(false); }); }); + +describe('isSafeStorageKey', () => { + it.each([ + 'file.jpg', + 'sessionId/messageId.jpg', + 'a/b/c.txt', + 'group:sid:123@g.us.json', // plugin/JID-style keys must survive (':' '@' '.' '-') + ])('accepts the safe relative key %s', k => { + expect(isSafeStorageKey(k)).toBe(true); + }); + + it.each([ + '../evil.txt', + 'a/../../etc/passwd', + 'media/../../../secret', + '/etc/passwd', // absolute + '..', + 'a\\..\\b', // backslash traversal (pins the split on '\\' too) + '', // empty + ])('rejects the traversing/absolute key %j', k => { + expect(isSafeStorageKey(k)).toBe(false); + }); + + it('rejects a key containing a NUL or other control character (it reaches the raw S3 object key)', () => { + expect(isSafeStorageKey(`foo${String.fromCharCode(0)}.txt`)).toBe(false); // NUL + expect(isSafeStorageKey(`bar${String.fromCharCode(9)}.txt`)).toBe(false); // tab + expect(isSafeStorageKey(`baz${String.fromCharCode(31)}.txt`)).toBe(false); // unit separator + }); +}); diff --git a/src/common/utils/path-safety.ts b/src/common/utils/path-safety.ts index ca816a95..a8461198 100644 --- a/src/common/utils/path-safety.ts +++ b/src/common/utils/path-safety.ts @@ -15,3 +15,19 @@ export function isPathWithin(root: string, target: string): boolean { const resolvedTarget = path.resolve(resolvedRoot, target); return resolvedTarget === resolvedRoot || resolvedTarget.startsWith(resolvedRoot + path.sep); } + +/** + * Returns true if `key` is a safe, contained relative storage key: a non-empty relative path with no + * `..` traversal segment. Used to validate untrusted archive entry names / object keys at the + * backend-agnostic `putFile`/`getFile` boundary so an S3 key (which has no host filesystem root to + * check against `isPathWithin`) still can't escape the intended `media/` prefix. Ordinary keys — + * including plugin/JID-style ones with `:`, `@`, `.`, `-` — are preserved. + */ +export function isSafeStorageKey(key: string): boolean { + if (typeof key !== 'string' || key.length === 0) return false; + // Reject NUL / control chars: harmless on the local FS but a NUL would reach the raw S3 object Key. + // eslint-disable-next-line no-control-regex + if (/[\u0000-\u001f]/.test(key)) return false; + if (path.isAbsolute(key)) return false; + return !key.split(/[/\\]/).includes('..'); +} diff --git a/src/core/plugins/plugin-storage.service.spec.ts b/src/core/plugins/plugin-storage.service.spec.ts new file mode 100644 index 00000000..0d0e24d0 --- /dev/null +++ b/src/core/plugins/plugin-storage.service.spec.ts @@ -0,0 +1,54 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { ConfigService } from '@nestjs/config'; +import { PluginStorageService } from './plugin-storage.service'; + +describe('PluginStorageService sandboxed per-plugin storage containment', () => { + let dataDir: string; + let service: PluginStorageService; + let storage: ReturnType; + const pluginId = 'demo-plugin'; + + beforeEach(() => { + dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'owa-plugindata-')); + const configService = { + get: (k: string) => (k === 'dataDir' ? dataDir : undefined), + } as unknown as ConfigService; + service = new PluginStorageService(configService); + storage = service.createPluginStorage(pluginId); + }); + + afterEach(() => { + fs.rmSync(dataDir, { recursive: true, force: true }); + }); + + it('round-trips a normal key', async () => { + await storage.set('state', { a: 1 }); + expect(await storage.get('state')).toEqual({ a: 1 }); + await storage.delete('state'); + expect(await storage.get('state')).toBeNull(); + }); + + it('preserves JID-style keys containing : @ . -', async () => { + await storage.set('group:sess-1:12345@g.us', { announced: true }); + expect(await storage.get('group:sess-1:12345@g.us')).toEqual({ announced: true }); + }); + + it('rejects a traversing set and writes nothing outside the plugin dir', async () => { + await expect(storage.set('../../escape', { x: 1 })).rejects.toThrow(); + expect(fs.existsSync(path.join(dataDir, 'escape.json'))).toBe(false); + expect(fs.existsSync(path.join(dataDir, 'plugins', 'escape.json'))).toBe(false); + }); + + it('refuses a traversing get WITHOUT reading the real outside file it targets', async () => { + // Place a real JSON file at the location the malicious key would resolve to + // (pluginDir/../../secret.json -> dataDir/secret.json). Containment must return null, not its content. + fs.writeFileSync(path.join(dataDir, 'secret.json'), JSON.stringify({ topsecret: true })); + expect(await storage.get('../../secret')).toBeNull(); + }); + + it('rejects a traversing delete', async () => { + await expect(storage.delete('../../escape')).rejects.toThrow(); + }); +}); diff --git a/src/core/plugins/plugin-storage.service.ts b/src/core/plugins/plugin-storage.service.ts index 1e9b5f30..fcc13dad 100644 --- a/src/core/plugins/plugin-storage.service.ts +++ b/src/core/plugins/plugin-storage.service.ts @@ -3,6 +3,7 @@ import { ConfigService } from '@nestjs/config'; import * as fs from 'fs'; import * as path from 'path'; import { createLogger } from '../../common/services/logger.service'; +import { isPathWithin } from '../../common/utils/path-safety'; import { PluginStatus, PluginStorage, PluginRegistryEntry } from './plugin.interfaces'; @Injectable() @@ -124,9 +125,19 @@ export class PluginStorageService { const logger = this.logger; + // Containment: a plugin storage key must resolve INSIDE its own sandbox dir. path.join normalizes + // `..`, so a key like `../../x` would otherwise escape and clobber another plugin's data, the + // registry, or .env.generated. Reject anything that escapes; JID chars (`:`,`@`,`.`,`-`) are fine. + const resolveKeyPath = (key: string): string | null => + isPathWithin(pluginDataDir, `${key}.json`) ? path.join(pluginDataDir, `${key}.json`) : null; + return { get: (key: string): Promise => { - const filePath = path.join(pluginDataDir, `${key}.json`); + const filePath = resolveKeyPath(key); + if (!filePath) { + logger.warn(`Refusing to read plugin data with an unsafe key: ${pluginId}/${key}`); + return Promise.resolve(null); + } try { if (fs.existsSync(filePath)) { const content = fs.readFileSync(filePath, 'utf-8'); @@ -139,7 +150,10 @@ export class PluginStorageService { }, set: (key: string, value: T): Promise => { - const filePath = path.join(pluginDataDir, `${key}.json`); + const filePath = resolveKeyPath(key); + if (!filePath) { + return Promise.reject(new Error(`Unsafe plugin storage key (escapes sandbox): ${key}`)); + } try { fs.writeFileSync(filePath, JSON.stringify(value, null, 2)); return Promise.resolve(); @@ -150,7 +164,10 @@ export class PluginStorageService { }, delete: (key: string): Promise => { - const filePath = path.join(pluginDataDir, `${key}.json`); + const filePath = resolveKeyPath(key); + if (!filePath) { + return Promise.reject(new Error(`Unsafe plugin storage key (escapes sandbox): ${key}`)); + } try { if (fs.existsSync(filePath)) { fs.unlinkSync(filePath); diff --git a/src/modules/infra/infra.controller.spec.ts b/src/modules/infra/infra.controller.spec.ts index d27ecdac..079acde3 100644 --- a/src/modules/infra/infra.controller.spec.ts +++ b/src/modules/infra/infra.controller.spec.ts @@ -1,4 +1,7 @@ import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { Readable } from 'stream'; import { Reflector } from '@nestjs/core'; import { BadRequestException } from '@nestjs/common'; @@ -372,3 +375,62 @@ describe('InfraController.getConfig (#226)', () => { expect(JSON.stringify(cfg)).not.toContain('"ak"'); }); }); + +describe('InfraController.exportStorage keeps the export import-able and sweeps it', () => { + function buildController(storage: Partial<{ createExportStream: jest.Mock }>) { + return new InfraController( + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + storage as never, + {} as never, + ); + } + + // fs.existsSync is globally mocked in this file, so probe the real filesystem via fs.promises.access. + const exists = (p: string): Promise => + fs.promises + .access(p) + .then(() => true) + .catch(() => false); + + // Poll (don't sleep a fixed time) so the sweep assertion isn't flaky under CI load. + const waitForGone = async (p: string, timeoutMs = 3000): Promise => { + const start = Date.now(); + while (await exists(p)) { + if (Date.now() - start > timeoutMs) throw new Error(`file was not swept in time: ${p}`); + await new Promise(resolve => setTimeout(resolve, 10)); + } + }; + + let cwdSpy: jest.SpyInstance | undefined; + let cwd: string | undefined; + + afterEach(() => { + cwdSpy?.mockRestore(); + if (cwd) fs.rmSync(cwd, { recursive: true, force: true }); + cwdSpy = undefined; + cwd = undefined; + delete process.env.STORAGE_EXPORT_TTL_MS; + }); + + it('writes under data/exports (so it stays import-able + survives restart) and TTL-sweeps it', async () => { + cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'owa-cwd-')); + cwdSpy = jest.spyOn(process, 'cwd').mockReturnValue(cwd); + process.env.STORAGE_EXPORT_TTL_MS = '30'; + const createExportStream = jest.fn().mockResolvedValue(Readable.from([Buffer.from('archive-bytes')])); + const controller = buildController({ createExportStream }); + + const result = await controller.exportStorage(); + + // Import-able: under /exports — the import handler only accepts paths inside data/. + expect(result.download.startsWith(path.join(cwd, 'data', 'exports'))).toBe(true); + expect(await exists(result.download)).toBe(true); + + await waitForGone(result.download); + expect(await exists(result.download)).toBe(false); + }); +}); diff --git a/src/modules/infra/infra.controller.ts b/src/modules/infra/infra.controller.ts index 24ab0786..d42a65fb 100644 --- a/src/modules/infra/infra.controller.ts +++ b/src/modules/infra/infra.controller.ts @@ -14,6 +14,7 @@ import { ShutdownService } from '../../common/services/shutdown.service'; import { createLogger } from '../../common/services/logger.service'; import * as fs from 'fs'; import * as path from 'path'; +import { randomUUID } from 'crypto'; import * as dotenv from 'dotenv'; interface InfraStatus { @@ -858,7 +859,16 @@ export class InfraController { // Note: In production, this would return a StreamableFile // For simplicity, we'll save to a temp file and return the path const stream = await this.storageService.createExportStream(); - const exportPath = path.join(process.cwd(), 'data', `storage-export-${Date.now()}.tar.gz`); + // Keep the export INSIDE data/ (under data/exports/): the import handler only accepts paths under + // data/, and the documented backend-migration flow re-imports this file AFTER a container restart, + // so it must live on the persistent volume — the OS temp dir is wiped on restart. The original + // unbounded-accumulation leak is addressed by the TTL sweep below + a collision-proof filename + // (a per-call UUID), not by relocating off the volume. + const exportDir = path.join(process.cwd(), 'data', 'exports'); + if (!fs.existsSync(exportDir)) { + fs.mkdirSync(exportDir, { recursive: true }); + } + const exportPath = path.join(exportDir, `storage-export-${Date.now()}-${randomUUID()}.tar.gz`); const writeStream = fs.createWriteStream(exportPath); stream.pipe(writeStream); @@ -868,6 +878,13 @@ export class InfraController { writeStream.on('error', reject); }); + // Sweep the throwaway archive so repeated exports don't accumulate on the data volume. + const ttlRaw = Number.parseInt(process.env.STORAGE_EXPORT_TTL_MS ?? '', 10); + const ttlMs = Number.isInteger(ttlRaw) && ttlRaw > 0 ? ttlRaw : 60 * 60 * 1000; // default 1h + setTimeout(() => { + fs.promises.unlink(exportPath).catch(() => undefined); + }, ttlMs).unref(); + return { message: 'Storage export completed', download: exportPath,