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.
- package/lib/controllers.js +8 -12
- package/lib/nonce-store.js +24 -2
- package/library.js +51 -26
- package/package.json +1 -1
- package/plugin.json +4 -0
- package/static/viewer.html +1620 -1543
package/lib/controllers.js
CHANGED
|
@@ -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
|
-
|
|
9
|
+
// Now uses dynamic key from nonce data
|
|
10
|
+
function partialXorEncode(buffer, xorKey) {
|
|
13
11
|
const data = Buffer.from(buffer);
|
|
14
|
-
const keyLen =
|
|
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] ^
|
|
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] ^
|
|
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': '
|
|
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);
|
package/lib/nonce-store.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
97
|
-
|
|
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