@bquery/bquery 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.
@@ -261,11 +261,13 @@ const DEFAULT_ALLOWED_ATTRIBUTES = new Set([
261
261
  'lang',
262
262
  'loading',
263
263
  'name',
264
+ 'rel',
264
265
  'role',
265
266
  'src',
266
267
  'srcset',
267
268
  'style',
268
269
  'tabindex',
270
+ 'target',
269
271
  'title',
270
272
  'type',
271
273
  'width',
@@ -351,6 +353,54 @@ const isSafeUrl = (value: string): boolean => {
351
353
  return true;
352
354
  };
353
355
 
356
+ /**
357
+ * Check if a URL is external (different origin).
358
+ * @internal
359
+ */
360
+ const isExternalUrl = (url: string): boolean => {
361
+ try {
362
+ // Normalize URL by trimming whitespace
363
+ const trimmedUrl = url.trim();
364
+
365
+ // Protocol-relative URLs (//example.com) are always external.
366
+ // CRITICAL: This check must run before the relative-URL check below;
367
+ // otherwise, a protocol-relative URL like "//evil.com" would be treated
368
+ // as a non-http(s) relative URL and incorrectly classified as same-origin.
369
+ // Handling them up front guarantees correct security classification.
370
+ if (trimmedUrl.startsWith('//')) {
371
+ return true;
372
+ }
373
+
374
+ // Normalize URL for case-insensitive protocol checks
375
+ const lowerUrl = trimmedUrl.toLowerCase();
376
+
377
+ // Check for non-http(s) protocols which are considered external/special
378
+ // (mailto:, tel:, ftp:, etc.)
379
+ const hasProtocol = /^[a-z][a-z0-9+.-]*:/i.test(trimmedUrl);
380
+ if (hasProtocol && !lowerUrl.startsWith('http://') && !lowerUrl.startsWith('https://')) {
381
+ // These are special protocols, not traditional "external" links
382
+ // but we treat them as external for security consistency
383
+ return true;
384
+ }
385
+
386
+ // Relative URLs are not external
387
+ if (!lowerUrl.startsWith('http://') && !lowerUrl.startsWith('https://')) {
388
+ return false;
389
+ }
390
+
391
+ // In non-browser environments (e.g., Node.js), treat all absolute URLs as external
392
+ if (typeof window === 'undefined' || !window.location) {
393
+ return true;
394
+ }
395
+
396
+ const urlObj = new URL(trimmedUrl, window.location.href);
397
+ return urlObj.origin !== window.location.origin;
398
+ } catch {
399
+ // If URL parsing fails, treat as potentially external for safety
400
+ return true;
401
+ }
402
+ };
403
+
354
404
  /**
355
405
  * Core sanitization logic (without Trusted Types wrapper).
356
406
  * @internal
@@ -433,6 +483,28 @@ const sanitizeHtmlCore = (html: string, options: SanitizeOptions = {}): string =
433
483
  for (const attrName of attrsToRemove) {
434
484
  el.removeAttribute(attrName);
435
485
  }
486
+
487
+ // Add rel="noopener noreferrer" to external links for security
488
+ if (tagName === 'a') {
489
+ const href = el.getAttribute('href');
490
+ const target = el.getAttribute('target');
491
+ const hasTargetBlank = target?.toLowerCase() === '_blank';
492
+ const isExternal = href && isExternalUrl(href);
493
+
494
+ // Add security attributes to links opening in new window or external links
495
+ if (hasTargetBlank || isExternal) {
496
+ const existingRel = el.getAttribute('rel');
497
+ const relValues = new Set(
498
+ existingRel ? existingRel.split(/\s+/).filter(Boolean) : []
499
+ );
500
+
501
+ // Add noopener and noreferrer
502
+ relValues.add('noopener');
503
+ relValues.add('noreferrer');
504
+
505
+ el.setAttribute('rel', Array.from(relValues).join(' '));
506
+ }
507
+ }
436
508
  }
437
509
 
438
510
  // Remove disallowed elements