Interactive Node.js Filesystem Access

This project will show you how Intrinsic filesystem policies work and also serve as a general overview of how Intrinsic itself works.

This application exposes two routes which you can open in your web browser to confirm it works.

First, copy and paste the file contents from the end of the page into a directory. Then, run npm install in this directory. This will install both Intrinsic as well as the Express web server. Intrinsic works with any HTTP server, from the built-in http module to higher-level ones such as Express.

Unprotected without Intrinsic

Now that the project is ready, run the following command to start the example web server using Node.js without Intrinsic:

node server.js

Suppose that we expect the first route to be able to read the file named read-during-request.json and we expect the second route not to be able to read it. In our code we're very obviously reading from the file intentionally, although in a real application the reading of the file could be due to a programmer mistake. When running the application with Node.js without Intrinsic, both routes have access to the sensitive file.

Once you've started the server, visit the following two URLs in your browser. You will notice that they both open just fine:

  • http://localhost:8000/route-which-should-read-file
  • http://localhost:8000/route-which-should-not-read-file

Protected with Intrinsic

Now that we've seen how our application is able to access more data than it should, let's restart the server and start it again using Intrinsic. You can press Ctrl+C to stop the server. This time we'll start the server using a different command:

node intrinsic.js

Note that the server is still being run using the same Node.js installation you're used to. The only difference is that the script being run has changed.

At this point you may be wondering why the log messages have been duplicated. Part of the attacker model that Intrinsic takes on is that each route defined in the applications policies needs to have its own "sandbox", isolated from the other routes in the application. This means that code will run multiple times.

Now that the server is running with Intrinsic, let's open the same two URLs from before in a web browser and view the results:

  • http://localhost:8000/route-which-should-read-file
  • http://localhost:8000/route-which-should-not-read-file

In this case the first URL loads just fine, however the second URL displays a 500 server error and an Intrinsic policy violation has been logged.

Quick Filesystem Policies Overview

Take a look at the file named intrinsic-policy.js. This file should give you an idea of how the policies are working and how they're differentiated for the supplied routes.

Policy Routes

When you create policies you create them on a per-route basis. Sometimes you will need to create policies which apply to every route in the application. For example, this might include instantiating a database connection or downloading some initialization JSON data from a configuration service. With these types of rules you should add them to the all routes policy:

server.allSandboxes(policy => {
  // write common policies here
});

Other times, you'll need to write rules which only affect a single route. You can add rules to individual routes like so:

server.addSandbox('get', '/my/specific/route', policy => {
  // exactly one route
});

And sometimes you will encounter rules which affect more than one but less than all routes. When these happen you can write a helper function to apply the rules (Intrinsic policy files are normal JavaScript files):

function readProfileTemplateFiles(policy) {
  // enable reading files in views/templates
}
server.addSandbox('get', '/profile', policy => {
  readProfileTemplateFiles(policy);
});
server.addSandbox('get', '/profile/edit', policy => {
  readProfileTemplateFiles(policy);
});
server.addSandbox('post', '/profile/edit/submit-and-redirect', policy => {
  // doesn't need to read template files
});

Check out the page on routing for more information on configuring incoming routes.

Read and Write Policies

You configure Intrinsic filesystem policies by choosing which paths can be read from or written to:

policy.fs.allowRead(path);
policy.fs.allowWrite(path);

The path variables need to be absolute paths to the file to be protected.

Glob Syntax

Specifying the exact path isn't always convenient. For example, consider that you need to dynamically read all of the .jpg files in the directory /tmp/uploads and that those filenames are dynamic. In this situation you can make use of glob patterns.

When using a glob, an asterisk (*) means any string of characters other than a forward slash (/). Two asterisks (**) means any number of characters including forward slashes. In the case of the uploaded JPG images, which our application needs to be able to read and write to, we might create rules which look like the following:

policy.fs.allowRead('/tmp/uploads/*.jpg');
policy.fs.allowWrite('/tmp/uploads/*.jpg');

Now let's assume you're working with an application which renders *.ejs templates and renders them into a webpage. These file are located in a views/ directory in the root of the project and can be deeply nested in a complex folder structure. In this case your rules might resemble the following:

const { join } = require('path');
policy.fs.allowRead(join(__dirname, 'views', '**', '*.ejs'));

Be careful when using glob patterns to create rules for your application. You should never be overly permissive otherwise you will unnecessarily increase the attack surface of your application.

Policy Building with Monitor Mode

Normally, when running your application, the first failure will trigger an error and may prevent the application from continuing, depending on your application's error handling. For example, if a request handler performs three different sequential I/O operations, if the first operation fails then the second and third won't run.

Intrinsic has a convenient feature called monitor mode to help in exactly these situations. Edit the intrinsic.js file and uncomment the line containing .enableMonitorMode(). Start the server again and operations which normally result in failure will now be allowed. However, the server will still print the policy violations as they occur. With monitor mode enabled, visit the route which failed previously and note the following output:

