ultimate-jekyll-manager 1.1.0 → 1.1.2

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/CHANGELOG.md CHANGED
@@ -15,6 +15,30 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
15
15
  - `Security` in case of vulnerabilities.
16
16
 
17
17
  ---
18
+ ## [1.1.2] - 2026-04-08
19
+ ### Fixed
20
+ - Fix AdSense minimum width error in dashboard sidebar by increasing sidebar width from 280px to 282px (content area now meets 250px minimum)
21
+
22
+ ### Changed
23
+ - Update dependencies: fast-xml-parser, postcss, webpack, wonderful-fetch, prepare-package
24
+
25
+ ---
26
+ ## [1.1.1] - 2026-04-06
27
+ ### Security
28
+ - Fix open redirect via `authReturnUrl` URL parameter in core/auth.js — now validated with `isValidRedirectUrl()`
29
+ - Fix cross-origin redirect via unvalidated postMessage in vert.js — added origin allowlist
30
+ - Replace `new Function()` code execution in redirect.js with safe named modifier lookup
31
+ - Sanitize markdown-it output with DOMPurify in campaign-preview.js (newsletter-safe tag allowlist)
32
+ - Validate OAuth redirect URL scheme in connections.js
33
+ - Escape `classes` parameter in prerendered-icons.js to prevent attribute breakout
34
+ - Defense-in-depth: escape `formatDate()` outputs in security.js, team.js, referrals.js
35
+ - Defense-in-depth: escape cancel/refund reason strings in billing.js, refund.js
36
+ - Defense-in-depth: escape `submittingText` in form-manager.js spinner
37
+ - Document redirect validation, postMessage origin checks, eval prohibition, and DOMPurify rules in CLAUDE.md
38
+
39
+ ### Added
40
+ - `dompurify` dependency for HTML sanitization
41
+
18
42
  ## [1.1.0] - 2026-04-06
19
43
  ### Added
20
44
  - `payment-config.js` shared library for reading payment data from build-time config
package/CLAUDE.md CHANGED
@@ -429,6 +429,69 @@ $el.textContent = data.message; // Safe — no escaping needed
429
429
 
430
430
  Only use `innerHTML` when you need actual HTML structure (tags, classes, etc.), and escape every dynamic value in it.
431
431
 
432
+ ### Even "Safe" Values Must Be Escaped
433
+ Even values that *seem* safe (like `Date.toLocaleDateString()` output, numeric calculations, or hardcoded config strings) MUST be escaped when inserted via `innerHTML`. This is defense-in-depth — if the data source ever changes, the escaping is already in place.
434
+
435
+ ```javascript
436
+ // ✅ CORRECT — escape even "safe" values in innerHTML
437
+ $el.innerHTML = `<small>${webManager.utilities().escapeHTML(formatDate(timestamp))}</small>`;
438
+ $el.innerHTML = `<span>${webManager.utilities().escapeHTML(reason)}</span>`;
439
+
440
+ // ❌ WRONG — assuming the value is safe because it's from a date formatter
441
+ $el.innerHTML = `<small>${formatDate(timestamp)}</small>`;
442
+ ```
443
+
444
+ ### Redirects Must Be Validated
445
+ Never redirect to a URL from untrusted sources without validation:
446
+
447
+ ```javascript
448
+ // ✅ CORRECT — validate before redirect
449
+ const url = urlParams.get('returnUrl');
450
+ if (url && webManager.isValidRedirectUrl(url)) {
451
+ window.location.href = url;
452
+ }
453
+
454
+ // ✅ CORRECT — validate API response URLs have safe scheme
455
+ if (response.url && /^https?:\/\//i.test(response.url)) {
456
+ window.location.href = response.url;
457
+ }
458
+
459
+ // ❌ WRONG — redirect to unvalidated input
460
+ window.location.href = urlParams.get('returnUrl');
461
+ ```
462
+
463
+ ### postMessage Handlers Must Check Origin
464
+ Always validate `event.origin` when handling `window.addEventListener('message', ...)`:
465
+
466
+ ```javascript
467
+ // ✅ CORRECT
468
+ window.addEventListener('message', (event) => {
469
+ if (event.origin !== window.location.origin && event.origin !== 'https://trusted-domain.com') {
470
+ return;
471
+ }
472
+ // handle message
473
+ });
474
+
475
+ // ❌ WRONG — any origin can send messages
476
+ window.addEventListener('message', (event) => {
477
+ window.location.href = event.data.url; // attacker-controlled redirect
478
+ });
479
+ ```
480
+
481
+ ### Never Use eval() or new Function()
482
+ Do not use `eval()`, `new Function()`, `setTimeout(string)`, or `setInterval(string)`. These execute arbitrary code and violate CSP policies.
483
+
484
+ ### Sanitize Markdown/Rich Text Output
485
+ When rendering user-authored markdown or rich text, use DOMPurify to sanitize the output:
486
+
487
+ ```javascript
488
+ import DOMPurify from 'dompurify';
489
+ const safeHTML = DOMPurify.sanitize(md.render(userContent), {
490
+ ALLOWED_TAGS: ['h1', 'h2', 'h3', 'p', 'br', 'a', 'b', 'strong', 'i', 'em', 'ul', 'ol', 'li', 'img', 'code', 'pre'],
491
+ ALLOWED_ATTR: ['href', 'src', 'alt', 'title', 'class', 'target', 'rel'],
492
+ });
493
+ ```
494
+
432
495
  ### Do NOT Escape Values Passed to textContent-Based APIs
