All files / lib/core/security urlPolicy.js

99.36% Statements 156/157
95.65% Branches 22/23
100% Functions 4/4
99.36% Lines 156/157

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 916x 916x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 505x 41x 40x 40x 40x 40x 41x 41x 505x 505x 505x 505x 505x 505x 505x 36x 36x 9x 9x 9x 36x 8x 8x 36x 3x 3x 16x 36x 505x 505x 505x 505x 505x 505x 505x 505x 505x 64x 59x 59x 5x 5x 5x 14x   14x 5x 5x 5x 64x  
/**
 * @file urlPolicy.js
 * @description Refuses dangerous URL schemes in attributes that navigate or load.
 *
 * ## The gap this closes
 *
 * Interpolation escapes correctly: `{{ value }}` in a quoted attribute cannot
 * break out of the quotes. What it cannot do is make the *value* safe, and for
 * a URL-bearing attribute the value is the whole attack:
 *
 * ```html
 * <a href="{{ link }}">…</a>      <!-- link = "javascript:steal()" -->
 * ```
 *
 * Nothing is escaped away, because nothing needs escaping — the string is a
 * perfectly well-formed attribute value that happens to execute when clicked.
 * Any application binding a user-supplied URL had a script-execution sink, and
 * Avenx shipped a full `Sanitizer` that was never applied on this path.
 *
 * ## The policy
 *
 * Only the scheme is judged, and only for attributes whose value is fetched or
 * navigated to. A relative URL, a fragment, a query, an absolute path and the
 * ordinary schemes all pass untouched; `javascript:`, `vbscript:` and `data:`
 * do not. `data:` is included because a `data:text/html` document navigated to
 * from `href` executes in the page's origin.
 *
 * The check is deliberately conservative about what it inspects. It is not a
 * URL validator and does not rewrite anything: it either allows the value
 * through unchanged or replaces it with a value that cannot navigate, and says
 * so. Silently mangling a URL would be worse than either.
 * @module lib/core/security/urlPolicy
 */
 
import { AvenxErrorCodes, formatMessage } from '../runtime/AvenxError.js';
import { logger } from '../runtime/AvenxLogger.js';
 
/**
 * Attributes whose value is navigated to or loaded.
 *
 * `src` and `href` are the obvious ones. `action` and `formaction` submit to a
 * URL; `xlink:href` is the SVG spelling of `href` and executes on click in
 * exactly the same way; `ping` and `data` are fetched.
 * @type {Set<string>}
 */
export const URL_ATTRIBUTES = new Set([
  'href',
  'src',
  'xlink:href',
  'action',
  'formaction',
  'ping',
  'data',
  'poster',
  'background',
]);
 
// `srcdoc` is deliberately not here. Its content is an HTML document, not a
// URL, so a scheme check says nothing about it -- `<img onerror=...>` has no
// scheme. A bound `srcdoc` is escaped as HTML instead, in the renderer, the
// same way `data-ax-html` is.
 
/**
 * Schemes that execute rather than locate.
 * @type {Set<string>}
 */
const DANGEROUS_SCHEMES = new Set(['javascript', 'vbscript', 'data']);
 
/**
 * `data:` URLs that are inert in every context Avenx puts them in.
 *
 * An image or a font cannot execute. Blocking `data:image/png` would break
 * inline avatars and icons for no security gain, so the media types that
 * cannot carry script are allowed through.
 * @type {RegExp}
 */
const INERT_DATA_URL = /^data:(image\/(png|jpe?g|gif|webp|avif|bmp|x-icon)|font\/|application\/font)/i;
 
/**
 * The value substituted for a refused URL.
 *
 * `about:blank` rather than an empty string: an empty `href` resolves to the
 * current document, so a refused link would silently reload the page instead of
 * doing nothing.
 * @type {string}
 */
export const REFUSED_URL = 'about:blank';
 
/**
 * Whether an attribute's value is treated as a URL.
 * @param {string} name - The attribute name.
 * @returns {boolean} True when the attribute navigates or loads.
 */
export function isUrlAttribute(name) {
  return typeof name === 'string' && URL_ATTRIBUTES.has(name.toLowerCase());
}
 
/**
 * Extracts the scheme of a URL, if it has one.
 *
 * Leading control characters and whitespace are stripped first: browsers ignore
 * them when resolving a URL, so `java\tscript:alert(1)` navigates exactly as
 * `javascript:alert(1)` does, and a check that did not strip them would be
 * reading a different string than the browser.
 * @param {string} value - The attribute value.
 * @returns {string|null} The lowercased scheme, or null when the URL is relative.
 */
export function schemeOf(value) {
  if (typeof value !== 'string') return null;
 
  // eslint-disable-next-line no-control-regex
  const normalized = value.replace(/[\u0000-\u0020\u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000\ufeff]/g, '');
  const match = normalized.match(/^([a-zA-Z][a-zA-Z0-9+.-]*):/);
  return match ? match[1].toLowerCase() : null;
}
 
/**
 * Whether a URL is safe to place in a navigating attribute.
 * @param {string} value - The attribute value.
 * @returns {boolean} True when the URL may be used.
 */
export function isSafeUrl(value) {
  const scheme = schemeOf(value);
  if (scheme === null) {
    // Relative, fragment, query or protocol-relative: no scheme to abuse.
    return true;
  }
  if (!DANGEROUS_SCHEMES.has(scheme)) {
    return true;
  }
  if (scheme === 'data' && INERT_DATA_URL.test(String(value).trim())) {
    return true;
  }
  return false;
}
 
/**
 * Returns a URL safe for the given attribute, reporting a refusal.
 * @param {string} name - The attribute name.
 * @param {string} value - The attribute value.
 * @param {object} [context] - Logging context.
 * @returns {string} The original value, or {@link REFUSED_URL}.
 */
export function sanitizeUrlAttribute(name, value, context) {
  if (!isUrlAttribute(name) || isSafeUrl(value)) {
    return value;
  }
  const message = formatMessage(AvenxErrorCodes.SECURITY_BLOCKED_URL, name, String(value).slice(0, 120));
  // The context argument is omitted rather than passed as undefined, which the
  // logger would render as the string "undefined" after the message.
  if (context) {
    logger.warn(message, context);
  } else {
    logger.warn(message);
  }
  return REFUSED_URL;
}