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 ADDED
Binary file
@@ -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
- // Partial XOR - encrypts first 10KB and every 50th byte after that
9
- // Now uses dynamic key from nonce data
10
- function partialXorEncode(buffer, xorKey) {
11
- const data = Buffer.from(buffer);
12
- const keyLen = xorKey.length;
13
-
14
- // Encrypt first 10KB fully
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 || 0; // Guest uid = 0
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
- // Always send full PDF - page restriction is handled client-side
49
- const pdfBuffer = await pdfHandler.getFullPdf(data.file);
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 partial XOR encryption with dynamic key from nonce
52
- const encodedBuffer = partialXorEncode(pdfBuffer, data.xorKey);
48
+ // Apply AES-256-GCM encryption
49
+ const encodedBuffer = aesGcmEncrypt(pdfBuffer, data.encKey, data.encIv);
53
50
 
54
51
  res.set({
55
- 'Content-Type': 'image/gif', // Misleading - actual PDF binary
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',
@@ -17,40 +17,36 @@ setInterval(() => {
17
17
  }
18
18
  }, CLEANUP_INTERVAL).unref();
19
19
 
20
- // Generate a random XOR key (8 bytes)
21
- function generateXorKey() {
22
- return crypto.randomBytes(8);
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 xorKey = generateXorKey();
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
- xorKey: xorKey, // Store unique key for this nonce
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
- xorKey: xorKey.toString('base64') // Return key for viewer injection
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; // Now includes xorKey
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, guests allowed)
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, guests allowed)
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
- // Check if user is Premium or Lite (admins/global mods always premium)
83
- let isPremium = false;
84
- let isLite = false;
85
- if (req.uid) {
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
- // Lite users get full PDF like premium (for nonce/server-side PDF data)
99
- const hasFullAccess = isPremium || isLite;
100
- const nonceData = nonceStore.generate(req.uid || 0, safeName, hasFullAccess);
101
-
102
- // Serve the viewer template with comprehensive security headers
103
- res.set({
104
- 'X-Frame-Options': 'SAMEORIGIN',
105
- 'X-Content-Type-Options': 'nosniff',
106
- 'X-XSS-Protection': '1; mode=block',
107
- 'Cache-Control': 'no-store, no-cache, must-revalidate, private, max-age=0',
108
- 'Pragma': 'no-cache',
109
- 'Expires': '0',
110
- 'Referrer-Policy': 'no-referrer',
111
- 'Permissions-Policy': 'accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=()',
112
- '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'",
113
- });
114
-
115
- // Inject the filename, nonce, and key into the cached viewer
116
- // Key is embedded in HTML - NOT visible in any network API response!
117
- const injectedHtml = viewerHtmlCache
118
- .replace('</head>', `
119
- <style>
120
- /* Hide upload overlay since PDF will auto-load */
121
- #uploadOverlay { display: none !important; }
122
- </style>
123
- <script>
124
- window.PDF_SECURE_CONFIG = {
125
- filename: ${JSON.stringify(safeName)},
126
- relativePath: ${JSON.stringify(req.app.get('relative_path') || '')},
127
- csrfToken: ${JSON.stringify(req.csrfToken ? req.csrfToken() : '')},
128
- nonce: ${JSON.stringify(nonceData.nonce)},
129
- dk: ${JSON.stringify(nonceData.xorKey)},
130
- isPremium: ${JSON.stringify(isPremium)},
131
- isLite: ${JSON.stringify(isLite)},
132
- uid: ${JSON.stringify(req.uid || 0)}
133
- };
134
- </script>
135
- </head>`);
136
-
137
- res.type('html').send(injectedHtml);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodebb-plugin-pdf-secure",
3
- "version": "1.2.28",
3
+ "version": "1.2.30",
4
4
  "description": "Secure PDF viewer plugin for NodeBB - prevents downloading, enables canvas-only rendering with Premium group support",
5
5
  "main": "library.js",
6
6
  "repository": {
Binary file
@@ -151,31 +151,37 @@
151
151
  // Thumbnails will be generated on-demand when sidebar opens
152
152
  }
153
153
 
154
- // Partial XOR decoder - must match backend encoding
155
- function partialXorDecode(encodedData, keyBase64) {
156
- const key = Uint8Array.from(atob(keyBase64), c => c.charCodeAt(0));
157
- const data = new Uint8Array(encodedData);
158
- const keyLen = key.length;
159
-
160
- // Decrypt first 10KB fully
161
- const fullDecryptLen = Math.min(10240, data.length);
162
- for (let i = 0; i < fullDecryptLen; i++) {
163
- data[i] = data[i] ^ key[i % keyLen];
164
- }
165
-
166
- // Decrypt every 50th byte after that
167
- for (let i = fullDecryptLen; i < data.length; i += 50) {
168
- data[i] = data[i] ^ key[i % keyLen];
169
- }
170
-
171
- return data.buffer;
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 = (window.PDF_SECURE_CONFIG && window.PDF_SECURE_CONFIG.uid) || 0;
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 xorKey = config.dk;
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
- // Decode XOR encrypted data
390
- if (xorKey) {
391
- console.log('[PDF-Secure] Decoding XOR encrypted data...');
392
- pdfBuffer = partialXorDecode(encodedBuffer, xorKey);
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 => ({'<':'&lt;','>':'&gt;','&':'&amp;','"':'&quot;',"'":'&#39;'}[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) {