Skip to content

sanitize uris in web and web-like applications with confidence

License

Notifications You must be signed in to change notification settings

codesplinta/URISanity

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

76 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

@isocroft PRs Welcome Made in Nigeria

URI Sanity

A small library used in the Browser and NodeJS to vet URIs (to mitigate vulnerabilities) with confidence. In other words, It's the DOMPurify for URIs. A uniform resource locator (URL) is, in fact, a subset of uniform resource identifiers (URI). Therefore, this library covers the super set of all resource identifiers where possible.

Motivation

There are many web-based zero-day vulnerabilities that can be expolited in Browsers/NodeJS servers using Standard and/or Custom URI schemes. Certain browsers like Safari and Firefox are usually subceptible to launching such URIs without a prompt or restrictions and enable Arbitrary File Execution, Remote Code Execution and/or Connection String Pollution (on the server) where possible. This is why this library was built. It moves to create a layer of protection for your web applications both on the Browser and on the Server (NodeJS only) by blocking badly formed/suspicious URIs.

Furthermore, other solutions like braintree/sanitize-url are quite naive and a bit too specific in it's approach to URL sanitization. Also, most web front-end frameworks like Angular and Vue (safe for React) do not do a very robust and serious (non-trivial) job of sanitiziting URLs either. This is why this library is very important to web application developers who need reliability in sanitizing URLs.

Validation

This library has been validated against popular malicious URIs delineated here and here

Installation

Install using npm

npm install urisanity

or install using yarn.

yarn add urisanity

Getting Started

All you need to do is import the package appropriately depending on the environment (Browser OR Node) being used

Browser environment

Using a script tag directly inside a web page

<script type="text/javascript" src="https://unpkg.com/browse/[email protected]/dist/urisanity.min.js" crossorigin="anonymous"></script>

import as ES6 module - no setup required

import URISanity from 'urisanity';

const sanitizedUrl = URISanity.vet('blob:https://www.foo-.evil.com/undefined', {
  // All flag options set - valid
  allowScriptOrDataURI: false,
  allowFileSystemURI: false,
  allowCommsAppURI: true,
  allowDBConnectionStringURI: false,
  allowBrowserSpecificURI: false,
  allowWebTransportURI: false,
  allowServiceAPIURI: false,
});

console.log(sanitizedUrl); // "about:blank"

const sanitizedDBUri = URISanity.vet("jdbc:sqlserver://;servername=server_name;integratedSecurity=true;authenticationScheme=JavaKerberos", {
  // One flag option set - valid
  allowDBConnectionStringURI: true,
  allowFileSystemURI: false, // you can omit this since it's `false`
  allowCommsAppURI: false, // you can omit this since it's `false`
  allowScriptOrDataURI: false, // you can omit this since it's `false`
  allowWebTransportURI: false, // you can omit this since it's `false`
  allowServiceAPIURI: false // you can omit this since it's `false`
})

console.log(sanitizedDBUri) // "jdbc:sqlserver://;servername=server_name;integratedSecurity=true;authenticationScheme=JavaKerberos"

const sanitizedCustomUrl = URISanity.vet(
  'icloud-sharing://www.icloud.com/photos/01eFfrthOPvnfZqlKMn', {
    /* No flag options set - valid */
});

console.log(sanitizedCustomUrl); // "about:blank"

const santizedBadUrl = URISanity.vet('http://aa.com/</script>"><img src=x onerror="prompt(document.domain)">)', {
  allowWebTransportURI: true,
  allowScriptOrDataURI: true
})

console.log(sanitizedBadUrl); // "about:blank"



const paramValue = URISanity.extractParamValueFromUri(
  'https://www.example.com?xyz=%200000#intro',
  'xyz'
);

console.log(paramValue); // " 0000"

const checkPassed = URISanity.checkParamsOverWhiteList(
  'grpc://api.broker.rt-msg.io:443?user=sal%C3%A1ta',
  ['user']
);

console.log(checkPassed); // true

const isSame = URISanity.isSameOrigin(window.location.href)