[INTRINSIC (MONITOR)] FsPolicyViolation: POLICY_VIOLATION sb: "fallback" |
  Can't read from file at /.../1-filesystem/read-during-request.json
[INTRINSIC (MONITOR)] FsPolicyViolation: POLICY_VIOLATION sb: "fallback" |
  Can't stat file at /.../1-filesystem/read-during-request.json

These messages tell us what underlying IO is being performed. Intrinsic is aware of more than simple read/write operations and prints messages for each, such as the stat and read operations mentioned above. Since stat is a form of reading, the two messages can be alleviated by a single policy for fs.allowRead.

Homework

Now that you're familiar with the basics of filesystem operations, the glob syntax, and how to use monitor mode, let's examine a third route in this application. Go ahead and run the service using Intrinsic and visit the following URL:

  • http://localhost:8000/route-with-complex-io

Now, read through the monitor mode messages and create a set of rules required to enable this route in intrinsic-policy.js. Once you've successfully created the rules you will no longer see violations listed in the output of monitor mode.

Keep in mind that operations like stat-ing a file, reading a file, are read operations. Other operations which modify the filesystem in some manner, such as delete, create, or write, are going to be write operations.

Once you're confident you've fixed the violations, restart the server with monitor mode disabled (by commenting out the .enableMonitorMode() line in intrinsic.js). When you load the URL again it should run just fine.

Files

These are the files necessary to run the interactive tutorial. Place the Intrinsic archive that you received from us in the same directory as these files.

always-read.json

{
  "always-read": "This file is always read"
}

input.txt

Two roads diverged in a yellow wood

intrinsic-policy.js

'use strict';

const { join } = require('path');
const { HttpServer } = require('@intrinsic/intrinsic');

const server = new HttpServer({ port: process.env.PORT || 8000 });

server.allSandboxes(policy => {
  // This file is always read, regardless of the route being requested
  // For this reason we put it in the `allRoutes` section
  policy.fs.allowRead(join(__dirname, 'always-read.json'));
});

server.addSandbox('get', '/route-which-should-read-file', policy => {
  // This route is allowed to read the JSON file
  policy.fs.allowRead(join(__dirname, 'read-during-request.json'));
});

server.addSandbox('get', '/route-which-should-not-read-file', policy => {
  // This route has no policies (other than what routes.allRoutes provides)
});

module.exports = server;

intrinsic.js

#!/usr/bin/env node
'use strict';

const intrinsic = require('@intrinsic/intrinsic');

intrinsic(__filename)
  .loadPolicies('./intrinsic-policy.js')
  // .enableMonitorMode()
  .run('./server.js');

package.json

{
  "name": "1-filesystem",
  "private": true,
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "keywords": [],
  "author": "Intrinsic <hello@intrinsic.com>",
  "license": "UNLICENSED",
  "dependencies": {
    "@intrinsic/intrinsic": "./intrinsic-intrinsic-1.0.0.tgz",
    "express": "^4.16.3"
  }
}

read-during-request.json

{
  "sometimes-read": "This file is read during a request"
}

server.js

#!/usr/bin/env node
'use strict';

const fs = require('fs');
const express = require('express');

const app = express();
const alwaysRead = JSON.parse(fs.readFileSync('./always-read.json').toString());
const PORT = process.env.PORT || 8000;

// This route should have access to the JSON file
app.get('/route-which-should-read-file', (req, res) => {
  fs.readFile('./read-during-request.json', (err, data) => {
    if (err) {
      console.error(err);
      return res.status(500).send({
        error: 'unable to read file'
      });
    }
    res.status(200).send({
      always: alwaysRead,
      request: JSON.parse(data)
    });
  });
});

// This route should not have access to the JSON file
app.get('/route-which-should-not-read-file', (req, res) => {
  fs.readFile('./read-during-request.json', (err, data) => {
    if (err) {
      console.error(err);
      return res.status(500).send({
        error: 'unable to read file'
      });
    }
    res.status(200).send({
      always: alwaysRead,
      request: JSON.parse(data)
    });
  });
});

// This route is used for homework
app.get('/route-with-complex-io', (req, res) => {
  fs.readFile('./input.txt', (err, data) => {
    if (err) return res.status(500).send({ error: err.message });
    fs.writeFile('./output.txt', data, (err) => {
      if (err) return res.status(500).send({ error: err.message });
      fs.unlink('./output.txt', (err) => {
        if (err) return res.status(500).send({ error: err.message });
        res.status(200).send({ ok: 'you got it' });
      });
    });
  });
});

app.listen(PORT, (err) => {
  if (err) {
    console.error('UNABLE TO START SERVER');
    console.error(err.message);
    return;
  }

  console.log(`Visit: http://localhost:${PORT}/route-which-should-read-file`);
  console.log(`Visit: http://localhost:${PORT}/route-which-should-not-read-file`);
});