Back

100% Statements 46/46
94.11% Branches 32/34
100% Functions 7/7
100% Lines 46/46

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 158 159 160 161 162 163 164 165 166 167    69x 69x 69x 69x   69x                                                       361x                   248x       69x                 389x                   110x                         131x 131x   131x                         117x 117x 117x   117x 110x 110x     7x 7x 7x   7x                         135x 135x 135x   135x 366x 5x     361x 361x       361x   361x 3x 2x 2x   3x     358x 1x   357x   357x   357x 117x 117x     240x         135x    
import { encodeMasked, maskEmails } from './mask-emails';
 
const REDACTED = '[redacted]';
const UNREPORTABLE = '[unreportable]';
const SAFE_NAME = /^[A-Za-z0-9_-]{1,64}$/;
const NESTED_QUERY_PARAMS = new Set( [ 'p', 'post_url' ] );
 
const ALLOWED_PARAMS = new Set( [
	'page',
	'p',
	'from',
	'to',
	'interval',
	'preset',
	'step',
	'comp',
	'compare_from',
	'compare_to',
	'compare_preset',
	'ds',
	'section',
	'ref',
	'ref_section',
	'post_url',
	'post_id',
	'author_id',
] );
 
/**
 * Decodes one raw parameter name, tolerating a malformed percent sequence.
 *
 * @param raw - The name as it appears in the search string.
 * @return The decoded name.
 */
function decodeName( raw: string ): string {
	return [ ...new URLSearchParams( raw ).keys() ][ 0 ] ?? '';
}
 
/**
 * Decodes one raw parameter value, tolerating a malformed percent sequence.
 *
 * @param raw - The value as it appears in the search string.
 * @return The decoded value.
 */
function decodeValue( raw: string ): string {
	return new URLSearchParams( `v=${ raw }` ).get( 'v' ) ?? '';
}
 
// Of any scheme or none, so a protocol-relative URL is covered too.
const URL_CREDENTIALS = /^([A-Za-z][A-Za-z0-9+.-]*:\/\/|\/\/)[^/?#]*@/;
 
/**
 * Removes the credentials a URL can carry in front of its host.
 *
 * @param url - A URL, absolute or protocol-relative.
 * @return The URL without them.
 */
export function stripUrlCredentials( url: string ): string {
	return url.replace( URL_CREDENTIALS, '$1' );
}
 
/**
 * The pathname of a route path, without the query or the fragment it may carry.
 *
 * @param path - A route path, as the `p` parameter carries it.
 * @return The pathname alone.
 */
export function routePathname( path: string ): string {
	return path.split( /[?#]/, 1 )[ 0 ];
}
 
/**
 * Masks the email addresses an allowlisted value carries.
 *
 * On the decoded value, because an address reaches a link as `%40` as readily as `@`, and the
 * allowlist keeps a value exactly as the link wrote it.
 *
 * @param raw - The value as it appears in the search string.
 * @return The value with each address masked, unchanged when it carries none.
 */
function redactAllowedValue( raw: string ): string {
	const decoded = decodeValue( raw );
	const masked = maskEmails( decoded );
 
	return masked === decoded ? raw : encodeMasked( masked );
}
 
/**
 * Redacts the query an allowlisted path or URL carries, by the rules of the page's own query.
 *
 * That query is a second search string nested inside one parameter value, so without this it
 * would reach the report whole, with none of its own names checked against the allowlist.
 *
 * @param raw - The path or URL as it appears in the search string.
 * @return The value with its query redacted and its fragment dropped, encoded as a value.
 */
function redactNestedQuery( raw: string ): string {
	const decoded = decodeValue( raw );
	const path = stripUrlCredentials( decoded );
	const cut = path.search( /[?#]/ );
 
	if ( cut === -1 ) {
		const masked = maskEmails( path );
		return masked === decoded ? raw : encodeMasked( masked );
	}
 
	const nested = path[ cut ] === '?' ? path.slice( cut + 1 ).split( /[?#]/, 1 )[ 0 ] : '';
	const query = redactQuery( nested );
	const pathname = maskEmails( path.slice( 0, cut ) );
 
	return encodeMasked( query === '' ? pathname : `${ pathname }?${ query }` );
}
 
/**
 * Keeps the value of allowlisted query parameters and masks every other value.
 *
 * A parameter whose name is unsafe is dropped rather than masked: in string form the name itself
 * is the leak, with no key left to hide it behind.
 *
 * @param search - A `location.search` value, with or without the leading `?`.
 * @return The parameters in their original order, as one query string.
 */
export function redactQuery( search: string ): string {
	const parts: string[] = [];
	const emitted = new Set< string >();
	let marked = false;
 
	for ( const pair of search.replace( /^\?/, '' ).split( '&' ) ) {
		if ( pair === '' ) {
			continue;
		}
 
		const cut = pair.indexOf( '=' );
		const name = decodeName( cut === -1 ? pair : pair.slice( 0, cut ) );
 
		// A parameter with no value is nothing but its name, and an identifier shape cannot tell a
		// key apart from a bare token appended to the URL, so only a known name survives that way.
		const unnamed = cut === -1 && ! ALLOWED_PARAMS.has( name );
 
		if ( ! SAFE_NAME.test( name ) || unnamed ) {
			if ( ! marked ) {
				marked = true;
				parts.push( UNREPORTABLE );
			}
			continue;
		}
 
		if ( emitted.has( name ) ) {
			continue;
		}
		emitted.add( name );
 
		const raw = cut === -1 ? '' : pair.slice( cut + 1 );
 
		if ( NESTED_QUERY_PARAMS.has( name ) ) {
			parts.push( `${ name }=${ redactNestedQuery( raw ) }` );
			continue;
		}
 
		parts.push(
			`${ name }=${ ALLOWED_PARAMS.has( name ) ? redactAllowedValue( raw ) : REDACTED }`
		);
	}
 
	return parts.join( '&' );
}