Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion src/docker-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import execa from 'execa';
import { DockerComposeConfig, WrapperConfig, BlockedTarget, API_PROXY_PORTS, API_PROXY_HEALTH_PORT } from './types';
import { logger } from './logger';
import { generateSquidConfig } from './squid-config';
import { generateSessionCa, initSslDb, CaFiles, parseUrlPatterns } from './ssl-bump';
import { generateSessionCa, initSslDb, CaFiles, parseUrlPatterns, cleanupSslKeyMaterial, unmountSslTmpfs } from './ssl-bump';

const SQUID_PORT = 3128;

Expand Down Expand Up @@ -1618,6 +1618,15 @@ export async function cleanup(workDir: string, keepFiles: boolean, proxyLogsDir?
}
}

// Securely wipe SSL key material before deleting workDir
cleanupSslKeyMaterial(workDir);

// Unmount tmpfs if it was used for SSL keys (data destroyed on unmount)
const sslDir = path.join(workDir, 'ssl');
if (fs.existsSync(sslDir)) {
await unmountSslTmpfs(sslDir);
}

// Clean up workDir
fs.rmSync(workDir, { recursive: true, force: true });

Expand Down
103 changes: 101 additions & 2 deletions src/ssl-bump.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import execa from 'execa';
import { parseUrlPatterns, generateSessionCa, initSslDb, isOpenSslAvailable } from './ssl-bump';
import { parseUrlPatterns, generateSessionCa, initSslDb, isOpenSslAvailable, secureWipeFile, cleanupSslKeyMaterial } from './ssl-bump';

