nodebb-plugin-pdf-secure 1.2.5 → 1.2.7

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/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) => {
@@ -115,4 +123,68 @@ plugin.addAdminNavigation = (header) => {
115
123
  return header;
116
124
  };
117
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 and og:image:url if it contains .pdf
135
+ if ((tag.property === 'og:image' || tag.property === 'og:image:url') && 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
+
157
+ // Transform PDF links to secure placeholders (server-side)
158
+ // This hides PDF URLs from: page source, API, RSS, ActivityPub
159
+ plugin.transformPdfLinks = async (data) => {
160
+ if (!data || !data.postData || !data.postData.content) {
161
+ return data;
162
+ }
163
+
164
+ // Regex to match PDF links: <a href="...xxx.pdf">text</a>
165
+ // Captures: full URL path, filename, link text
166
+ const pdfLinkRegex = /<a\s+[^>]*href=["']([^"']*\/([^"'\/]+\.pdf))["'][^>]*>([^<]*)<\/a>/gi;
167
+
168
+ data.postData.content = data.postData.content.replace(pdfLinkRegex, (match, fullPath, filename, linkText) => {
169
+ // Decode filename to prevent double encoding (URL may already be encoded)
170
+ let decodedFilename;
171
+ try { decodedFilename = decodeURIComponent(filename); }
172
+ catch (e) { decodedFilename = filename; }
173
+
174
+ // Sanitize for HTML attribute
175
+ const safeFilename = decodedFilename.replace(/[<>"'&]/g, '');
176
+ const displayName = linkText.trim() || safeFilename;
177
+
178
+ // Return secure placeholder div instead of actual link
179
+ return `<div class="pdf-secure-placeholder" data-filename="${safeFilename}">
180
+ <svg viewBox="0 0 24 24" style="width:20px;height:20px;fill:#e81224;vertical-align:middle;margin-right:8px;">
181
+ <path d="M14 2H6c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V8l-6-6zm4 18H6V4h7v5h5v11z"/>
182
+ </svg>
183
+ <span>${displayName}</span>
184
+ </div>`;
185
+ });
186
+
187
+ return data;
188
+ };
189
+
118
190
  module.exports = plugin;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodebb-plugin-pdf-secure",
3
- "version": "1.2.5",
3
+ "version": "1.2.7",
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,14 @@
14
14
  {
15
15
  "hook": "filter:admin.header.build",
16
16
  "method": "addAdminNavigation"
17
+ },
18
+ {
19
+ "hook": "filter:meta.getMetaTags",
20
+ "method": "filterMetaTags"
21
+ },
22
+ {
23
+ "hook": "filter:parse.post",
24
+ "method": "transformPdfLinks"
17
25
  }
18
26
  ],
19
27
  "staticDirs": {
@@ -2,6 +2,26 @@
2
2
 
3
3
  // Main plugin logic - PDF links become inline embedded viewers with lazy loading + queue
4
4
  (async function () {
5
+ // ============================================
6
+ // PDF.js PRELOAD - Cache CDN assets before iframe loads
7
+ // ============================================
8
+ (function preloadPdfJs() {
9
+ const preloads = [
10
+ { href: 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js', as: 'script' },
11
+ { href: 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf_viewer.min.css', as: 'style' }
12
+ ];
13
+ preloads.forEach(({ href, as }) => {
14
+ if (!document.querySelector(`link[href="${href}"]`)) {
15
+ const link = document.createElement('link');
16
+ link.rel = 'preload';
17
+ link.href = href;
18
+ link.as = as;
19
+ link.crossOrigin = 'anonymous';
20
+ document.head.appendChild(link);
21
+ }
22
+ });
23
+ })();
24
+
5
25
  // Loading queue - only load one PDF at a time
6
26
  const loadQueue = [];
7
27
  let isLoading = false;
@@ -9,6 +29,9 @@
9
29
 
10
30
  // Listen for postMessage from iframe when PDF is fully rendered
11
31
  window.addEventListener('message', function (event) {
32
+ // Security: Only accept messages from same origin
33
+ if (event.origin !== window.location.origin) return;
34
+
12
35
  if (event.data && event.data.type === 'pdf-secure-ready') {
13
36
  console.log('[PDF-Secure] Queue: PDF ready -', event.data.filename);
14
37
  if (currentResolver) {
@@ -59,8 +82,20 @@
59
82
  var postContents = document.querySelectorAll('[component="post/content"]');
60
83
 
61
84
  postContents.forEach(function (content) {
62
- var pdfLinks = content.querySelectorAll('a[href$=".pdf"], a[href$=".PDF"]');
85
+ // NEW: Detect server-rendered secure placeholders (hides URL from source)
86
+ var placeholders = content.querySelectorAll('.pdf-secure-placeholder');
87
+ placeholders.forEach(function (placeholder) {
88
+ if (placeholder.dataset.pdfSecureProcessed) return;
89
+ placeholder.dataset.pdfSecureProcessed = 'true';
63
90
 
91
+ var filename = placeholder.dataset.filename;
92
+ var displayName = placeholder.querySelector('span')?.textContent || filename;
93
+
94
+ createPdfViewer(placeholder, filename, displayName);
95
+ });
96
+
97
+ // FALLBACK: Detect old-style PDF links (for backwards compatibility)
98
+ var pdfLinks = content.querySelectorAll('a[href$=".pdf"], a[href$=".PDF"]');
64
99
  pdfLinks.forEach(function (link) {
65
100
  if (link.dataset.pdfSecure) return;
66
101
  link.dataset.pdfSecure = 'true';
@@ -68,112 +103,119 @@
68
103
  var href = link.getAttribute('href');
69
104
  var parts = href.split('/');
70
105
  var filename = parts[parts.length - 1];
106
+ var displayName = link.textContent || filename;
71
107
 
72
- // Create container
73
- var container = document.createElement('div');
74
- container.className = 'pdf-secure-embed';
75
- container.style.cssText = 'margin:16px 0;border-radius:12px;overflow:hidden;background:#1f1f1f;border:1px solid rgba(255,255,255,0.1);box-shadow:0 4px 20px rgba(0,0,0,0.25);';
76
-
77
- // Header
78
- var header = document.createElement('div');
79
- header.className = 'pdf-secure-embed-header';
80
- header.style.cssText = 'display:flex;align-items:center;justify-content:space-between;padding:10px 16px;background:linear-gradient(135deg,#2d2d2d 0%,#252525 100%);border-bottom:1px solid rgba(255,255,255,0.08);';
81
-
82
- var title = document.createElement('div');
83
- title.className = 'pdf-secure-embed-title';
84
- title.style.cssText = 'display:flex;align-items:center;gap:10px;color:#fff;font-size:14px;font-weight:500;';
85
-
86
- var icon = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
87
- icon.setAttribute('viewBox', '0 0 24 24');
88
- icon.style.cssText = 'width:20px;height:20px;min-width:20px;max-width:20px;fill:#e81224;flex-shrink:0;';
89
- icon.innerHTML = '<path d="M14 2H6c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V8l-6-6zm4 18H6V4h7v5h5v11z"/>';
90
-
91
- var nameSpan = document.createElement('span');
92
- nameSpan.style.cssText = 'white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:400px;';
93
- nameSpan.textContent = decodeURIComponent(link.textContent || filename);
94
-
95
- title.appendChild(icon);
96
- title.appendChild(nameSpan);
97
-
98
- // Actions
99
- var actions = document.createElement('div');
100
- actions.style.cssText = 'display:flex;gap:8px;';
101
-
102
- var fullscreenBtn = document.createElement('button');
103
- fullscreenBtn.className = 'pdf-secure-fullscreen-btn';
104
- fullscreenBtn.title = 'Tam Ekran';
105
- fullscreenBtn.style.cssText = 'display:flex;align-items:center;justify-content:center;width:32px;height:32px;background:rgba(255,255,255,0.08);border:none;border-radius:6px;cursor:pointer;';
106
-
107
- var fullscreenIcon = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
108
- fullscreenIcon.setAttribute('viewBox', '0 0 24 24');
109
- fullscreenIcon.style.cssText = 'width:18px;height:18px;min-width:18px;max-width:18px;fill:#fff;';
110
- fullscreenIcon.innerHTML = '<path d="M7 14H5v5h5v-2H7v-3zm-2-4h2V7h3V5H5v5zm12 7h-3v2h5v-5h-2v3zM14 5v2h3v3h2V5h-5z"/>';
111
- fullscreenBtn.appendChild(fullscreenIcon);
112
- actions.appendChild(fullscreenBtn);
113
-
114
- header.appendChild(title);
115
- header.appendChild(actions);
116
- container.appendChild(header);
117
-
118
- // Body with loading placeholder
119
- var iframeWrapper = document.createElement('div');
120
- iframeWrapper.className = 'pdf-secure-embed-body';
121
- iframeWrapper.style.cssText = 'position:relative;width:100%;height:600px;background:#525659;';
122
-
123
- // Loading placeholder - ALWAYS VISIBLE until PDF ready (z-index: 10)
124
- var loadingPlaceholder = document.createElement('div');
125
- loadingPlaceholder.className = 'pdf-loading-placeholder';
126
- loadingPlaceholder.style.cssText = 'position:absolute;top:0;left:0;right:0;bottom:0;display:flex;flex-direction:column;align-items:center;justify-content:center;background:#2d2d2d;color:#fff;gap:16px;z-index:10;transition:opacity 0.3s;';
127
- loadingPlaceholder.innerHTML = `
108
+ createPdfViewer(link, filename, displayName);
109
+ });
110
+ });
111
+ }
112
+
113
+ function createPdfViewer(targetElement, filename, displayName) {
114
+
115
+ // Create container
116
+ var container = document.createElement('div');
117
+ container.className = 'pdf-secure-embed';
118
+ container.style.cssText = 'margin:16px 0;border-radius:12px;overflow:hidden;background:#1f1f1f;border:1px solid rgba(255,255,255,0.1);box-shadow:0 4px 20px rgba(0,0,0,0.25);';
119
+
120
+ // Header
121
+ var header = document.createElement('div');
122
+ header.className = 'pdf-secure-embed-header';
123
+ header.style.cssText = 'display:flex;align-items:center;justify-content:space-between;padding:10px 16px;background:linear-gradient(135deg,#2d2d2d 0%,#252525 100%);border-bottom:1px solid rgba(255,255,255,0.08);';
124
+
125
+ var title = document.createElement('div');
126
+ title.className = 'pdf-secure-embed-title';
127
+ title.style.cssText = 'display:flex;align-items:center;gap:10px;color:#fff;font-size:14px;font-weight:500;';
128
+
129
+ var icon = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
130
+ icon.setAttribute('viewBox', '0 0 24 24');
131
+ icon.style.cssText = 'width:20px;height:20px;min-width:20px;max-width:20px;fill:#e81224;flex-shrink:0;';
132
+ icon.innerHTML = '<path d="M14 2H6c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V8l-6-6zm4 18H6V4h7v5h5v11z"/>';
133
+
134
+ var nameSpan = document.createElement('span');
135
+ nameSpan.style.cssText = 'white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:400px;';
136
+ try { nameSpan.textContent = decodeURIComponent(displayName); }
137
+ catch (e) { nameSpan.textContent = displayName; }
138
+
139
+ title.appendChild(icon);
140
+ title.appendChild(nameSpan);
141
+
142
+ // Actions
143
+ var actions = document.createElement('div');
144
+ actions.style.cssText = 'display:flex;gap:8px;';
145
+
146
+ var fullscreenBtn = document.createElement('button');
147
+ fullscreenBtn.className = 'pdf-secure-fullscreen-btn';
148
+ fullscreenBtn.title = 'Tam Ekran';
149
+ fullscreenBtn.style.cssText = 'display:flex;align-items:center;justify-content:center;width:32px;height:32px;background:rgba(255,255,255,0.08);border:none;border-radius:6px;cursor:pointer;';
150
+
151
+ var fullscreenIcon = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
152
+ fullscreenIcon.setAttribute('viewBox', '0 0 24 24');
153
+ fullscreenIcon.style.cssText = 'width:18px;height:18px;min-width:18px;max-width:18px;fill:#fff;';
154
+ fullscreenIcon.innerHTML = '<path d="M7 14H5v5h5v-2H7v-3zm-2-4h2V7h3V5H5v5zm12 7h-3v2h5v-5h-2v3zM14 5v2h3v3h2V5h-5z"/>';
155
+ fullscreenBtn.appendChild(fullscreenIcon);
156
+ actions.appendChild(fullscreenBtn);
157
+
158
+ header.appendChild(title);
159
+ header.appendChild(actions);
160
+ container.appendChild(header);
161
+
162
+ // Body with loading placeholder
163
+ var iframeWrapper = document.createElement('div');
164
+ iframeWrapper.className = 'pdf-secure-embed-body';
165
+ iframeWrapper.style.cssText = 'position:relative;width:100%;height:600px;background:#525659;';
166
+
167
+ // Loading placeholder - ALWAYS VISIBLE until PDF ready (z-index: 10)
168
+ var loadingPlaceholder = document.createElement('div');
169
+ loadingPlaceholder.className = 'pdf-loading-placeholder';
170
+ loadingPlaceholder.style.cssText = 'position:absolute;top:0;left:0;right:0;bottom:0;display:flex;flex-direction:column;align-items:center;justify-content:center;background:#2d2d2d;color:#fff;gap:16px;z-index:10;transition:opacity 0.3s;';
171
+ loadingPlaceholder.innerHTML = `
128
172
  <svg viewBox="0 0 24 24" style="width:48px;height:48px;fill:#555;">
129
173
  <path d="M14 2H6c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V8l-6-6zm4 18H6V4h7v5h5v11z"/>
130
174
  </svg>
131
175
  <div class="pdf-loading-text" style="font-size:14px;color:#a0a0a0;">Sırada bekliyor...</div>
132
176
  <style>@keyframes spin{from{transform:rotate(0deg)}to{transform:rotate(360deg)}}</style>
133
177
  `;
134
- iframeWrapper.appendChild(loadingPlaceholder);
178
+ iframeWrapper.appendChild(loadingPlaceholder);
135
179
 
136
- container.appendChild(iframeWrapper);
180
+ container.appendChild(iframeWrapper);
137
181
 
138
- // Fullscreen handler
139
- fullscreenBtn.addEventListener('click', function () {
140
- var iframe = iframeWrapper.querySelector('iframe');
141
- if (iframe) {
142
- if (iframe.requestFullscreen) iframe.requestFullscreen();
143
- else if (iframe.webkitRequestFullscreen) iframe.webkitRequestFullscreen();
182
+ // Fullscreen handler
183
+ fullscreenBtn.addEventListener('click', function () {
184
+ var iframe = iframeWrapper.querySelector('iframe');
185
+ if (iframe) {
186
+ if (iframe.requestFullscreen) iframe.requestFullscreen();
187
+ else if (iframe.webkitRequestFullscreen) iframe.webkitRequestFullscreen();
188
+ }
189
+ });
190
+
191
+ targetElement.replaceWith(container);
192
+
193
+ // LAZY LOADING with Intersection Observer + Queue
194
+ var observer = new IntersectionObserver(function (entries) {
195
+ entries.forEach(function (entry) {
196
+ if (entry.isIntersecting) {
197
+ // Update placeholder to show loading state
198
+ var textEl = loadingPlaceholder.querySelector('.pdf-loading-text');
199
+ if (textEl) textEl.textContent = 'PDF Yükleniyor...';
200
+
201
+ var svgEl = loadingPlaceholder.querySelector('svg');
202
+ if (svgEl) {
203
+ svgEl.style.fill = '#0078d4';
204
+ svgEl.style.animation = 'spin 1s linear infinite';
205
+ svgEl.innerHTML = '<path d="M12 4V2A10 10 0 0 0 2 12h2a8 8 0 0 1 8-8z"/>';
144
206
  }
145
- });
146
-
147
- link.replaceWith(container);
148
-
149
- // LAZY LOADING with Intersection Observer + Queue
150
- var observer = new IntersectionObserver(function (entries) {
151
- entries.forEach(function (entry) {
152
- if (entry.isIntersecting) {
153
- // Update placeholder to show loading state
154
- var textEl = loadingPlaceholder.querySelector('.pdf-loading-text');
155
- if (textEl) textEl.textContent = 'PDF Yükleniyor...';
156
-
157
- var svgEl = loadingPlaceholder.querySelector('svg');
158
- if (svgEl) {
159
- svgEl.style.fill = '#0078d4';
160
- svgEl.style.animation = 'spin 1s linear infinite';
161
- svgEl.innerHTML = '<path d="M12 4V2A10 10 0 0 0 2 12h2a8 8 0 0 1 8-8z"/>';
162
- }
163
-
164
- // Add to queue
165
- queuePdfLoad(iframeWrapper, filename, loadingPlaceholder);
166
- observer.disconnect();
167
- }
168
- });
169
- }, {
170
- rootMargin: '200px',
171
- threshold: 0
172
- });
173
207
 
174
- observer.observe(container);
208
+ // Add to queue
209
+ queuePdfLoad(iframeWrapper, filename, loadingPlaceholder);
210
+ observer.disconnect();
211
+ }
175
212
  });
213
+ }, {
214
+ rootMargin: '200px',
215
+ threshold: 0
176
216
  });
217
+
218
+ observer.observe(container);
177
219
  }
178
220
 
179
221
  function loadPdfIframe(wrapper, filename, placeholder) {
@@ -1334,6 +1334,34 @@
1334
1334
  (function () {
1335
1335
  'use strict';
1336
1336
 
1337
+ // ============================================
1338
+ // CANVAS EXPORT PROTECTION
1339
+ // Block toDataURL/toBlob for PDF render canvas only
1340
+ // Allows: thumbnails, annotations, other canvases
1341
+ // ============================================
1342
+ const originalToDataURL = HTMLCanvasElement.prototype.toDataURL;
1343
+ const originalToBlob = HTMLCanvasElement.prototype.toBlob;
1344
+
1345
+ HTMLCanvasElement.prototype.toDataURL = function () {
1346
+ // Block only main PDF page canvases (inside .page elements in #viewerContainer)
1347
+ if (this.closest && this.closest('.page') && this.closest('#viewerContainer')) {
1348
+ console.warn('[Security] Canvas toDataURL blocked for PDF page');
1349
+ return 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=='; // 1x1 transparent
1350
+ }
1351
+ return originalToDataURL.apply(this, arguments);
1352
+ };
1353
+
1354
+ HTMLCanvasElement.prototype.toBlob = function (callback) {
1355
+ // Block only main PDF page canvases
1356
+ if (this.closest && this.closest('.page') && this.closest('#viewerContainer')) {
1357
+ console.warn('[Security] Canvas toBlob blocked for PDF page');
1358
+ // Return empty blob
1359
+ if (callback) callback(new Blob([], { type: 'image/png' }));
1360
+ return;
1361
+ }
1362
+ return originalToBlob.apply(this, arguments);
1363
+ };
1364
+
1337
1365
  pdfjsLib.GlobalWorkerOptions.workerSrc = '';
1338
1366
 
1339
1367
  // State - now private, not accessible from console
@@ -1520,6 +1548,10 @@
1520
1548
  // Security: Delete config containing sensitive data (nonce, key)
1521
1549
  delete window.PDF_SECURE_CONFIG;
1522
1550
 
1551
+ // Security: Remove PDF.js globals to prevent console manipulation
1552
+ delete window.pdfjsLib;
1553
+ delete window.pdfjsViewer;
1554
+
1523
1555
  // Security: Block dangerous PDF.js methods
1524
1556
  if (pdfDoc) {
1525
1557
  pdfDoc.getData = function () {
@@ -1536,6 +1568,17 @@
1536
1568
 
1537
1569
  } catch (err) {
1538
1570
  console.error('[PDF-Secure] Auto-load error:', err);
1571
+
1572
+ // Notify parent of error (prevents 60s queue hang)
1573
+ if (window.parent && window.parent !== window) {
1574
+ const config = window.PDF_SECURE_CONFIG || {};
1575
+ window.parent.postMessage({
1576
+ type: 'pdf-secure-ready',
1577
+ filename: config.filename,
1578
+ error: err.message
1579
+ }, '*');
1580
+ }
1581
+
1539
1582
  if (dropzone) {
1540
1583
  dropzone.innerHTML = `
1541
1584
  <svg viewBox="0 0 24 24" style="fill: #e81224;">