-
Notifications
You must be signed in to change notification settings - Fork 21
Expand file tree
/
Copy pathssl-bump.ts
More file actions
351 lines (309 loc) · 11.3 KB
/
ssl-bump.ts
File metadata and controls
351 lines (309 loc) · 11.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
/**
* SSL Bump utilities for HTTPS content inspection
*
* This module provides functionality to generate per-session CA certificates
* for Squid SSL Bump mode, which enables URL path filtering for HTTPS traffic.
*
* Security considerations:
* - 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';
/**
* Recursively chown a directory and its contents
*/
export function chownRecursive(dirPath: string, uid: number, gid: number): void {
fs.chownSync(dirPath, uid, gid);
for (const entry of fs.readdirSync(dirPath, { withFileTypes: true })) {
const fullPath = path.join(dirPath, entry.name);
if (entry.isDirectory()) {
chownRecursive(fullPath, uid, gid);
} else {
fs.chownSync(fullPath, uid, gid);
}
}
}
/**
* Result of CA generation containing paths to certificate files
*/
export interface CaFiles {
/** Path to CA certificate (PEM format) */
certPath: string;
/** Path to CA private key (PEM format) */
keyPath: string;
/** DER format certificate for easy import */
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
*/
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');
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
*
* The CA certificate is used by Squid to generate per-host certificates
* on-the-fly, allowing it to inspect HTTPS traffic for URL filtering.
*
* @param config - SSL Bump configuration
* @returns Paths to generated CA files
* @throws Error if OpenSSL commands fail
*/
export async function generateSessionCa(config: { workDir: string; commonName?: string; validityDays?: number }): Promise<CaFiles> {
const { workDir, commonName = 'AWF Session CA', validityDays = 1 } = config;
// Create ssl directory in workDir, backed by tmpfs when possible
// Use recursive:true which is a no-op if the directory already exists (avoids TOCTOU)
const sslDir = path.join(workDir, 'ssl');
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');
logger.debug(`Generating SSL Bump CA certificate in ${sslDir}`);
try {
// Generate RSA private key and self-signed certificate in one command
// Using -batch to avoid interactive prompts
// Security: commonName defaults to 'AWF Session CA' and is only configurable
// via SslBumpConfig interface (not direct user input). The value is used in
// the certificate subject which is not shell-interpreted by OpenSSL.
await execa('openssl', [
'req',
'-new',
'-newkey', 'rsa:2048',
'-days', validityDays.toString(),
'-nodes', // No password on private key
'-x509',
// eslint-disable-next-line local/no-unsafe-execa
'-subj', `/CN=${commonName}`,
'-keyout', keyPath,
'-out', certPath,
'-batch',
]);
// Set restrictive permissions on private key
fs.chmodSync(keyPath, 0o600);
fs.chmodSync(certPath, 0o644);
logger.debug(`CA certificate generated: ${certPath}`);
logger.debug(`CA private key generated: ${keyPath}`);
// Generate DER format for easier import into trust stores
await execa('openssl', [
'x509',
'-in', certPath,
'-outform', 'DER',
'-out', derPath,
]);
fs.chmodSync(derPath, 0o644);
logger.debug(`CA certificate (DER) generated: ${derPath}`);
return { certPath, keyPath, derPath };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw new Error(`Failed to generate SSL Bump CA: ${message}`);
}
}
/**
* Initializes Squid's SSL certificate database
*
* Squid requires a certificate database to store dynamically generated
* certificates for SSL Bump mode. The database structure expected by Squid is:
* - ssl_db/certs/ - Directory for storing generated certificates
* - ssl_db/index.txt - Index file for certificate lookups
* - ssl_db/size - File tracking current database size
*
* NOTE: We create this structure on the host because security_file_certgen
* (Squid's DB initialization tool) requires the directory to NOT exist when
* it runs. Since Docker volume mounts create the directory, we need to
* pre-populate the structure ourselves.
*
* @param workDir - Working directory
* @returns Path to the SSL database directory
*/
export async function initSslDb(workDir: string): Promise<string> {
const sslDbPath = path.join(workDir, 'ssl_db');
const certsPath = path.join(sslDbPath, 'certs');
const indexPath = path.join(sslDbPath, 'index.txt');
const sizePath = path.join(sslDbPath, 'size');
// Create the database structure (recursive:true is a no-op if dir exists, avoids TOCTOU)
fs.mkdirSync(sslDbPath, { recursive: true, mode: 0o700 });
// Create certs subdirectory
fs.mkdirSync(certsPath, { recursive: true, mode: 0o700 });
// Create index.txt atomically — 'wx' flag (O_WRONLY|O_CREAT|O_EXCL) fails if file exists
try {
fs.writeFileSync(indexPath, '', { mode: 0o600, flag: 'wx' });
} catch (err: unknown) {
if ((err as NodeJS.ErrnoException).code !== 'EEXIST') throw err;
}
// Create size file atomically
try {
fs.writeFileSync(sizePath, '0\n', { mode: 0o600, flag: 'wx' });
} catch (err: unknown) {
if ((err as NodeJS.ErrnoException).code !== 'EEXIST') throw err;
}
// Chown to proxy user (uid=13, gid=13) so the non-root Squid container can access it
// Gracefully skip if not running as root (e.g., in unit tests)
try {
chownRecursive(sslDbPath, 13, 13);
} catch (err: unknown) {
if ((err as NodeJS.ErrnoException).code !== 'EPERM') throw err;
logger.debug('Skipping SSL db chown (not running as root)');
}
logger.debug(`SSL certificate database initialized at: ${sslDbPath}`);
return sslDbPath;
}
/**
* Validates that OpenSSL is available
*
* @returns true if OpenSSL is available, false otherwise
*/
export async function isOpenSslAvailable(): Promise<boolean> {
try {
await execa('openssl', ['version']);
return true;
} catch {
return false;
}
}
/**
* Regex pattern for matching URL path characters.
* Uses character class instead of .* to prevent catastrophic backtracking (ReDoS).
* Matches any non-whitespace character, which is appropriate for URL paths.
*/
const URL_CHAR_PATTERN = '[^\\s]*';
/**
* Parses URL patterns for SSL Bump ACL rules
*
* Converts user-friendly URL patterns into Squid url_regex ACL patterns.
*
* Examples:
* - `https://github.com/myorg/*` → `^https://github\.com/myorg/[^\s]*`
* - `https://api.example.com/v1/users` → `^https://api\.example\.com/v1/users$`
*
* @param patterns - Array of URL patterns (can include wildcards)
* @returns Array of regex patterns for Squid url_regex ACL
*/
export function parseUrlPatterns(patterns: string[]): string[] {
return patterns.map(pattern => {
// Remove trailing slash for consistency
let p = pattern.replace(/\/$/, '');
// Preserve existing .* patterns by using a placeholder before escaping
const WILDCARD_PLACEHOLDER = '\x00WILDCARD\x00';
p = p.replace(/\.\*/g, WILDCARD_PLACEHOLDER);
// Escape regex special characters except *
p = p.replace(/[.+?^${}()|[\]\\]/g, '\\$&');
// Convert * wildcards to safe pattern (prevents ReDoS)
p = p.replace(/\*/g, URL_CHAR_PATTERN);
// Restore preserved patterns from placeholder
p = p.replace(new RegExp(WILDCARD_PLACEHOLDER, 'g'), URL_CHAR_PATTERN);
// Anchor the pattern
// If pattern ends with the URL char pattern (from wildcard), don't add end anchor
if (p.endsWith(URL_CHAR_PATTERN)) {
return `^${p}`;
}
// For exact matches, add end anchor
return `^${p}$`;
});
}