// Pattern constant for the safer URL character class (matches the implementation)
const URL_CHAR_PATTERN = '[^\\s]*';
Expand All @@ -17,6 +17,10 @@ const mockExeca = execa as unknown as jest.Mock;
beforeEach(() => {
mockExeca.mockReset();
mockExeca.mockImplementation((cmd: string, args: string[]) => {
if (cmd === 'mount' || cmd === 'umount') {
// tmpfs mount/unmount - fail gracefully in tests (no root privileges)
return Promise.reject(new Error('mount not available in test'));
}
if (cmd === 'openssl') {
if (args[0] === 'version') {
return Promise.resolve({ stdout: 'OpenSSL 3.0.0 7 Sep 2021' });
Expand Down Expand Up @@ -169,14 +173,27 @@ describe('SSL Bump', () => {
});

it('should throw error when OpenSSL command fails', async () => {
mockExeca.mockImplementationOnce(() => {
mockExeca.mockImplementation((cmd: string) => {
if (cmd === 'mount') {
return Promise.reject(new Error('mount not available'));
}
return Promise.reject(new Error('OpenSSL not found'));
});

await expect(generateSessionCa({ workDir: tempDir })).rejects.toThrow(
'Failed to generate SSL Bump CA: OpenSSL not found'
);
});

it('should attempt tmpfs mount for ssl directory', async () => {
await generateSessionCa({ workDir: tempDir });

// Verify mount was attempted
expect(mockExeca).toHaveBeenCalledWith(
'mount',
expect.arrayContaining(['-t', 'tmpfs', 'tmpfs']),
);
});
});

describe('initSslDb', () => {
Expand Down Expand Up @@ -253,4 +270,86 @@ describe('SSL Bump', () => {
expect(result).toBe(false);
});
});

describe('secureWipeFile', () => {
let tempDir: string;

beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'wipe-test-'));
});

afterEach(() => {
fs.rmSync(tempDir, { recursive: true, force: true });
});

it('should overwrite and delete a file', () => {
const filePath = path.join(tempDir, 'secret.key');
fs.writeFileSync(filePath, 'SECRET KEY DATA');

secureWipeFile(filePath);

expect(fs.existsSync(filePath)).toBe(false);
});

it('should handle non-existent files gracefully', () => {
const filePath = path.join(tempDir, 'nonexistent.key');
expect(() => secureWipeFile(filePath)).not.toThrow();
});

it('should handle empty files', () => {
const filePath = path.join(tempDir, 'empty.key');
fs.writeFileSync(filePath, '');

secureWipeFile(filePath);

expect(fs.existsSync(filePath)).toBe(false);
});
});

describe('cleanupSslKeyMaterial', () => {
let tempDir: string;

beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ssl-cleanup-test-'));
});

afterEach(() => {
fs.rmSync(tempDir, { recursive: true, force: true });
});

it('should wipe all SSL files', () => {
const sslDir = path.join(tempDir, 'ssl');
fs.mkdirSync(sslDir, { mode: 0o700 });
fs.writeFileSync(path.join(sslDir, 'ca-key.pem'), 'PRIVATE KEY');
fs.writeFileSync(path.join(sslDir, 'ca-cert.pem'), 'CERTIFICATE');
fs.writeFileSync(path.join(sslDir, 'ca-cert.der'), 'DER CERT');

cleanupSslKeyMaterial(tempDir);

expect(fs.existsSync(path.join(sslDir, 'ca-key.pem'))).toBe(false);
expect(fs.existsSync(path.join(sslDir, 'ca-cert.pem'))).toBe(false);
expect(fs.existsSync(path.join(sslDir, 'ca-cert.der'))).toBe(false);
});

it('should wipe ssl_db certificate files', () => {
const sslDir = path.join(tempDir, 'ssl');
fs.mkdirSync(sslDir, { mode: 0o700 });
fs.writeFileSync(path.join(sslDir, 'ca-key.pem'), 'KEY');

const sslDbDir = path.join(tempDir, 'ssl_db');
const certsDir = path.join(sslDbDir, 'certs');
fs.mkdirSync(certsDir, { recursive: true });
fs.writeFileSync(path.join(certsDir, 'cert1.pem'), 'CERT1');
fs.writeFileSync(path.join(certsDir, 'cert2.pem'), 'CERT2');

cleanupSslKeyMaterial(tempDir);

expect(fs.existsSync(path.join(certsDir, 'cert1.pem'))).toBe(false);
expect(fs.existsSync(path.join(certsDir, 'cert2.pem'))).toBe(false);
});

it('should handle missing ssl directory gracefully', () => {
expect(() => cleanupSslKeyMaterial(tempDir)).not.toThrow();
});
});
});
125 changes: 123 additions & 2 deletions src/ssl-bump.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,16 @@
* for Squid SSL Bump mode, which enables URL path filtering for HTTPS traffic.
*
* Security considerations:
* - CA key is stored only in workDir (tmpfs-backed in container)
* - CA key is stored in tmpfs (memory-only) when possible, never hitting disk
* - Keys are securely wiped (overwritten with random data) before deletion
* - Certificate is valid for 1 day only
* - Private key is never logged
* - CA is unique per session
*/

import * as fs from 'fs';
import * as path from 'path';
import * as crypto from 'crypto';
import execa from 'execa';
import { logger } from './logger';

Expand Down Expand Up @@ -40,6 +42,117 @@
derPath: string;
}

/**
* Mounts a tmpfs filesystem at the given path so SSL keys are stored in memory only.
* Falls back gracefully if mount fails (e.g., insufficient permissions).
*
* @param sslDir - Directory path to mount tmpfs on
* @returns true if tmpfs was mounted, false if fallback to disk
*/
export async function mountSslTmpfs(sslDir: string): Promise<boolean> {
try {
// Mount tmpfs with restrictive options (4MB is more than enough for SSL keys)
await execa('mount', [
'-t', 'tmpfs',
'-o', 'size=4m,mode=0700,noexec,nosuid,nodev',
'tmpfs',
sslDir,
]);

logger.debug(`tmpfs mounted at ${sslDir} for SSL key storage`);
return true;
} catch (error) {
logger.debug(`Could not mount tmpfs at ${sslDir} (falling back to disk): ${error}`);
return false;
}
}

