CVE-2017-6201 (JavaScript, published 2018-02-06T16:29Z)
Description: value: A Server Side Request Forgery vulnerability exists in the install app process in Sandstorm before build 0.203. A remote attacker may exploit this issue by providing a URL. It could bypass access control such as firewalls that prevent the attackers from accessing the URLs directly.
Severity: MEDIUM (CVSS v3 base score: 8.1)
Weakness: CWE-918 \u2014 Server-Side Request Forgery (SSRF)
The web server receives a URL or similar request from an upstream component and retrieves the contents of this URL, but it does not sufficiently ensure that the request is being sent to the expected destination.
Repository: https://github.com/sandstorm-io/sandstorm
Files changed:
shell/client/admin-client.js
shell/client/admin/admin.html
shell/client/admin/networking-client.js
shell/client/admin/networking.html
shell/client/styles/_admin-networking.scss
shell/client/styles/_new-admin.scss
shell/imports/constants.js
shell/imports/server/migrations.js
shell/imports/server/networking.js
shell/server/00-startup.js
shell/server/drivers/external-ui-view.js
shell/server/hack-session.js
shell/server/installer.js
Commit message: Implement IP blacklist for user-initiated requests.


Vulnerable code:
ACCOUNT_DELETION_SUSPENSION_TIME = 7 * 60 * 60 * 24 * 1000; // 7 days in ms
export { ACCOUNT_DELETION_SUSPENSION_TIME };
    return { session: new Capnp.Capability(new ExternalWebSession(this.url, options), ApiSession) };
    const parsedUrl = Url.parse(url);
    this.options = options || {};
    return new Promise((resolve, reject) => {
      let requestMethod = Http.request;
      if (url.indexOf("https://") === 0) {
        requestMethod = Https.request;
      } else if (url.indexOf("http://") !== 0) {
        err = new Error("Protocol not recognized.");
        err.nature = "precondition";
        reject(err);
      }

      req = requestMethod(url, (resp) => {
        const buffers = [];
        let err;

        switch (Math.floor(resp.statusCode / 100)) {
          case 2: // 2xx response -- OK.
            resp.on("data", (buf) => {
              buffers.push(buf);
            });

            resp.on("end", () => {
              resolve({
                content: Buffer.concat(buffers),
                mimeType: resp.headers["content-type"] || null,
              });
            });
            break;
          case 3: // 3xx response -- redirect.
            resolve(session.httpGet(resp.headers.location));
            break;
          case 4: // 4xx response -- client error.
            err = new Error("Status code " + resp.statusCode + " received in response.");
            err.nature = "precondition";
            reject(err);
            break;
          case 5: // 5xx response -- internal server error.
            err = new Error("Status code " + resp.statusCode + " received in response.");
            err.nature = "localBug";
            reject(err);
            break;
          default: // ???
            err = new Error("Invalid status code " + resp.statusCode + " received in response.");
            err.nature = "localBug";
            reject(err);
            break;
      });

      req.on("error", (e) => {
        e.nature = "networkFailure";
        reject(e);
      });
      req.setTimeout(15000, () => {
        req.abort();
        err = new Error("Request timed out.");
        err.nature = "localBug";
        err.durability = "overloaded";
        reject(err);

      req.end();
    const url = Url.parse(this.url);
    const options = {
      hostname: url.hostname,
      port: url.port,
      path: url.path,
    };

    let protocol;
    if (url.protocol === "http:" || url.protocol === "https:") {
      protocol = Request.defaults({
        maxRedirects: 20,
        // Since we will verify the download against a hash anyway, we don't need to verify the
        // server's certificate. In fact, the only reason we support HTTPS at all here is because
        // some servers refuse to serve over HTTP (which is, in general, a good thing). Skipping the
        // certificate check here is helpful in that it means we don't have to worry about having a
        // reasonable list of trusted CAs available to Sandstorm.
        strictSSL: false,
      });
    } else {
      throw new Error("Protocol not supported: " + url.protocol);
    }

    // TODO(security):  It could arguably be a security problem that it's possible to probe the
    //   server's local network (behind any firewalls) by presenting URLs here.
    let bytesExpected = undefined;
    let bytesReceived = 0;
    const hasher = Crypto.createHash("sha256");
    let done = false;
    const updateDownloadProgress = _.throttle(this.wrapCallback(() => {
      if (!done) {
        if (bytesExpected) {
          this.updateProgress("download", bytesReceived / bytesExpected);
        } else {
          this.updateProgress("download", bytesReceived);
      }
    }), 500);

    const request = protocol.get(this.url);

    request.on("response", this.wrapCallback((response) => {
      if ("content-length" in response.headers) {
        bytesExpected = parseInt(response.headers["content-length"]);
      }
    }));
    request.on("data", this.wrapCallback((chunk) => {
      hasher.update(chunk);
      out.write(chunk);
      bytesReceived += chunk.length;
      updateDownloadProgress();
    }));
    request.on("end", this.wrapCallback(() => {
      out.done();
      if (hasher.digest("hex").slice(0, 32) !== this.packageId) {
        throw new Error("Package hash did not match.");
      }
      done = true;
      delete this.downloadRequest;
      this.updateProgress("unpack");
      out.saveAs(this.packageId).then(this.wrapCallback((info) => {
        this.appId = info.appId;
        this.authorPgpKeyFingerprint = info.authorPgpKeyFingerprint;
        this.done(info.manifest);
      }), this.wrapCallback((err) => {
        throw err;
    }));
    request.on("error", this.wrapCallback((err) => { throw err; }));
    this.downloadRequest = request;

Fixed code:
  this.route("newAdminNetworking", {
    path: "/admin/networking",
    controller: newAdminRoute,
  });
          {{#adminNavItem routeName="newAdminNetworking"}}
            <div class="item-name">Networking</div>
            <div class="item-subtext">Control how the network is accessed.</div>
          {{/adminNavItem}}
// Sandstorm - Personal Cloud Sandbox
// Copyright (c) 2017 Sandstorm Development Group, Inc. and contributors
// All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//   http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

import { PRIVATE_IPV4_ADDRESSES, PRIVATE_IPV6_ADDRESSES } from "/imports/constants.js";

const DEFAULT_IP_BLACKLIST = PRIVATE_IPV4_ADDRESSES.concat(PRIVATE_IPV6_ADDRESSES).join("\n");

Template.newAdminNetworking.onCreated(function () {
  this.originalIpBlacklist = globalDb.getSettingWithFallback("ipBlacklist", "");
  this.ipBlacklist = new ReactiveVar(this.originalIpBlacklist);
  this.formState = new ReactiveVar({
    state: "edit", // Other allowed states: "submitting", "success", and "error"
    message: undefined,
  });
});

Template.newAdminNetworking.helpers({
  ipBlacklist() {
    const instance = Template.instance();
    return instance.ipBlacklist.get();
  },

  saveDisabled() {
    const instance = Template.instance();
    return instance.formState.get().state === "submitting" ||
           instance.ipBlacklist.get() === instance.originalIpBlacklist;
  },

  restoreDisabled() {
    const instance = Template.instance();
    return instance.ipBlacklist.get() === DEFAULT_IP_BLACKLIST;
  },

  hasError() {
    const instance = Template.instance();
    return instance.formState.get().state === "error";
  },

  hasSuccess() {
    const instance = Template.instance();
    return instance.formState.get().state === "success";
  },

  message() {
    const instance = Template.instance();
    return instance.formState.get().message;
  },
});

Template.newAdminNetworking.events({
  "submit .admin-networking"(evt) {
    evt.preventDefault();
    evt.stopPropagation();
  },

  "input textarea.ip-blacklist"(evt) {
    evt.preventDefault();
    evt.stopPropagation();
    const instance = Template.instance();
    instance.ipBlacklist.set(evt.currentTarget.value);
  },

  "click .save"(evt) {
    const instance = Template.instance();
    const newIpBlacklist = instance.ipBlacklist.get();

    instance.formState.set({
      state: "submitting",
      message: "",
    });

    Meteor.call("setSetting", undefined, "ipBlacklist", newIpBlacklist, (err) => {
      if (err) {
        instance.formState.set({
          state: "error",
          message: err.message,
        });
      } else {
        instance.originalIpBlacklist = newIpBlacklist;
        instance.formState.set({
          state: "success",
          message: "Saved changes.",
        });
      }
    });
  },

  "click .restore"(evt) {
    const instance = Template.instance();
    instance.ipBlacklist.set(DEFAULT_IP_BLACKLIST);
  },
});
<template name="newAdminNetworking">
  <h1>
    <ul class="admin-breadcrumbs">
      <li>{{#linkTo route="newAdminRoot"}}Admin{{/linkTo}}</li>
      <li>Security</li>
    </ul>
  </h1>

  {{#if hasSuccess}}
    {{#focusingSuccessBox}}
      {{message}}
    {{/focusingSuccessBox}}
  {{/if}}
  {{#if hasError}}
    {{#focusingErrorBox}}
      {{message}}
    {{/focusingErrorBox}}
  {{/if}}

  <form class="admin-networking">
    <div class="form-group">
      <label>
        Server-side request IP blacklist:
        <textarea class="ip-blacklist" value="{{ ipBlacklist }}"></textarea>
      </label>
      <span class="form-subtext">Users will be prohibited from making requests to these IP addresses. This includes making a request from an app, downloading an SPK file from a user-provided URL, etc. You may specify one IP address or network (in CIDR notation, e.g. "127.0.0.0/8") per line. The default value includes standard local and private network addresses. Note that when an HTTP proxy is in use, this setting may be ignored; the proxy must implement its own blacklist.</span>
    </div>

    {{!-- TODO(someday): Allow whitelisting certain IPs or hosts? --}}
    {{!-- TODO(someday): Configure HTTP proxy here. --}}

    <div class="button-row">
      <button type="submit" class="save" disabled="{{saveDisabled}}">Save</button>
      <button type="button" class="restore" disabled="{{restoreDisabled}}">Restore defaults</button>
    </div>
  </form>
</template>
.admin-networking {
  @extend %standard-form;

  textarea {
    min-height: 200px;
    font-family: monospace;
  }

  .save {
    @extend %button-base;
    @extend %button-primary;
    margin-left: 10px;
  }

  .restore {
    @extend %button-base;
    @extend %button-secondary;
    margin-left: 10px;
  }
}
@import "_admin-networking.scss";
const ACCOUNT_DELETION_SUSPENSION_TIME = 7 * 60 * 60 * 24 * 1000; // 7 days in ms
// Lists below developed from RFC6890, which is an overview of all special addresses.
const PRIVATE_IPV4_ADDRESSES = [
  "10.0.0.0/8",            // RFC1918 reserved for internal network
  "127.0.0.0/8",           // RFC1122 loopback / localhost
  "169.254.0.0/16",        // RFC3927 "link local" (auto-configured LAN in absence of DHCP)
  "172.16.0.0/12",         // RFC1918 reserved for internal network
  "192.168.0.0/16",        // RFC1918 reserved for internal network
];

const PRIVATE_IPV6_ADDRESSES = [
  "::1/128",               // RFC4291 loopback / localhost
  "fc00::/7",              // RFC4193 unique private network
  "fe80::/10",             // RFC4291 "link local" (auto-configured LAN in absence of DHCP)
];

const SPECIAL_IPV4_ADDRESSES = [
  "0.0.0.0/8",             // RFC1122 "this host" / wildcard
  "100.64.0.0/10",         // RFC6598 "shared address space" for carrier-grade NAT
  "192.0.0.0/24",          // RFC6890 reserved for special protocols
  "192.0.2.0/24",          // RFC5737 "example address" block 1 -- like example.com for IPs
  "192.88.99.0/24",        // RFC3068 6to4 relay
  "198.18.0.0/15",         // RFC2544 standard benchmarks
  "198.51.100.0/24",       // RFC5737 "example address" block 2 -- like example.com for IPs
  "203.0.113.0/24",        // RFC5737 "example address" block 3 -- like example.com for IPs
  "224.0.0.0/4",           // RFC1112 multicast
  "240.0.0.0/4",           // RFC1112 multicast / reserved for future use
  "255.255.255.255/32"     // RFC0919 broadcast address
];

const SPECIAL_IPV6_ADDRESSES = [
  "::/128",                // RFC4291 unspecified address / wildcard
  "64:ff9b::/96",          // RFC6052 IPv4-IPv6 translation
  "::ffff:0:0/96",         // RFC4291 IPv4-mapped address
                           // TODO(someday): I don't understand the difference between the above
                           //     two. Both are described as mapping ip4 addresses into the ip6
                           //     space. Perhaps this should be allowed, however, we'd need to
                           //     filter the ip4 address against the ip4 blacklist, so special
                           //     handling would be needed.
  "100::/64",              // RFC6666 discard-only address block
  "2001::/23",             // RFC2928 reserved for special protocols
  "2001:2::/48",           // RFC5180 standard benchmarks
  "2001:db8::/32",         // RFC3849 "example address" block -- like example.com for IPs
  "2001:10::/28",          // RFC4843 ORCHID
  "2002::/16",             // RFC3056 6to4 relay
  "ff00::/8",              // RFC4291 multicast
];

export {
  ACCOUNT_DELETION_SUSPENSION_TIME, PRIVATE_IPV4_ADDRESSES, PRIVATE_IPV6_ADDRESSES,
  SPECIAL_IPV4_ADDRESSES, SPECIAL_IPV6_ADDRESSES
};
import { PRIVATE_IPV4_ADDRESSES, PRIVATE_IPV6_ADDRESSES } from "/imports/constants.js";
function setIpBlacklist(db, backend) {
  if (Meteor.settings.public.isTesting) {
    db.collections.settings.insert({ _id: "ipBlacklist", value: "192.168.0.0/16" });
  } else {
    const defaultIpBlacklist = PRIVATE_IPV4_ADDRESSES.concat(PRIVATE_IPV6_ADDRESSES).join("\n");
    db.collections.settings.insert({ _id: "ipBlacklist", value: defaultIpBlacklist });
  }
}

  setIpBlacklist,
// Sandstorm - Personal Cloud Sandbox
// Copyright (c) 2017 Sandstorm Development Group, Inc. and contributors
// All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//   http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

import { SPECIAL_IPV4_ADDRESSES, SPECIAL_IPV6_ADDRESSES } from "/imports/constants.js";

const Dns = Npm.require("dns");
const Ip = Npm.require("ip");
const Url = Npm.require("url");

const lookupInFiber = Meteor.wrapAsync(Dns.lookup, Dns);

function getNetwork(addr, bits) {
  // npm ip's "mask" and "cidr" functions are broken for ipv6. :(

  const parsed = Ip.toBuffer(addr);

  for (let i = Math.ceil(bits / 8); i < parsed.length; i++) {
    parsed[i] = 0;
  }
  const n = Math.floor(bits / 8);
  if (n < parsed.length) {
    parsed[n] = parsed[n] & (0xff << (8 - bits % 8));
  }

  return parsed;
}

function parseCidr(cidr) {
  // Given a CIDR-format network specification, return a function which, given an address string,
  // returns true if the address is in this network.

  // The "ip" NPM module's CIDR handling unfortunately is very broken for IPv6. Many bugs have been
  // filed but it remains broken.

  try {
    cidr = cidr.trim();
    if (cidr === "") return null;

    const parts = cidr.split('/');
    if (parts.length === 1) {
      // Bare address.
      return addr => Ip.isEqual(cidr, addr);
    } else if (parts.length === 2) {
      const bits = parseInt(parts[1], 10);
      if (bits !== bits) throw new Error("value after slash must be an integer");
      const network = getNetwork(parts[0], bits);
      return addr => {
        return network.equals(getNetwork(addr, bits));
      };
    } else {
      throw new Error("too many slashes");
    }
  } catch (err) {
    console.error("invalid network specification in IP blacklist:", cidr, err);
    return null;
  }
}

SPECIAL_FILTERS = SPECIAL_IPV4_ADDRESSES.concat(SPECIAL_IPV6_ADDRESSES).map(parseCidr);

function ssrfSafeLookup(db, url) {
  // Given an HTTP/HTTPS URL, look up the hostname, verify it doesn't point to a blacklisted IP,
  // then return an object of {url, host}, where `url` has the original hostname substituted with
  // an IP address, and `host` is the original hostname suitable for sending in the `Host` header.

  const parsedUrl = Url.parse(url);

  if (parsedUrl.protocol !== "http:" && parsedUrl.protocol !== "https:") {
    throw new Error("not an HTTP nor HTTPS URL: " + url);
  }

  const addresses = lookupInFiber(parsedUrl.hostname, { all: true, hints: Dns.ADDRCONFIG });

  // TODO(perf): Subscribe to blacklist changes so that we don't have to do a new lookup and
  //   parse each time.
  const blacklist = db.getSettingWithFallback("ipBlacklist", "")
      .split("\n").map(parseCidr).filter(x => x);

  for (let i in addresses) {
    const address = addresses[i];
    if (address.family !== 4 && address.family !== 6) continue;

    let ok = true;
    blacklist.forEach(test => { if (test(address.address)) { ok = false; } });
    SPECIAL_FILTERS.forEach(test => { if (test(address.address)) { ok = false; } });

    if (ok) {
      const host = parsedUrl.host;
      delete parsedUrl.host;
      parsedUrl.hostname = address.address;
      return { url: Url.format(parsedUrl), host };
    }
  }

  if (addresses.length > 0) {
    throw new Meteor.Error(403,
        "can't connect to blacklisted private network address: " + parsedUrl.hostname +
        "; the Sandstorm server admin can change the blacklist in the admin settings");
  } else {
    throw new Meteor.Error(404, "host not found: " + parsedUrl.hostname);
  }
}

function ssrfSafeLookupOrProxy(db, url) {
  // If there is an HTTP proxy, then it will have to do the work of blacklisting IPs, because it's
  // the proxy that does the DNS lookup.
  const httpProxy = process.env.HTTP_PROXY || process.env.http_proxy;
  const httpsProxy = process.env.HTTPS_PROXY || process.env.https_proxy;

  if (httpProxy && url.startsWith("http:")) {
    return { proxy: httpProxy };
  } else if (httpsProxy && url.startsWith("https:")) {
    return { proxy: httpsProxy };
  } else {
    return ssrfSafeLookup(db, url);
  }
}

function ssrfSafeHttp(originalHttpCall, db, method, url, options, callback) {
  if (typeof options === "function") {
    callback = options;
    options = undefined;
  }
  if (!options) options = {};

  if (options.npmRequestOptions && options.npmRequestOptions.proxy) {
    // Request already specifies a different proxy.
    return originalHttpCall(method, url, options, callback);
  }

  const safe = ssrfSafeLookupOrProxy(db, url);

  if (safe.proxy) {
    if (!options.npmRequestOptions) options.npmRequestOptions = {};
    options.npmRequestOptions.proxy = safe.proxy;
    return originalHttpCall(method, url, options, callback);
  } else {
    const safe = ssrfSafeLookup(db, url);
    if (!options.headers) options.headers = {};
    options.headers.host = safe.host;
    options.servername = safe.host.split(":")[0];
    return originalHttpCall(method, safe.url, options, callback);
  }
}

function monkeyPatchHttp(db, HTTP) {
  const original = HTTP.call.bind(HTTP);
  HTTP.call = ssrfSafeHttp.bind(this, original, db);
}

export { ssrfSafeLookup, ssrfSafeLookupOrProxy, monkeyPatchHttp };
import { monkeyPatchHttp } from "/imports/server/networking.js";
monkeyPatchHttp(globalDb, HTTP);

import { ssrfSafeLookup } from "/imports/server/networking.js";
    return inMeteor(() => {
      return {
        session: new Capnp.Capability(new ExternalWebSession(this.url, options, globalDb),
                                      ApiSession)
      };
    });
    // TODO(soon): Support HTTP proxy.
    const safe = ssrfSafeLookup(db, url);

    if (!options) options = {};
    if (!options.headers) options.headers = {};
    options.headers.host = safe.host;
    options.servername = safe.host.split(":")[0];

    const parsedUrl = Url.parse(safe.url);
    this.options = options;
import { ssrfSafeLookup } from "/imports/server/networking.js";
    return inMeteor(() => {
      return ssrfSafeLookup(globalDb, url);
    }).then(safe => {
      return new Promise((resolve, reject) => {
        let requestMethod = Http.request;
        if (safe.url.indexOf("https://") === 0) {
          requestMethod = Https.request;
        } else if (safe.url.indexOf("http://") !== 0) {
          err = new Error("Protocol not recognized.");
          err.nature = "precondition";
          reject(err);
        const options = Url.parse(safe.url);
        options.headers = { host: safe.host };
        options.servername = safe.host.split(":")[0];

        req = requestMethod(options, (resp) => {
          const buffers = [];
          let err;
  
          switch (Math.floor(resp.statusCode / 100)) {
            case 2: // 2xx response -- OK.
              resp.on("data", (buf) => {
                buffers.push(buf);
              });
  
              resp.on("end", () => {
                resolve({
                  content: Buffer.concat(buffers),
                  mimeType: resp.headers["content-type"] || null,
                });
              });
              break;
            case 3: // 3xx response -- redirect.
              resolve(session.httpGet(resp.headers.location));
              break;
            case 4: // 4xx response -- client error.
              err = new Error("Status code " + resp.statusCode + " received in response.");
              err.nature = "precondition";
              reject(err);
              break;
            case 5: // 5xx response -- internal server error.
              err = new Error("Status code " + resp.statusCode + " received in response.");
              err.nature = "localBug";
              reject(err);
              break;
            default: // ???
              err = new Error("Invalid status code " + resp.statusCode + " received in response.");
              err.nature = "localBug";
              reject(err);
              break;
          }
        });
  
        req.on("error", (e) => {
          e.nature = "networkFailure";
          reject(e);
        });
  
        req.setTimeout(15000, () => {
          req.abort();
          err = new Error("Request timed out.");
          err.nature = "localBug";
          err.durability = "overloaded";
          reject(err);
        });
  
        req.end();
import { ssrfSafeLookupOrProxy } from "/imports/server/networking.js";
    inMeteor(this.wrapCallback(function () {
      const safe = ssrfSafeLookupOrProxy(globalDb, this.url);

      let bytesExpected = undefined;
      let bytesReceived = 0;
      const hasher = Crypto.createHash("sha256");
      let done = false;
      const updateDownloadProgress = _.throttle(this.wrapCallback(() => {
        if (!done) {
          if (bytesExpected) {
            this.updateProgress("download", bytesReceived / bytesExpected);
          } else {
            this.updateProgress("download", bytesReceived);
          }
      }), 500);

      const request = safe.proxy
          ? Request.get(this.url, { proxy: safe.proxy })
          : Request.get(safe.url, {
              headers: { host: safe.host },
              servername: safe.host.split(":")[0],
            });

      request.on("response", this.wrapCallback((response) => {
        if ("content-length" in response.headers) {
          bytesExpected = parseInt(response.headers["content-length"]);
        }
      }));
      request.on("data", this.wrapCallback((chunk) => {
        hasher.update(chunk);
        out.write(chunk);
        bytesReceived += chunk.length;
        updateDownloadProgress();
      }));
      request.on("end", this.wrapCallback(() => {
        out.done();
        if (hasher.digest("hex").slice(0, 32) !== this.packageId) {
          throw new Error("Package hash did not match.");
        }
        done = true;
        delete this.downloadRequest;
        this.updateProgress("unpack");
        out.saveAs(this.packageId).then(this.wrapCallback((info) => {
          this.appId = info.appId;
          this.authorPgpKeyFingerprint = info.authorPgpKeyFingerprint;
          this.done(info.manifest);
        }), this.wrapCallback((err) => {
          throw err;
        }));
      request.on("error", this.wrapCallback((err) => { throw err; }));
      this.downloadRequest = request;
    }));