Skip to content

Commit aa11bcb

Browse files
committed
security: harden dDRM pipeline — remove CEK logging, rate limit decrypts, audit trail, CORS, blob cleanup
Pre-media-pipeline security hardening addressing 5 findings from dDRM audit: - Remove plaintext CEK and decrypted payload logging from media.ts, replace with truncated SHA-256 hash (non-reversible, still useful for debugging) - Add dDRM-specific rate limit scopes (30 decrypt/min, 300 segments/min) and move rate limiter before router mounts so decrypt endpoints are covered - Add /api/storage/lit/secure-view, /api/storage/lit/decrypt, /api/media/init to AUDITED_ENDPOINTS with action names for structured audit trail - Replace CORS wildcard (*) with origin allowlist (localhost, puter.me, puter.site, puter.localhost) to prevent cross-origin content exfiltration - Track and revoke blob URLs in dDRM viewer on page unload to prevent decrypted content persisting in browser memory Made-with: Cursor
1 parent c2ee84f commit aa11bcb

7 files changed

Lines changed: 78 additions & 10 deletions

File tree

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
/**
2+
* Lit Action: Non-Media Asset Encryption (Chipotle/PKP-AES)
3+
*
4+
* Encrypts the Content Encryption Key (CEK) using Lit.Actions.Encrypt (PKP-AES).
5+
* The CEK is passed as a base64 string and encrypted under the specified PKP,
6+
* producing a ciphertext that can only be decrypted by the same PKP via
7+
* Lit.Actions.Decrypt.
8+
*
9+
* jsParams expected:
10+
* - plaintext: The base64-encoded CEK string to encrypt
11+
* - pkpId: PKP wallet address to encrypt under
12+
*/
13+
14+
(async () => {
15+
try {
16+
const encrypted = await Lit.Actions.Encrypt({
17+
pkpId: pkpId,
18+
message: plaintext,
19+
});
20+
Lit.Actions.setResponse({ response: JSON.stringify({ ciphertext: encrypted }) });
21+
} catch (e) {
22+
Lit.Actions.setResponse({ response: JSON.stringify({ error: e.message }) });
23+
}
24+
})();

pc2-node/data/test-apps/ddrm-viewer/viewer.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,7 @@
108108
var viewerState = {
109109
totalPages: 1,
110110
pagesLoaded: 0,
111+
blobUrls: [],
111112
};
112113