/**
* Unmounts a tmpfs filesystem. All data is immediately destroyed since tmpfs is memory-only.
*
* @param sslDir - Directory path where tmpfs was mounted
*/
export async function unmountSslTmpfs(sslDir: string): Promise<void> {
try {
await execa('umount', [sslDir]);
logger.debug(`tmpfs unmounted at ${sslDir} - key material destroyed`);
} catch (error) {
logger.debug(`Could not unmount tmpfs at ${sslDir}: ${error}`);
}
}

/**
* Securely wipes a file by overwriting its contents with random data before unlinking.
* This prevents recovery of sensitive key material from disk.
*
* @param filePath - Path to the file to securely wipe
*/
export function secureWipeFile(filePath: string): void {
try {
if (!fs.existsSync(filePath)) {
return;
}

const stat = fs.statSync(filePath);
const size = stat.size;

if (size > 0) {
// Overwrite with random data
const fd = fs.openSync(filePath, 'w');

Check failure

Code scanning / CodeQL

Potential file system race condition High

The file may have changed since it
was checked
.
The file may have changed since it
was checked
.

Check failure

Code scanning / CodeQL

Insecure temporary file High

Insecure creation of file in
the os temp dir
.
const randomData = crypto.randomBytes(size);
fs.writeSync(fd, randomData);
fs.fsyncSync(fd);
fs.closeSync(fd);
}

fs.unlinkSync(filePath);
logger.debug(`Securely wiped: ${filePath}`);
} catch (error) {
// Best-effort: if secure wipe fails, still try to delete
try {
fs.unlinkSync(filePath);
} catch {
// Ignore deletion errors during cleanup
}
logger.debug(`Could not securely wipe ${filePath}: ${error}`);
}
}

/**
* Securely cleans up SSL key material from the workDir.
* Overwrites private keys with random data before deletion to prevent recovery.
*
* @param workDir - Working directory containing ssl/ subdirectory
*/
export function cleanupSslKeyMaterial(workDir: string): void {
const sslDir = path.join(workDir, 'ssl');
if (!fs.existsSync(sslDir)) {
return;
}

logger.debug('Securely wiping SSL key material...');

// Wipe the private key (most sensitive)
secureWipeFile(path.join(sslDir, 'ca-key.pem'));

// Wipe other SSL files
secureWipeFile(path.join(sslDir, 'ca-cert.pem'));
secureWipeFile(path.join(sslDir, 'ca-cert.der'));

// Clean up ssl_db (contains generated per-host certificates)
const sslDbPath = path.join(workDir, 'ssl_db');
if (fs.existsSync(sslDbPath)) {
const certsDir = path.join(sslDbPath, 'certs');
if (fs.existsSync(certsDir)) {
for (const file of fs.readdirSync(certsDir)) {
secureWipeFile(path.join(certsDir, file));
}
}
}

logger.debug('SSL key material securely wiped');
}

/**
* Generates a self-signed CA certificate for SSL Bump
*
Expand All @@ -53,12 +166,20 @@
export async function generateSessionCa(config: SslBumpConfig): Promise<CaFiles> {
const { workDir, commonName = 'AWF Session CA', validityDays = 1 } = config;

// Create ssl directory in workDir
// Create ssl directory in workDir, backed by tmpfs when possible
const sslDir = path.join(workDir, 'ssl');
if (!fs.existsSync(sslDir)) {
fs.mkdirSync(sslDir, { recursive: true, mode: 0o700 });
}

// Attempt to mount tmpfs so keys never touch disk
const usingTmpfs = await mountSslTmpfs(sslDir);
if (usingTmpfs) {
logger.info('SSL keys stored in memory-only filesystem (tmpfs)');
} else {
logger.debug('SSL keys stored on disk (tmpfs mount not available)');
}

const certPath = path.join(sslDir, 'ca-cert.pem');
const keyPath = path.join(sslDir, 'ca-key.pem');
const derPath = path.join(sslDir, 'ca-cert.der');
Expand Down
Loading