console.log(isSame) // true

NodeJS (commonjs) environment

Setup an env file in your NodeJS app and include an ORIGIN

ORIGIN=http://127.0.0.1:4050
const URISanity = require('urisanity');

const sanitizedFileUrl = URISanity.vet(
  'file://www.airbnb.com/Users/xxx/Desktop/index.html',
  {
    allowWebTransportURI: true
  }
);

console.log(sanitizedFileUrl) // "about:blank"
const URISanity = require('urisanity');

let sanitizedUrl = URISanity.vet(
  'file://www.airbnb.com/Users/xxx/Desktop/index.html',
  {
    allowWebTransportURI: false,
    allowFileSystemURI: true
  }
);

console.log(sanitizedUrl) // "file://www.airbnb.com/Users/xxx/Desktop/index.html"

Implementing Trusted Types

You can make use of Trusted Types while using URI Sanity. An excerpt from a 2021 report from Google on Trusted Types reads:

Trusted Types are supported in several popular frameworks and libraries including Angular, React (with a feature flag), Lit, Karma, and Webpack. Enforcing Trusted Types in applications built on top of these frameworks is now relatively simple; in some cases no application-level code changes are required.

Before the advent of Trusted Types (specifically, in the days of Angular 1.x), frontend web engineers used this approach in sanitizing URIs for web applications and it was grossly inefficient and/or naive. This is also another approach that still doesn't cater to a much braoder system for URI sanitization. Now, with URISanity, you have the broader systems needed for quality URI sanitization.

import URISanity from 'urisanity';
import DOMPurify from 'dompurify';

window.addEventListener('securitypolicyviolation', console.error.bind(console));

/* @HINT: feature / object detection */
if (typeof window.trustedTypes !== 'undefined') {
  trustedTypes.createPolicy('default', {
    createHTML: (html) => {
      /* @HINT: 
        
        sanitize all potentially malicious characters from HTML string 
      */
      return DOMPurify.sanitize(html, {
        USE_PROFILES: {
          html: true,
          svg: true,
        },
      })
    },
    createScriptURL: (url) => {
      /* @HINT: 
        
        vet URL string and return "about:blank" if URL string is suspicious
      */
      return URISanity.vet(url, {
        allowWebTransportURI: true,
      })
    },
  });
}

More Use Cases

URISanity can be used to improve the web security of browser API sinks (injection sinks) that make use if URIs and aren't covered and/or catered for by Trusted Types and basic CSP. By instrumenting these API sinks (sinks for Document Object Model / Browser Object Model) and utilizing browser custom event API(s), the solution is quite elegant. Take a look below:

Let's define some basic browser custom events and their handler

/* @NOTE: Can also be the "connect-src" whitelist of urls from a CSP directive */
/* @HINT: The whitelist below is for excluding URLs that are not known to the web app and may be expoitative/suspicious */
const whitelistedURLEndpoints = [
  'https://www.facebook.com/tr',
  'https://www.google-analytics.com/collect'
]

/* @HINT: Setting custome events to check and validate URLs */
document.addEventListener( 'beforerequest', onBeforeURIUsed, false )
document.addEventListener( 'beforeinclude', onBeforeURIUsed, false )

/* @HINT: Event handler common to the two events above */
function onBeforeURIUsed ( event ) {
  /* @CHECK: https://www.npmjs.com/package/urisanity ; urisanity */
  /* @HINT: Vet the URL endpoint being requested/included for safety */
  if (window.urisanity.vet(
    event.detail.endpoint,
    { allowWebTransportURI: true }
  ) !== 'about:blank') {
    const { origin, pathname } = new URL(event.detail.endpoint)

    /* @HINT: Make sure the endpoint being requested/included is part of the whitelist */
    if (whitelistedURLEndpoints.includes(`${origin}${pathname}`)) {
      if (origin.includes('.google-analytics.')) {
        /* @HINT: Check that only the request params we need are attached */
        /* @HINT: Any other extra params should not be allowed */
        if (window.urisanity.checkParamsOverWhiteList(
          event.detail.endpoint,
          ['tid', 'cid'],
          event.detail.data
        )) {
          return;
        }
      }
    }
  }

  /* @HINT: trigger an error to be thrown when the endpoint is not in the whitelist above */
  /* @HINT: Or the validation above for any origin (or for google-analytics) doesn't pass */
  event.preventDefault()
}