433
496
  `showNotification()`, `formManager.showSuccess()`, `formManager.showError()`, and `textContent` assignments use safe text insertion internally. Pre-escaping these causes double-encoding (e.g., `We'll` displays as `We&#039;ll`).
434
497
 
@@ -1108,12 +1171,24 @@ const response = await authorizedFetch(url, options);
1108
1171
  - No need to manually call `webManager.auth().getIdToken()`
1109
1172
  - Automatic token injection as Authorization Bearer header
1110
1173
  - Centralized authentication handling
1174
+ - Automatic usage sync: extracts `bm-properties` header from every response and updates `webManager.bindings()` with fresh usage data under the `usage` key
1175
+
1176
+ **Options pass-through:** All `wonderful-fetch` options (`response`, `output`, `body`, `timeout`, etc.) are passed through untouched. Internally, `authorizedFetch` uses `output: 'complete'` to read response headers, then returns only the body by default. If the caller passes `output: 'complete'`, they get the full `{ status, headers, body }` response.
1177
+
1178
+ **Automatic Usage Binding Sync:**
1179
+
1180
+ After every successful response, `authorizedFetch` reads the `bm-properties` header and updates the `usage` bindings key:
1181
+ ```javascript
1182
+ // After an API call, bindings are automatically updated:
1183
+ // usage.credits = { monthly: 5, daily: 2, limit: 100 }
1184
+ ```
1185
+ This means any `data-wm-bind` elements bound to `usage.*` paths are automatically kept in sync without any manual work. See "Usage Bindings" below.
1111
1186
 
1112
1187
  **⚠️ IMPORTANT: Auth State Requirement**
1113
1188
 
1114
1189
  `authorizedFetch` requires Firebase Auth to have determined the current user's authentication state before being called. On fresh page loads (e.g., OAuth callback pages, deep links), Firebase Auth needs time to restore the session from IndexedDB/localStorage.
1115
1190
 
1116
- **If called before auth state is determined, it will throw: `"No authenticated user found"`**
1191
+ **If called before auth state is determined, it will warn: `"No authenticated user found"`**
1117
1192
 
1118
1193
  **Solution:** Wait for auth state before calling `authorizedFetch`:
1119
1194
 
