nodebb-plugin-pdf-secure 1.2.4 → 1.2.6

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.
@@ -5,23 +5,21 @@ const pdfHandler = require('./pdf-handler');
5
5
 
6
6
  const Controllers = module.exports;
7
7
 
8
- // XOR key for partial encryption (rotates through this pattern)
9
- const XOR_KEY = [0x5A, 0x3C, 0x7E, 0x1F, 0x9D, 0xB2, 0x4A, 0xE8];
10
-
11
8
  // Partial XOR - encrypts first 10KB and every 50th byte after that
12
- function partialXorEncode(buffer) {
9
+ // Now uses dynamic key from nonce data
10
+ function partialXorEncode(buffer, xorKey) {
13
11
  const data = Buffer.from(buffer);
14
- const keyLen = XOR_KEY.length;
12
+ const keyLen = xorKey.length;
15
13
 
16
14
  // Encrypt first 10KB fully
17
15
  const fullEncryptLen = Math.min(10240, data.length);
18
16
  for (let i = 0; i < fullEncryptLen; i++) {
19
- data[i] = data[i] ^ XOR_KEY[i % keyLen];
17
+ data[i] = data[i] ^ xorKey[i % keyLen];
20
18
  }
21
19
 
22
20
  // Encrypt every 50th byte after that
23
21
  for (let i = fullEncryptLen; i < data.length; i += 50) {
24
- data[i] = data[i] ^ XOR_KEY[i % keyLen];
22
+ data[i] = data[i] ^ xorKey[i % keyLen];
25
23
  }
26
24
 
27
25
  return data;
@@ -56,16 +54,14 @@ Controllers.servePdfBinary = async function (req, res) {
56
54
  pdfBuffer = await pdfHandler.getSinglePagePdf(data.file);
57
55
  }
58
56
 
59
- // Apply partial XOR encryption
60
- const encodedBuffer = partialXorEncode(pdfBuffer);
57
+ // Apply partial XOR encryption with dynamic key from nonce
58
+ const encodedBuffer = partialXorEncode(pdfBuffer, data.xorKey);
61
59
 
62
60
  res.set({
63
- 'Content-Type': 'application/octet-stream',
61
+ 'Content-Type': 'image/gif', // Misleading - actual PDF binary
64
62
  'Cache-Control': 'no-store, no-cache, must-revalidate, private',
65
63
  'X-Content-Type-Options': 'nosniff',
66
64
  'Content-Disposition': 'inline',
67
- // Send XOR key as header for client decoding
68
- 'X-PDF-Key': Buffer.from(XOR_KEY).toString('base64'),
69
65
  });
70
66
 
71
67
  return res.send(encodedBuffer);
@@ -1,5 +1,6 @@
1
1
  'use strict';
2
2
 
3
+ const crypto = require('crypto');
3
4
  const { v4: uuidv4 } = require('uuid');
4
5
 
5
6
  const store = new Map();
@@ -16,17 +17,38 @@ setInterval(() => {
16
17
  }
17
18
  }, CLEANUP_INTERVAL).unref();
18
19
 
20
+ // Generate a random XOR key (8 bytes)
21
+ function generateXorKey() {
22
+ return crypto.randomBytes(8);
23
+ }
24
+
19
25
  const NonceStore = module.exports;
20
26
 
21
27
  NonceStore.generate = function (uid, file, isPremium) {
22
28
  const nonce = uuidv4();
29
+ const xorKey = generateXorKey();
30
+
23
31
  store.set(nonce, {
24
32
  uid: uid,
25
33
  file: file,
26
34
  isPremium: isPremium,
35
+ xorKey: xorKey, // Store unique key for this nonce
27
36
  createdAt: Date.now(),
28
37
  });
29
- return nonce;
38
+
39
+ return {
40
+ nonce: nonce,
41
+ xorKey: xorKey.toString('base64') // Return key for viewer injection
42
+ };
43
+ };
44
+
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');
30
52
  };
31
53
 
32
54
  NonceStore.validate = function (nonce, uid) {
@@ -48,5 +70,5 @@ NonceStore.validate = function (nonce, uid) {
48
70
  return null;
49
71
  }
50
72
 
51
- return data;
73
+ return data; // Now includes xorKey
52
74
  };
package/library.js CHANGED
@@ -26,6 +26,14 @@ plugin.init = async (params) => {
26
26
  console.error('[PDF-Secure] Failed to cache viewer template:', err.message);
27
27
  }
28
28
 
29
+ // Double slash bypass protection - catches /uploads//files/ attempts
30
+ router.use((req, res, next) => {
31
+ if (req.path.includes('//') && req.path.toLowerCase().includes('.pdf')) {
32
+ return res.status(403).json({ error: 'Invalid path' });
33
+ }
34
+ next();
35
+ });
36
+
29
37
  // PDF direct access blocker middleware
30
38
  // Intercepts requests to uploaded PDF files and returns 403