Now, let's instrument certain browser API sinks and make them able to throw errors on suspicion of a malformed/malicious API.

 /*!
  * FIRST SECTION
  *
  */

/* @HINT: Extract the native definitions of these APIs from the DOM Interfaces */
const originalSetAttributeMethod = HTMLElement.prototype.setAttribute

/* @HINT: Create a new definition for `setAttribute` that instruments the API to detect suspicious URIs */
HTMLElement.prototype.setAttribute = function setAttribute (attributeName, newValue) {
	  const that = this;
	  const previousValue = that.getAttribute(attributeName);

	  const timerID = window.setTimeout(function () { 
      /* @HINT: Stop [ DOMSubtreeModified ] event from firing before [ DOMAttrModified ] event */
		  originalSetAttributeMethod.call(that, attributeName, newValue);
	  }, 0);

    /* @HINT: Whenever the attribute name is `href`, then check the URL that is the value */
    if (attributeName === 'href') {
      /* @HINT: Fire a custom event `beforeinclude` to track manual whitelisting of URL endpoints */
      let event = new window.CustomEvent('beforeinclude', {
        detail: {
          endpoint: newValue,
          sink: "HTMLElement.setAttribute",
          data: null
        },
        bubbles: true,
        cancelable: true
      });

      /* @HINT: Detect if the dispatched custom event was cancelled by a call to `event.preventDefault()` */
      /* @HINT: If the event was cancelled, it means the URL endpoint above was disallowed by the checks */
      const cancelled = !document.dispatchEvent(event)

       /* @HINT: If it's cancelled, stop the `setTimeout` call above from being executed by clearing the timeout */
       /* @HINT: Also, we throw an error to stop the call to `setAttribute` from being requested */
       if (cancelled) {
         window.clearTimeout(timerID)
         throw new Error(
           "Suspicious Activity: "
           +
           event.detail.endpoint
           +
           " request, using [ " + event.detail.data + " ] in "
           +
           " [ " + event.detail.sink + " ]"
         )
       }
    }

    /* @HINT: When listening to mutation events, might be okay to stagger certain event sequences properly */
	  if (newValue !== previousValue) {
	    let event = document.createEvent("MutationEvent");
	    event.initMutationEvent(
	      "DOMAttrModified",
	      true,
	      false,
	      that,
	      previousValue || "",
	      newValue || "",
	      attributeName,
	      (previousValue === null) ? event.ADDITION : event.MODIFICATION
	    );
		  
	    that.dispatchEvent(
        event
      );
	  }
	};


 /*!
  * NEXT SECTION
  *
  */


/* @HINT: craete a function/constructor that does nothing a.k.a no-operation function */
const noop = function noOperation () {}

/* @HINT: Copy out the user-agent interface function `sendBeacon` */
const NativeSendBeacon = window.Navigator.prototype.sendBeacon || noop

window.Navigator.prototype.sendBeacon = function sendBeacon (url, data) {
  /* @HINT: Fire a custom event `beforerequest` to track manual whitelisting of URL endpoints */
  const event = new window.CustomEvent('beforerequest', {
    detail: {
      endpoint: url,
      method: "POST",
      sink: "Navigator.sendBeacon",
      data: data
    },
    bubbles: true,
    cancelable: true
  })

  /* @HINT: Detect if the dispatched custom event was cancelled by a call to `event.preventDefault()` */
  /* @HINT: If the event was cancelled, it means the URL endpoint above was disallowed by the checks */
  const cancelled = !document.dispatchEvent(event)

   /* @HINT: If it's cancelled, we throw an error to stop the call to `sendBeacon` from being requested */
   if (cancelled) {
     throw new Error(
       "Suspicious Activity: "
       +
       event.detail.endpoint
       +
       " request, using [ " + event.detail.data + " ] in "
	      +
	      " [ " + event.detail.sink + " ]"
     )
   }

   /* @HINT: If all checks out and no error was thrown above then proceed as usual */
   return NativeSendBeacon.call(this, url, data);
};

