nodebb-plugin-pdf-secure 1.2.28 → 1.2.30
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/image.png +0 -0
- package/lib/controllers.js +21 -24
- package/lib/nonce-store.js +12 -16
- package/library.js +56 -47
- package/package.json +1 -1
- package/static/lib/pdf-secure (1).pdf +0 -0
- package/static/viewer-app.js +33 -26
- package/static/viewer.html +128 -29
- package/static/lib/viewer.js +0 -184
- package/static/viewer-yedek.html +0 -4548
package/image.png
ADDED
|
Binary file
|
package/lib/controllers.js
CHANGED
|
@@ -1,28 +1,18 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
+
const crypto = require('crypto');
|
|
3
4
|
const nonceStore = require('./nonce-store');
|
|
4
5
|
const pdfHandler = require('./pdf-handler');
|
|
5
6
|
|
|
6
7
|
const Controllers = module.exports;
|
|
7
8
|
|
|
8
|
-
//
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
const
|
|
12
|
-
const
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
const fullEncryptLen = Math.min(10240, data.length);
|
|
16
|
-
for (let i = 0; i < fullEncryptLen; i++) {
|
|
17
|
-
data[i] = data[i] ^ xorKey[i % keyLen];
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
// Encrypt every 50th byte after that
|
|
21
|
-
for (let i = fullEncryptLen; i < data.length; i += 50) {
|
|
22
|
-
data[i] = data[i] ^ xorKey[i % keyLen];
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
return data;
|
|
9
|
+
// AES-256-GCM encryption - replaces weak XOR obfuscation
|
|
10
|
+
function aesGcmEncrypt(buffer, key, iv) {
|
|
11
|
+
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
|
|
12
|
+
const encrypted = Buffer.concat([cipher.update(buffer), cipher.final()]);
|
|
13
|
+
const authTag = cipher.getAuthTag(); // 16 bytes
|
|
14
|
+
// Format: [authTag (16 bytes)] [ciphertext]
|
|
15
|
+
return Buffer.concat([authTag, encrypted]);
|
|
26
16
|
}
|
|
27
17
|
|
|
28
18
|
Controllers.renderAdminPage = function (req, res) {
|
|
@@ -32,12 +22,17 @@ Controllers.renderAdminPage = function (req, res) {
|
|
|
32
22
|
};
|
|
33
23
|
|
|
34
24
|
Controllers.servePdfBinary = async function (req, res) {
|
|
25
|
+
// Authentication gate - require logged-in user
|
|
26
|
+
if (!req.uid) {
|
|
27
|
+
return res.status(401).json({ error: 'Authentication required' });
|
|
28
|
+
}
|
|
29
|
+
|
|
35
30
|
const { nonce } = req.query;
|
|
36
31
|
if (!nonce) {
|
|
37
32
|
return res.status(400).json({ error: 'Missing nonce' });
|
|
38
33
|
}
|
|
39
34
|
|
|
40
|
-
const uid = req.uid
|
|
35
|
+
const uid = req.uid;
|
|
41
36
|
|
|
42
37
|
const data = nonceStore.validate(nonce, uid);
|
|
43
38
|
if (!data) {
|
|
@@ -45,14 +40,16 @@ Controllers.servePdfBinary = async function (req, res) {
|
|
|
45
40
|
}
|
|
46
41
|
|
|
47
42
|
try {
|
|
48
|
-
//
|
|
49
|
-
const pdfBuffer =
|
|
43
|
+
// Server-side premium gate: non-premium users only get first page
|
|
44
|
+
const pdfBuffer = data.isPremium
|
|
45
|
+
? await pdfHandler.getFullPdf(data.file)
|
|
46
|
+
: await pdfHandler.getSinglePagePdf(data.file);
|
|
50
47
|
|
|
51
|
-
// Apply
|
|
52
|
-
const encodedBuffer =
|
|
48
|
+
// Apply AES-256-GCM encryption
|
|
49
|
+
const encodedBuffer = aesGcmEncrypt(pdfBuffer, data.encKey, data.encIv);
|
|
53
50
|
|
|
54
51
|
res.set({
|
|
55
|
-
'Content-Type': '
|
|
52
|
+
'Content-Type': 'application/octet-stream',
|
|
56
53
|
'Cache-Control': 'no-store, no-cache, must-revalidate, private',
|
|
57
54
|
'X-Content-Type-Options': 'nosniff',
|
|
58
55
|
'Content-Disposition': 'inline',
|
package/lib/nonce-store.js
CHANGED
|
@@ -17,40 +17,36 @@ setInterval(() => {
|
|
|
17
17
|
}
|
|
18
18
|
}, CLEANUP_INTERVAL).unref();
|
|
19
19
|
|
|
20
|
-
// Generate
|
|
21
|
-
function
|
|
22
|
-
return
|
|
20
|
+
// Generate AES-256-GCM key (32 bytes) and IV (12 bytes)
|
|
21
|
+
function generateEncryptionParams() {
|
|
22
|
+
return {
|
|
23
|
+
key: crypto.randomBytes(32),
|
|
24
|
+
iv: crypto.randomBytes(12),
|
|
25
|
+
};
|
|
23
26
|
}
|
|
24
27
|
|
|
25
28
|
const NonceStore = module.exports;
|
|
26
29
|
|
|
27
30
|
NonceStore.generate = function (uid, file, isPremium) {
|
|
28
31
|
const nonce = uuidv4();
|
|
29
|
-
const
|
|
32
|
+
const { key, iv } = generateEncryptionParams();
|
|
30
33
|
|
|
31
34
|
store.set(nonce, {
|
|
32
35
|
uid: uid,
|
|
33
36
|
file: file,
|
|
34
37
|
isPremium: isPremium,
|
|
35
|
-
|
|
38
|
+
encKey: key, // AES-256-GCM key
|
|
39
|
+
encIv: iv, // GCM initialization vector
|
|
36
40
|
createdAt: Date.now(),
|
|
37
41
|
});
|
|
38
42
|
|
|
39
43
|
return {
|
|
40
44
|
nonce: nonce,
|
|
41
|
-
|
|
45
|
+
dk: key.toString('base64'), // decryption key for viewer
|
|
46
|
+
iv: iv.toString('base64'), // IV for viewer
|
|
42
47
|
};
|
|
43
48
|
};
|
|
44
49
|
|
|
45
|
-
// Get key without consuming nonce (for viewer injection)
|
|
46
|
-
NonceStore.getKey = function (nonce) {
|
|
47
|
-
const data = store.get(nonce);
|
|
48
|
-
if (!data) {
|
|
49
|
-
return null;
|
|
50
|
-
}
|
|
51
|
-
return data.xorKey.toString('base64');
|
|
52
|
-
};
|
|
53
|
-
|
|
54
50
|
NonceStore.validate = function (nonce, uid) {
|
|
55
51
|
const data = store.get(nonce);
|
|
56
52
|
if (!data) {
|
|
@@ -70,5 +66,5 @@ NonceStore.validate = function (nonce, uid) {
|
|
|
70
66
|
return null;
|
|
71
67
|
}
|
|
72
68
|
|
|
73
|
-
return data; //
|
|
69
|
+
return data; // Includes encKey and encIv for AES-256-GCM
|
|
74
70
|
};
|
package/library.js
CHANGED
|
@@ -55,14 +55,19 @@ plugin.init = async (params) => {
|
|
|
55
55
|
next();
|
|
56
56
|
});
|
|
57
57
|
|
|
58
|
-
// PDF binary endpoint (nonce-validated,
|
|
58
|
+
// PDF binary endpoint (nonce-validated, authentication required)
|
|
59
59
|
router.get('/api/v3/plugins/pdf-secure/pdf-data', controllers.servePdfBinary);
|
|
60
60
|
|
|
61
61
|
// Admin page route
|
|
62
62
|
routeHelpers.setupAdminPageRoute(router, '/admin/plugins/pdf-secure', controllers.renderAdminPage);
|
|
63
63
|
|
|
64
|
-
// Viewer page route (fullscreen Mozilla PDF.js viewer,
|
|
64
|
+
// Viewer page route (fullscreen Mozilla PDF.js viewer, authentication required)
|
|
65
65
|
router.get('/plugins/pdf-secure/viewer', async (req, res) => {
|
|
66
|
+
// Authentication gate - require logged-in user
|
|
67
|
+
if (!req.uid) {
|
|
68
|
+
return res.status(401).json({ error: 'Authentication required' });
|
|
69
|
+
}
|
|
70
|
+
|
|
66
71
|
const { file } = req.query;
|
|
67
72
|
if (!file) {
|
|
68
73
|
return res.status(400).send('Missing file parameter');
|
|
@@ -79,10 +84,10 @@ plugin.init = async (params) => {
|
|
|
79
84
|
return res.status(500).send('Viewer not available');
|
|
80
85
|
}
|
|
81
86
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
87
|
+
try {
|
|
88
|
+
// Check if user is Premium or Lite (admins/global mods always premium)
|
|
89
|
+
let isPremium = false;
|
|
90
|
+
let isLite = false;
|
|
86
91
|
const [isAdmin, isGlobalMod, isPremiumMember, isVipMember, isLiteMember] = await Promise.all([
|
|
87
92
|
groups.isMember(req.uid, 'administrators'),
|
|
88
93
|
groups.isMember(req.uid, 'Global Moderators'),
|
|
@@ -93,48 +98,52 @@ plugin.init = async (params) => {
|
|
|
93
98
|
isPremium = isAdmin || isGlobalMod || isPremiumMember || isVipMember;
|
|
94
99
|
// Lite: full PDF access but restricted UI (no annotations, sidebar, etc.)
|
|
95
100
|
isLite = !isPremium && isLiteMember;
|
|
96
|
-
}
|
|
97
101
|
|
|
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
|
-
|
|
102
|
+
// Lite users get full PDF like premium (for nonce/server-side PDF data)
|
|
103
|
+
const hasFullAccess = isPremium || isLite;
|
|
104
|
+
const nonceData = nonceStore.generate(req.uid, safeName, hasFullAccess);
|
|
105
|
+
|
|
106
|
+
// Serve the viewer template with comprehensive security headers
|
|
107
|
+
res.set({
|
|
108
|
+
'X-Frame-Options': 'SAMEORIGIN',
|
|
109
|
+
'X-Content-Type-Options': 'nosniff',
|
|
110
|
+
'X-XSS-Protection': '1; mode=block',
|
|
111
|
+
'Cache-Control': 'no-store, no-cache, must-revalidate, private, max-age=0',
|
|
112
|
+
'Pragma': 'no-cache',
|
|
113
|
+
'Expires': '0',
|
|
114
|
+
'Referrer-Policy': 'no-referrer',
|
|
115
|
+
'Permissions-Policy': 'accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=()',
|
|
116
|
+
'Content-Security-Policy': "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://cdnjs.cloudflare.com; style-src 'self' 'unsafe-inline' https://cdnjs.cloudflare.com; img-src 'self' data: blob:; connect-src 'self'; frame-ancestors 'self'",
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
// Inject the filename, nonce, and key into the cached viewer
|
|
120
|
+
// Key is embedded in HTML - NOT visible in any network API response!
|
|
121
|
+
const injectedHtml = viewerHtmlCache
|
|
122
|
+
.replace('</head>', `
|
|
123
|
+
<style>
|
|
124
|
+
/* Hide upload overlay since PDF will auto-load */
|
|
125
|
+
#uploadOverlay { display: none !important; }
|
|
126
|
+
</style>
|
|
127
|
+
<script>
|
|
128
|
+
window.PDF_SECURE_CONFIG = {
|
|
129
|
+
filename: ${JSON.stringify(safeName)},
|
|
130
|
+
relativePath: ${JSON.stringify(req.app.get('relative_path') || '')},
|
|
131
|
+
csrfToken: ${JSON.stringify(req.csrfToken ? req.csrfToken() : '')},
|
|
132
|
+
nonce: ${JSON.stringify(nonceData.nonce)},
|
|
133
|
+
dk: ${JSON.stringify(nonceData.dk)},
|
|
134
|
+
iv: ${JSON.stringify(nonceData.iv)},
|
|
135
|
+
isPremium: ${JSON.stringify(isPremium)},
|
|
136
|
+
isLite: ${JSON.stringify(isLite)},
|
|
137
|
+
uid: ${JSON.stringify(req.uid)}
|
|
138
|
+
};
|
|
139
|
+
</script>
|
|
140
|
+
</head>`);
|
|
141
|
+
|
|
142
|
+
res.type('html').send(injectedHtml);
|
|
143
|
+
} catch (err) {
|
|
144
|
+
console.error('[PDF-Secure] Viewer route error:', err.message);
|
|
145
|
+
return res.status(500).send('Internal error');
|
|
146
|
+
}
|
|
138
147
|
});
|
|
139
148
|
};
|
|
140
149
|
|
package/package.json
CHANGED
|
Binary file
|
package/static/viewer-app.js
CHANGED
|
@@ -151,31 +151,37 @@
|
|
|
151
151
|
// Thumbnails will be generated on-demand when sidebar opens
|
|
152
152
|
}
|
|
153
153
|
|
|
154
|
-
//
|
|
155
|
-
function
|
|
156
|
-
const
|
|
157
|
-
const
|
|
158
|
-
const
|
|
159
|
-
|
|
160
|
-
//
|
|
161
|
-
const
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
154
|
+
// AES-256-GCM decoder using Web Crypto API
|
|
155
|
+
async function aesGcmDecode(encodedData, keyBase64, ivBase64) {
|
|
156
|
+
const keyBytes = Uint8Array.from(atob(keyBase64), c => c.charCodeAt(0));
|
|
157
|
+
const iv = Uint8Array.from(atob(ivBase64), c => c.charCodeAt(0));
|
|
158
|
+
const encData = new Uint8Array(encodedData);
|
|
159
|
+
|
|
160
|
+
// Format: [authTag (16 bytes)] [ciphertext]
|
|
161
|
+
const authTag = encData.slice(0, 16);
|
|
162
|
+
const ciphertext = encData.slice(16);
|
|
163
|
+
|
|
164
|
+
// Web Crypto expects ciphertext + authTag concatenated
|
|
165
|
+
const combined = new Uint8Array(ciphertext.length + authTag.length);
|
|
166
|
+
combined.set(ciphertext);
|
|
167
|
+
combined.set(authTag, ciphertext.length);
|
|
168
|
+
|
|
169
|
+
const cryptoKey = await crypto.subtle.importKey(
|
|
170
|
+
'raw', keyBytes, { name: 'AES-GCM' }, false, ['decrypt']
|
|
171
|
+
);
|
|
172
|
+
|
|
173
|
+
return crypto.subtle.decrypt(
|
|
174
|
+
{ name: 'AES-GCM', iv: iv },
|
|
175
|
+
cryptoKey,
|
|
176
|
+
combined
|
|
177
|
+
);
|
|
172
178
|
}
|
|
173
179
|
|
|
174
180
|
function showPremiumLockOverlay(totalPages) {
|
|
175
181
|
const viewerEl = document.getElementById('viewer');
|
|
176
182
|
if (!viewerEl) return;
|
|
177
183
|
|
|
178
|
-
const uid = (
|
|
184
|
+
const uid = (_cfg && _cfg.uid) || 0;
|
|
179
185
|
const checkoutUrl = 'https://forum.ieu.app/pay/checkout?uid=' + uid;
|
|
180
186
|
|
|
181
187
|
const overlay = document.createElement('div');
|
|
@@ -373,7 +379,8 @@
|
|
|
373
379
|
if (!pdfBuffer) {
|
|
374
380
|
// Nonce and key are embedded in HTML config (not fetched from API)
|
|
375
381
|
const nonce = config.nonce;
|
|
376
|
-
const
|
|
382
|
+
const decryptKey = config.dk;
|
|
383
|
+
const decryptIv = config.iv;
|
|
377
384
|
|
|
378
385
|
// Fetch encrypted PDF binary
|
|
379
386
|
const pdfUrl = config.relativePath + '/api/v3/plugins/pdf-secure/pdf-data?nonce=' + encodeURIComponent(nonce);
|
|
@@ -386,10 +393,10 @@
|
|
|
386
393
|
const encodedBuffer = await pdfRes.arrayBuffer();
|
|
387
394
|
console.log('[PDF-Secure] Encrypted data received:', encodedBuffer.byteLength, 'bytes');
|
|
388
395
|
|
|
389
|
-
//
|
|
390
|
-
if (
|
|
391
|
-
console.log('[PDF-Secure]
|
|
392
|
-
pdfBuffer =
|
|
396
|
+
// Decrypt AES-256-GCM encrypted data
|
|
397
|
+
if (decryptKey && decryptIv) {
|
|
398
|
+
console.log('[PDF-Secure] Decrypting AES-256-GCM data...');
|
|
399
|
+
pdfBuffer = await aesGcmDecode(encodedBuffer, decryptKey, decryptIv);
|
|
393
400
|
} else {
|
|
394
401
|
pdfBuffer = encodedBuffer;
|
|
395
402
|
}
|
|
@@ -467,7 +474,7 @@
|
|
|
467
474
|
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z"/>
|
|
468
475
|
</svg>
|
|
469
476
|
<h2>Hata</h2>
|
|
470
|
-
<p>${err.message}</p>
|
|
477
|
+
<p>${err.message.replace(/[<>&"']/g, c => ({'<':'<','>':'>','&':'&','"':'"',"'":'''}[c]))}</p>
|
|
471
478
|
`;
|
|
472
479
|
}
|
|
473
480
|
}
|
|
@@ -2658,7 +2665,7 @@
|
|
|
2658
2665
|
function toggleFullscreen() {
|
|
2659
2666
|
if (window.self !== window.top) {
|
|
2660
2667
|
// Inside iframe: ask parent to handle fullscreen
|
|
2661
|
-
window.parent.postMessage({ type: 'pdf-secure-fullscreen-toggle' },
|
|
2668
|
+
window.parent.postMessage({ type: 'pdf-secure-fullscreen-toggle' }, window.location.origin);
|
|
2662
2669
|
} else {
|
|
2663
2670
|
// Standalone mode: use native fullscreen
|
|
2664
2671
|
if (document.fullscreenElement) {
|