31
39
  router.get('/assets/uploads/files/:filename', (req, res, next) => {
@@ -59,6 +67,11 @@ plugin.init = async (params) => {
59
67
  return res.status(500).send('Viewer not available');
60
68
  }
61
69
 
70
+ // Generate nonce + key HERE (in viewer route)
71
+ // This way the key is ONLY embedded in HTML, never in a separate API response
72
+ const isPremium = true;
73
+ const nonceData = nonceStore.generate(req.uid, safeName, isPremium);
74
+
62
75
  // Serve the viewer template with comprehensive security headers
63
76
  res.set({
64
77
  'X-Frame-Options': 'SAMEORIGIN',
@@ -72,7 +85,8 @@ plugin.init = async (params) => {
72
85
  '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'",
73
86
  });
74
87
 
75
- // Inject the filename and config into the cached viewer
88
+ // Inject the filename, nonce, and key into the cached viewer
89
+ // Key is embedded in HTML - NOT visible in any network API response!
76
90
  const injectedHtml = viewerHtmlCache
77
91
  .replace('</head>', `
78
92
  <style>
@@ -83,7 +97,9 @@ plugin.init = async (params) => {
83
97
  window.PDF_SECURE_CONFIG = {
84
98
  filename: ${JSON.stringify(safeName)},
85
99
  relativePath: ${JSON.stringify(req.app.get('relative_path') || '')},
86
- csrfToken: ${JSON.stringify(req.csrfToken ? req.csrfToken() : '')}
100
+ csrfToken: ${JSON.stringify(req.csrfToken ? req.csrfToken() : '')},
101
+ nonce: ${JSON.stringify(nonceData.nonce)},
102
+ dk: ${JSON.stringify(nonceData.xorKey)}
87
103
  };
88
104
  </script>
89
105
  </head>`);
@@ -93,30 +109,8 @@ plugin.init = async (params) => {
93
109
  };
94
110
 
95
111
  plugin.addRoutes = async ({ router, middleware, helpers }) => {
96
- // Nonce generation endpoint
97
- routeHelpers.setupApiRoute(router, 'get', '/pdf-secure/nonce', [middleware.ensureLoggedIn], async (req, res) => {
98
- const { file } = req.query;
99
- if (!file) {
100
- return helpers.formatApiResponse(400, res, new Error('Missing file parameter'));
101
- }
102
-
103
- // Sanitize filename
104
- const path = require('path');
105
- const safeName = path.basename(file);
106
- if (!safeName || !safeName.toLowerCase().endsWith('.pdf')) {
107
- return helpers.formatApiResponse(400, res, new Error('Invalid file'));
108
- }
109
-
110
- // Premium check disabled for testing — everyone gets full PDF
111
- const isPremium = true;
112
-
113
- const nonce = nonceStore.generate(req.uid, safeName, isPremium);
114
-
115
- helpers.formatApiResponse(200, res, {
116
- nonce: nonce,
117
- isPremium: isPremium,
118
- });
119
- });
112
+ // Nonce endpoint removed - nonce is now generated in viewer route
113
+ // This improves security by not exposing any key-related data in API responses
120
114
  };
121
115
 
122
116
  plugin.addAdminNavigation = (header) => {
@@ -129,4 +123,35 @@ plugin.addAdminNavigation = (header) => {
129
123
  return header;
130
124
  };
131
125
 
126
+ // Filter meta tags to hide PDF URLs and filenames
127
+ plugin.filterMetaTags = async (hookData) => {
128
+ if (!hookData || !hookData.tags) {
129
+ return hookData;
130
+ }
131
+
132
+ // Filter out PDF-related meta tags
133
+ hookData.tags = hookData.tags.filter(tag => {
134
+ // Remove og:image if it contains .pdf
135
+ if (tag.property === 'og:image' && tag.content && tag.content.toLowerCase().includes('.pdf')) {
136
+ return false;
137
+ }
138
+ // Remove twitter:image if it contains .pdf
139
+ if (tag.name === 'twitter:image' && tag.content && tag.content.toLowerCase().includes('.pdf')) {
140
+ return false;
141
+ }
142
+ return true;
143
+ });
144
+
145
+ // Sanitize description to hide .pdf extensions
146
+ hookData.tags = hookData.tags.map(tag => {
147
+ if ((tag.name === 'description' || tag.property === 'og:description') && tag.content) {
148
+ // Replace .pdf extension with empty string in description
149
+ tag.content = tag.content.replace(/\.pdf/gi, '');
150
+ }
151
+ return tag;
152
+ });
153
+
154
+ return hookData;
155
+ };
156
+
132
157
  module.exports = plugin;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodebb-plugin-pdf-secure",
3
- "version": "1.2.4",
3
+ "version": "1.2.6",
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": {
package/plugin.json CHANGED
@@ -14,6 +14,10 @@
14
14
  {
15
15
  "hook": "filter:admin.header.build",
16
16
  "method": "addAdminNavigation"
17
+ },
18
+ {
19
+ "hook": "filter:meta.getMetaTags",
20
+ "method": "filterMetaTags"
17
21
  }
18
22
  ],
19
23
  "staticDirs": {