/* @HINT: define property `name` on custom function */
  Object.defineProperty(sendBeacon, 'name', {
    writable: false,
    value: 'sendBeacon'
  });

/* @HINT: define property function `toString` on custom function */
Object.defineProperty(sendBeacon, 'toString', {
  writable: true,
  value: function toString () {
    return NativeSendBeacon.toString()
  }
})

/* @HINT: Take care of the special Firefox/IceWeasel (Gecko) property `toSource` */
if ('toSource' in NativeSendBeacon) {
  Object.defineProperty(sendBeacon, 'toSource', {
    writable: true,
    value: function toSource () {
      return NativeSendBeacon.toSource()
    }
  })
}

Finally, the code above in the event handler get triggered whenever navigator.sendBeacon() is called and the URLs are using URISanity. The zhorn package provides all of this functionality and depends on URISanity.

Documentation

Here is a brief guide to using this library and it's API method(s)

Flag Options

When using the .vet(uri: String [, options: Object]) API method, there are flag option(s) to filter out different URI categories in the vetting process. They are as follows:

  1. allowCommsAppURI : This applies only to the deep links that are used by communication tools and apps like Whatsapp, Zoom, Slack, Skype or browser comms URIs e.g. sms, tel, whatsapp, slack
  2. allowDBConnectionStringURI : This applies only to database connection string URIs e.g. postgresql, mongodb, jdbc:mysql
  3. allowBrowserSpecificURI : This applies only to browser extensions, packaged apps and browser data display URIs e.g. view-source, moz-extension
  4. allowServiceAPIURI : This appies only to third-party data storage services whos have specialized URIs for data transfer and storage operations e.g. cloudinary, s3, grpc
  5. allowScriptOrDataURI : This applies only to script and/or data URIs e.g. data, javascript
  6. allowFileSystemURI : This applies only to URIs related to the local filesystem e.g. blob, file, local
  7. allowWebTransportURI : This applies only to web data transport URIs e.g. http, ws, https, wss, blob:https

API Methods

URISanity.vet(uri: String [, options: Object]): String

The .vet(uri: String [, options: Object]) method is used to sanitize a URI of any standard form to ensure that it doesn't contain unwanted and/or malicious content. Only the second argument is optional. If the second ( options ) argument isn't passed, it means that all flag options are false.

URISanity.extractParamValueFromUri(uri: String, queryParamName: String): String

The .extractParamValueFromUri(uri: String, queryParamName: String) method is used to extract the value of a query parameter from a given URI. Both arguments are not optional.

URISanity.checkParamsOverWhiteList(uri: String, paramNamesWhiteList: Array [, querySearch: String]): Boolean

The .checkParamsOverWhiteList(uri: String, queryParamNames: Array [, querySearch: String | Object]) method is used to check whether the params (query OR body) associated with a given URI is correct, allowed and valid for it's use case. Only the third argument for this method is optional.

URISanity.isSameOrigin(uri: String): Boolean

The .isSameOrigin(uri: String) method is used to check if the URI being inspected has the sam origin (protocol + host) as the environment (Browser or NodeJS). The only argument for this method is not optional.

License

MIT License

Contributing

If you wish to contribute to this project, you are very much welcome. Please, create an issue first before you proceed to create a PR (either to propose a feature or fix a bug). Make sure to clone the repo, checkout to a contribution branch and build the project before making modifications to the codebase.

Run all the following command (in order they appear) below:

$ npm run lint

$ npm run build

$ npm run test

TypeScript

You can find the TS declaration here (simply copy from the gist and paste in the root of your project as urisanity.d.ts)