@@ -1136,6 +1211,40 @@ webManager.auth().listen({ once: true }, async () => {
1136
1211
 
1137
1212
  **Reference:** `src/assets/js/libs/authorized-fetch.js`
1138
1213
 
1214
+ #### Usage Bindings
1215
+
1216
+ Usage data is available in the `usage` bindings key. It is populated from two sources:
1217
+
1218
+ 1. **On page load (auth settle):** `web-manager` reads `account.usage` from Firestore and resolves plan limits from `config.payment.plans`, then sets `usage` bindings with the merged data.
1219
+ 2. **After API calls:** `authorizedFetch` reads the `bm-properties` response header and merges fresh usage counters + limits into the existing `usage` bindings.
1220
+
1221
+ **Bindings structure:**
1222
+ ```javascript
1223
+ // usage.credits = { monthly: 5, daily: 2, limit: 100 }
1224
+ // usage.requests = { monthly: 20, limit: 500 }
1225
+ ```
1226
+
1227
+ **HTML usage:**
1228
+ ```html
1229
+ <!-- Show usage counter: "5/100" -->
1230
+ <span data-wm-bind="@show usage.credits">
1231
+ <span data-wm-bind="usage.credits.monthly">–</span>/<span data-wm-bind="usage.credits.limit">–</span>
1232
+ </span>
1233
+ ```
1234
+
1235
+ **Config requirement:** Plan limits must be defined in `_config.yml` under `web_manager.payment.plans`:
1236
+ ```yaml
1237
+ web_manager:
1238
+ payment:
1239
+ plans:
1240
+ - id: basic
1241
+ limits:
1242
+ credits: 100
1243
+ - id: premium
1244
+ limits:
1245
+ credits: 500
1246
+ ```
1247
+
1139
1248
  #### Payment Config Library
1140
1249
 
1141
1250
  Reads payment configuration (products, processors, prices, limits) from `webManager.config.payment` — populated from `_config.yml` at build time. **Do NOT fetch `/backend-manager/brand` to get payment data.** It's already available instantly via this library.
@@ -83,8 +83,8 @@ button *, a * {
83
83
  // Sidebar
84
84
  // ============================================
85
85
  .sidebar {
86
- width: 280px;
87
- min-width: 280px;
86
+ width: 282px;
87
+ min-width: 282px;
88
88
  }
89
89
 
90
90
  .sidebar-logo {
@@ -38,7 +38,8 @@ export default function () {
38
38
  webManager.auth().listen({}, async (state) => {
39
39
  const user = state.user;
40
40
  const url = new URL(window.location.href);
41
- const authReturnUrl = url.searchParams.get('authReturnUrl');
41
+ const authReturnUrlRaw = url.searchParams.get('authReturnUrl');
42
+ const authReturnUrl = authReturnUrlRaw && webManager.isValidRedirectUrl(authReturnUrlRaw) ? authReturnUrlRaw : null;
42
43
  const authSignout = url.searchParams.get('authSignout');
43
44
 
44
45
  // Log
@@ -803,7 +803,7 @@ export class FormManager {
803
803
  if (show) {
804
804
  // Store original content
805
805
  $btn._originalHTML = $btn.innerHTML;
806
- $btn.innerHTML = `<span class="spinner-border spinner-border-sm me-2"></span>${this.config.submittingText}`;
806
+ $btn.innerHTML = `<span class="spinner-border spinner-border-sm me-2"></span>${webManager.utilities().escapeHTML(this.config.submittingText)}`;
807
807
  } else if ($btn._originalHTML) {
808
808
  $btn.innerHTML = $btn._originalHTML;
809
809
  }
@@ -2,6 +2,7 @@
2
2
  * Prerendered Icons Library
3
3
  * Retrieves pre-rendered icon HTML from the frontmatter icon system
4
4
  */
5
+ import webManager from 'web-manager';
5
6
 
6
7
  /**
7
8
  * Get pre-rendered icon by name from frontmatter icon system.
@@ -31,5 +32,5 @@ export function getPrerenderedIcon(iconName, classes) {
31
32
  return $iconTemplate.innerHTML;
32
33
  }
33
34
 
34
- return $iconTemplate.innerHTML.replace('class="fa"', `class="fa ${classes}"`);
35
+ return $iconTemplate.innerHTML.replace('class="fa"', `class="fa ${webManager.utilities().escapeHTML(classes)}"`);
35
36
  }
@@ -34,15 +34,23 @@ const performRedirect = () => {
34
34
  const currentUrl = new URL(window.location.href);
35
35
  const siteUrl = new URL(config.siteUrl);
36
36
 
37
- // Parse modifier function
37
+ // Named modifier lookup (safe alternative to eval/new Function)
38
+ const MODIFIERS = {
39
+ 'search-cse': (url) => {
40
+ const q = url.searchParams.get('q');
41
+ url.searchParams.set('q', 'site:' + window.location.origin + ' ' + q);
42
+ return url;
43
+ },
44
+ };
45
+
46
+ // Resolve modifier by name
38
47
  let modifierFunction = (url) => url;
39
48
  if (config.modifier && config.modifier !== '""' && config.modifier !== '') {
40
- try {
41
- // Safely evaluate modifier function
42
- modifierFunction = new Function('url', `return (${config.modifier})(url)`);
43
- } catch (error) {
44
- console.warn('[Redirect] Failed to parse modifier function:', error);
45
- console.warn('[Redirect] Modifier string:', config.modifier);
49
+ const modifierName = config.modifier.trim();
50
+ if (MODIFIERS[modifierName]) {
51
+ modifierFunction = MODIFIERS[modifierName];
52
+ } else {
53
+ console.warn('[Redirect] Unknown modifier:', modifierName);
46
54
  }
47
55
  }
48
56
 
@@ -90,8 +90,12 @@ const setupMessageHandler = () => {
90
90
  // Flag as set up
91
91
  window.__ujVertMessageHandlerSetup = true;
92
92
 
93
- // Listen for messages from iframes
93
+ // Listen for messages from vert iframes (validate origin)
94
94
  window.addEventListener('message', (event) => {
95
+ if (event.origin !== window.location.origin && event.origin !== 'https://promo-server.itwcreativeworks.com') {
96
+ return;
97
+ }
98
+
95
99
  const message = event.data || {};
96
100
  const command = message.command || '';
97
101
  const payload = message.payload || {};
@@ -320,8 +320,8 @@ function populateCancelReasons() {
320
320
 
321
321
  $container.innerHTML = shuffled.map((reason, i) => `
322
322
  <div class="form-check mb-2">
323
- <input class="form-check-input" type="radio" name="cancel_reason" id="cancel-reason-${i}" value="${reason}">
324
- <label class="form-check-label" for="cancel-reason-${i}">${reason}</label>
323
+ <input class="form-check-input" type="radio" name="cancel_reason" id="cancel-reason-${i}" value="${webManager.utilities().escapeHTML(reason)}">
324
+ <label class="form-check-label" for="cancel-reason-${i}">${webManager.utilities().escapeHTML(reason)}</label>
325
325
  </div>
326
326
  `).join('');
327
327
  }
@@ -281,7 +281,7 @@ async function handleConnect(providerId) {
281
281
  tries: 2,
282
282
  });
283
283
 
284
- if (response.url) {
284
+ if (response.url && /^https?:\/\//i.test(response.url)) {
285
285
  window.location.href = response.url;
286
286
  } else {
287
287
  throw new Error(response.message || 'Failed to get authorization URL');
@@ -110,12 +110,12 @@ function updateReferralsList(referrals) {
110
110
  <span class="badge bg-secondary me-2">#${sortedReferrals.length - index}</span>
111
111
  <div>
112
112
  <strong class="font-monospace small">${webManager.utilities().escapeHTML(referral.uid || 'Unknown User')}</strong>
113
- <div class="text-muted small">${dateStr}${timeStr ? ` at ${timeStr}` : ''}</div>
113
+ <div class="text-muted small">${webManager.utilities().escapeHTML(dateStr)}${timeStr ? ` at ${webManager.utilities().escapeHTML(timeStr)}` : ''}</div>
114
114
  </div>
115
115
  </div>
116
116
  </div>
117
117
  <div class="text-end">
118
- ${getTimeSince(timestamp)}
118
+ <small class="text-muted">${webManager.utilities().escapeHTML(getTimeSince(timestamp))}</small>
119
119
  </div>
120
120
  </div>
121
121
  </div>
@@ -146,48 +146,40 @@ function formatTime(date) {
146
146
 
147
147
  // Get time since string
148
148
  function getTimeSince(timestamp) {
149
- if (!timestamp) return '<small class="text-muted">Unknown</small>';
149
+ if (!timestamp) return 'Unknown';
150
150
 
151
151
  const now = Date.now();
152
152
  const diff = now - timestamp;
153
153
 
154
- // Less than 1 minute
155
- if (diff < 60000) {
156
- return '<small class="text-success">Just now</small>';
157
- }
154
+ if (diff < 60000) return 'Just now';
158
155
 
159
- // Less than 1 hour
160
156
  if (diff < 3600000) {
161
157
  const minutes = Math.floor(diff / 60000);
162
- return `<small class="text-muted">${minutes} min${minutes > 1 ? 's' : ''} ago</small>`;
158
+ return `${minutes} min${minutes > 1 ? 's' : ''} ago`;
163
159
  }
164
160
 
165
- // Less than 24 hours
166
161
  if (diff < 86400000) {
167
162
  const hours = Math.floor(diff / 3600000);
168
- return `<small class="text-muted">${hours} hour${hours > 1 ? 's' : ''} ago</small>`;
163
+ return `${hours} hour${hours > 1 ? 's' : ''} ago`;
169
164
  }
170
165
 
171
- // Less than 7 days
172
166
  if (diff < 604800000) {
173
167
  const days = Math.floor(diff / 86400000);
174
- return `<small class="text-muted">${days} day${days > 1 ? 's' : ''} ago</small>`;
168
+ return `${days} day${days > 1 ? 's' : ''} ago`;
175
169
  }
176
170
 
177
- // Less than 30 days
178
171
  if (diff < 2592000000) {
179
172
  const weeks = Math.floor(diff / 604800000);
180
- return `<small class="text-muted">${weeks} week${weeks > 1 ? 's' : ''} ago</small>`;
173
+ return `${weeks} week${weeks > 1 ? 's' : ''} ago`;
181
174
  }
182
175
 
183
- // More than 30 days
184
176
  const months = Math.floor(diff / 2592000000);
185
177
  if (months < 12) {
186
- return `<small class="text-muted">${months} month${months > 1 ? 's' : ''} ago</small>`;
178
+ return `${months} month${months > 1 ? 's' : ''} ago`;
187
179
  }
188
180
 
189
181
  const years = Math.floor(months / 12);
190
- return `<small class="text-muted">${years} year${years > 1 ? 's' : ''} ago</small>`;
182
+ return `${years} year${years > 1 ? 's' : ''} ago`;
191
183
  }
192
184
 
193
185
  // Setup button handlers
@@ -128,8 +128,8 @@ function populateRefundReasons() {
128
128
 
129
129
  $container.innerHTML = shuffled.map((reason, i) => `
130
130
  <div class="form-check mb-2">
131
- <input class="form-check-input" type="radio" name="refund_reason" id="refund-reason-${i}" value="${reason}" required>
132
- <label class="form-check-label" for="refund-reason-${i}">${reason}</label>
131
+ <input class="form-check-input" type="radio" name="refund_reason" id="refund-reason-${i}" value="${webManager.utilities().escapeHTML(reason)}" required>
132
+ <label class="form-check-label" for="refund-reason-${i}">${webManager.utilities().escapeHTML(reason)}</label>
133
133
  </div>
134
134
  `).join('');
135
135
  }
@@ -301,7 +301,7 @@ async function updateActiveSessions(account) {
301
301
  </div>
302
302
  </div>
303
303
  <div class="text-end">
304
- <small class="text-muted">${formatDate(session.timestamp || (session.timestampUNIX * 1000))}</small>
304
+ <small class="text-muted">${webManager.utilities().escapeHTML(formatDate(session.timestamp || (session.timestampUNIX * 1000)))}</small>
305
305
  ${session.isCurrent ? '<span class="badge bg-primary ms-2">Current</span>' : ''}
306
306
  </div>
307
307
  </div>
@@ -66,7 +66,7 @@ function updateInviteStatus(invites) {
66
66
  <div class="d-flex justify-content-between align-items-center">
67
67
  <div>
68
68
  <strong>${webManager.utilities().escapeHTML(invite.email)}</strong>
69
- <small class="text-muted d-block">Invited ${formatDate(invite.invitedAt)}</small>
69
+ <small class="text-muted d-block">Invited ${webManager.utilities().escapeHTML(formatDate(invite.invitedAt))}</small>
70
70
  </div>
71
71
  <div>
72
72
  <button class="btn btn-sm btn-outline-danger" data-action="cancel-invite" data-invite-id="${webManager.utilities().escapeHTML(invite.id)}">
@@ -26,7 +26,13 @@ async function renderEmailPreview(formData) {
26
26
  md = new MarkdownIt({ html: true, breaks: true, linkify: true });
27
27
  }
28
28
 
29
- const renderedContent = content ? md.render(content) : '<p class="text-muted">No content yet</p>';
29
+ const DOMPurify = (await import('dompurify')).default;
30
+ const renderedContent = content
31
+ ? DOMPurify.sanitize(md.render(content), {
32
+ ALLOWED_TAGS: ['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'p', 'br', 'hr', 'ul', 'ol', 'li', 'a', 'b', 'strong', 'i', 'em', 'u', 's', 'del', 'blockquote', 'pre', 'code', 'img', 'table', 'thead', 'tbody', 'tr', 'th', 'td', 'div', 'span', 'sup', 'sub'],
33
+ ALLOWED_ATTR: ['href', 'src', 'alt', 'title', 'width', 'height', 'class', 'style', 'target', 'rel'],
34
+ })
35
+ : '<p class="text-muted">No content yet</p>';
30
36
 
31
37
  return `
32
38
  <div class="email-preview">
@@ -6,13 +6,5 @@ permalink: /search/cse
6
6
  ### REGULAR PAGES ###
7
7
  redirect:
8
8
  url: "https://cse.google.com/cse?cx={{ page.resolved.advertising.cse.site-id }}&ie=UTF-8"
9
- modifier: "
10
- function (url) {
11
- var q = url.searchParams.get('q');
12
-
13
- url.searchParams.set('q', 'site:' + window.location.origin + ' ' + q);
14
-
15
- return url;
16
- }
17
- "
9
+ modifier: "search-cse"
18
10
  ---
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ultimate-jekyll-manager",
3
- "version": "1.1.0",
3
+ "version": "1.1.2",
4
4
  "description": "Ultimate Jekyll dependency manager",
5
5
  "main": "dist/index.js",
6
6
  "exports": {
@@ -78,8 +78,9 @@
78
78
  "chart.js": "^4.5.1",
79
79
  "cheerio": "^1.2.0",
80
80
  "chrome-launcher": "^1.2.1",
81
+ "dompurify": "^3.3.3",
81
82
  "dotenv": "^17.4.1",
82
- "fast-xml-parser": "^5.5.10",
83
+ "fast-xml-parser": "^5.5.11",
83
84
  "fs-jetpack": "^5.1.0",
84
85
  "glob": "^13.0.6",
85
86
  "gulp-clean-css": "^4.3.0",
@@ -99,14 +100,14 @@
99
100
  "minimatch": "^10.2.5",
100
101
  "node-powertools": "^3.0.0",
101
102
  "npm-api": "^1.0.1",
102
- "postcss": "^8.5.8",
103
+ "postcss": "^8.5.9",
103
104
  "prettier": "^3.8.1",
104
105
  "sass": "^1.99.0",
105
106
  "spellchecker": "^3.7.1",
106
107
  "through2": "^4.0.2",
107
108
  "web-manager": "^4.1.37",
108
- "webpack": "^5.105.4",
109
- "wonderful-fetch": "^2.0.4",
109
+ "webpack": "^5.106.0",
110
+ "wonderful-fetch": "^2.0.5",
110
111
  "wonderful-version": "^1.3.2",
111
112
  "yargs": "^18.0.0"
112
113
  },
@@ -114,6 +115,6 @@
114
115
  "gulp": "^5.0.1"
115
116
  },
116
117
  "devDependencies": {
117
- "prepare-package": "^2.0.7"
118
+ "prepare-package": "^2.0.8"
118
119
  }
119
120
  }