113114
var zoom = { level: 1, min: 0.25, max: 5, step: 0.25 };
@@ -194,6 +195,7 @@
194195
})
195196
.then(function (result) {
196197
var blobUrl = URL.createObjectURL(result.blob);
198+
viewerState.blobUrls.push(blobUrl);
197199

198200
if (isAudioType) {
199201
showAudioPlayer(blobUrl);
@@ -228,6 +230,7 @@
228230
})
229231
.then(function (blob) {
230232
var blobUrl = URL.createObjectURL(blob);
233+
viewerState.blobUrls.push(blobUrl);
231234
var placeholder = document.getElementById('page-slot-' + pageNum);
232235
if (placeholder) {
233236
var img = document.createElement('img');
@@ -589,6 +592,11 @@
589592
}
590593
}
591594

595+
// ── Cleanup: revoke blob URLs on unload ──────────────
596+
window.addEventListener('beforeunload', function () {
597+
viewerState.blobUrls.forEach(function (url) { URL.revokeObjectURL(url); });
598+
});
599+
592600
// ── Go ────────────────────────────────────────────────
593601

594602
init();

pc2-node/src/api/audit.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,10 @@ const AUDITED_ENDPOINTS = [
2828
'/api/ai/chat',
2929
// Key-value operations
3030
'/kv/',
31+
// dDRM decrypt operations
32+
'/api/storage/lit/secure-view',
33+
'/api/storage/lit/decrypt',
34+
'/api/media/init',
3135
];
3236

3337
/**
@@ -63,6 +67,9 @@ function extractAction(method: string, endpoint: string): string {
6367
'POST /api/backups/create': 'backup_create',
6468
'POST /api/backups/restore': 'backup_restore',
6569
'POST /api/ai/chat': 'ai_chat',
70+
'POST /api/storage/lit/secure-view': 'ddrm_secure_view',
71+
'POST /api/storage/lit/decrypt': 'ddrm_decrypt',
72+
'POST /api/media/init': 'media_session_init',
6673
};
6774

6875
const key = `${method} ${path}`;

pc2-node/src/api/index.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -336,6 +336,10 @@ export function setupAPI(app: Express): void {
336336
}
337337
});
338338

339+
// Apply rate limiting and audit middleware before routers so dDRM endpoints are covered
340+
app.use(rateLimitMiddleware());
341+
app.use(auditMiddleware);
342+
339343
// Storage usage endpoint
340344
app.use('/api/storage', storageRouter);
341345
app.use('/api/media', mediaRouter);
@@ -407,10 +411,6 @@ export function setupAPI(app: Express): void {
407411
});
408412
});
409413

410-
// Apply rate limiting and audit middleware to all routes (after authentication)
411-
app.use(rateLimitMiddleware());
412-
app.use(auditMiddleware);
413-
414414
// Search endpoint (require auth)
415415
app.post('/search', authenticate, handleSearch);
416416

pc2-node/src/api/media.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1049,9 +1049,8 @@ async function unwrapECDHEnvelope(
10491049
// Verify structure: read keyCount at bodyOffset
10501050
const keyCount = (decrypted[bodyOffset] << 24) | (decrypted[bodyOffset + 1] << 16) |
10511051
(decrypted[bodyOffset + 2] << 8) | decrypted[bodyOffset + 3];
1052-
logger.info(`[media/CEK] Unwrapped license: metaSize=${metaSize}, keyCount=${keyCount}, cekStart=${cekStart}, totalDecrypted=${decrypted.length}`);
1053-
logger.info(`[media/CEK] CEK (hex): ${Buffer.from(cekBytes).toString('hex')}`);
1054-
logger.info(`[media/CEK] Full decrypted payload (hex): ${Buffer.from(decrypted).toString('hex')}`);
1052+
const cekHash = crypto.createHash('sha256').update(cekBytes).digest('hex').slice(0, 12);
1053+
logger.info(`[media/CEK] Unwrapped license: metaSize=${metaSize}, keyCount=${keyCount}, cekLen=${cekBytes.length}, cekSha=${cekHash}`);
10551054
return Buffer.from(cekBytes).toString('base64');
10561055
}
10571056

pc2-node/src/api/middleware.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -444,13 +444,24 @@ export function corsMiddleware(
444444
res: Response,
445445
next: NextFunction
446446
): void {
447-
res.setHeader('Access-Control-Allow-Origin', '*');
447+
const origin = req.headers.origin || '';
448+
const allowed =
449+
!origin ||
450+
origin.startsWith('http://localhost') ||
451+
origin.startsWith('https://localhost') ||
452+
origin.includes('.puter.localhost') ||
453+
origin.includes('.puter.me') ||
454+
origin.includes('.puter.site');
455+
456+
if (allowed) {
457+
res.setHeader('Access-Control-Allow-Origin', origin || '*');
458+
}
448459
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
449460
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, Range');
450461
res.setHeader('Access-Control-Expose-Headers', 'Content-Range, Accept-Ranges, Content-Length');
451462

452463
if (req.method === 'OPTIONS') {
453-
res.status(200).end();
464+
res.status(allowed ? 200 : 403).end();
454465
return;
455466
}
456467

pc2-node/src/api/rate-limit.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,16 @@ const DEFAULT_LIMITS: Record<string, RateLimitConfig> = {
4040
windowMs: 60 * 1000, // 1 minute
4141
maxRequests: 10, // 10 requests per minute
4242
},
43+
// dDRM decrypt operations — prevent bulk content extraction
44+
ddrm: {
45+
windowMs: 60 * 1000, // 1 minute
46+
maxRequests: 30, // 30 decrypts per minute per wallet
47+
},
48+
// Media segment fetches — higher limit for smooth HD playback
49+
media_segment: {
50+
windowMs: 60 * 1000, // 1 minute
51+
maxRequests: 300, // 300 segments per minute per wallet
52+
},
4353
// Default for unscoped requests
4454
default: {
4555
windowMs: 60 * 1000, // 1 minute
@@ -101,7 +111,16 @@ function getEndpointScope(method: string, path: string): string {
101111
'/kv/',
102112
];
103113

104-
// Check each category
114+
// Check each category — dDRM endpoints first (most specific)
115+
const ddrmEndpoints = [
116+
'/api/storage/lit/secure-view', '/api/storage/lit/decrypt',
117+
'/api/media/init',
118+
];
119+
for (const endpoint of ddrmEndpoints) {
120+
if (path.startsWith(endpoint)) return 'ddrm';
121+
}
122+
if (path.startsWith('/api/media/segment')) return 'media_segment';
123+
105124
for (const endpoint of adminEndpoints) {
106125
if (path.startsWith(endpoint)) return 'admin';
107126
}

0 commit comments

Comments
 (0)