CVE-2017-6201: Server-Side Request Forgery (SSRF) (CWE-918)
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.
CWE description: 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.
CVSS v3 base score: 8.1
Published: 2018-02-06T16:29Z
Repository: https://github.com/sandstorm-io/sandstorm
Language: JavaScript
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 date: 2017-03-01 22:27:12 -0800
Commit message: Implement IP blacklist for user-initiated requests.

Diff: diff --git a/shell/client/admin-client.js b/shell/client/admin-client.js
index d341df4e9..d4a4ac742 100644
--- a/shell/client/admin-client.js
+++ b/shell/client/admin-client.js
@@ -148,10 +148,14 @@ Router.map(function () {
   });
   this.route("newAdminPreinstalledApps", {
     path: "/admin/preinstalled-apps",
     controller: newAdminRoute,
   });
+  this.route("newAdminNetworking", {
+    path: "/admin/networking",
+    controller: newAdminRoute,
+  });
   this.route("newAdminMaintenance", {
     path: "/admin/maintenance",
     controller: newAdminRoute,
   });
   this.route("newAdminStatus", {
diff --git a/shell/client/admin/admin.html b/shell/client/admin/admin.html
index ff0bd6301..56a4e6eef 100644
--- a/shell/client/admin/admin.html
+++ b/shell/client/admin/admin.html
@@ -97,10 +97,14 @@
           {{/adminNavItem}}
           {{#adminNavItem routeName="newAdminAppSources"}}
             <div class="item-name">App sources</div>
             <div class="item-subtext">Where to look for apps and app updates.</div>
           {{/adminNavItem}}
+          {{#adminNavItem routeName="newAdminNetworking"}}
+            <div class="item-name">Networking</div>
+            <div class="item-subtext">Control how the network is accessed.</div>
+          {{/adminNavItem}}
         </ul>
       </li>
       <li>
         <h2>Management</h2>
         <ul class="nav-items">
diff --git a/shell/client/admin/networking-client.js b/shell/client/admin/networking-client.js
new file mode 100644
index 000000000..4c5dc596d
--- /dev/null
+++ b/shell/client/admin/networking-client.js
@@ -0,0 +1,105 @@
+// 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);
+  },
+});
diff --git a/shell/client/admin/networking.html b/shell/client/admin/networking.html
new file mode 100644
index 000000000..0cc936004
--- /dev/null
+++ b/shell/client/admin/networking.html
@@ -0,0 +1,37 @@
+<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>
diff --git a/shell/client/styles/_admin-networking.scss b/shell/client/styles/_admin-networking.scss
new file mode 100644
index 000000000..1ac1f40d9
--- /dev/null
+++ b/shell/client/styles/_admin-networking.scss
@@ -0,0 +1,20 @@
+.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;
+  }
+}
diff --git a/shell/client/styles/_new-admin.scss b/shell/client/styles/_new-admin.scss
index 91e199465..25f3d62a7 100644
--- a/shell/client/styles/_new-admin.scss
+++ b/shell/client/styles/_new-admin.scss
@@ -102,5 +102,6 @@
 @import "_admin-preinstalled-apps.scss";
 @import "_admin-maintenance.scss";
 @import "_admin-users.scss";
 @import "_admin-stats.scss";
 @import "_admin-hosting-management.scss";
+@import "_admin-networking.scss";
diff --git a/shell/imports/constants.js b/shell/imports/constants.js
index cd7946526..3d6739e2d 100644
--- a/shell/imports/constants.js
+++ b/shell/imports/constants.js
@@ -12,8 +12,58 @@
 // 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.
 
-ACCOUNT_DELETION_SUSPENSION_TIME = 7 * 60 * 60 * 24 * 1000; // 7 days in ms
+const ACCOUNT_DELETION_SUSPENSION_TIME = 7 * 60 * 60 * 24 * 1000; // 7 days in ms
 
-export { ACCOUNT_DELETION_SUSPENSION_TIME };
+// 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
+};
diff --git a/shell/imports/server/migrations.js b/shell/imports/server/migrations.js
index 658018411..41a48e0f3 100644
--- a/shell/imports/server/migrations.js
+++ b/shell/imports/server/migrations.js
@@ -7,10 +7,11 @@
 import { Meteor } from "meteor/meteor";
 import { _ } from "meteor/underscore";
 import { Match } from "meteor/check";
 import { userPictureUrl, fetchPicture } from "/imports/server/accounts/picture.js";
 import { waitPromise } from "/imports/server/async-helpers.js";
+import { PRIVATE_IPV4_ADDRESSES, PRIVATE_IPV6_ADDRESSES } from "/imports/constants.js";
 
 const Future = Npm.require("fibers/future");
 const Url = Npm.require("url");
 const Crypto = Npm.require("crypto");
 
@@ -775,10 +776,19 @@ function removeFeatureKeys(db, backend) {
 
   db.notifications.remove({ "admin.type": "cantRenewFeatureKey" });
   db.notifications.remove({ "admin.type": "trialFeatureKeyExpired" });
 }
 
+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 });
+  }
+}
+
 // This must come after all the functions named within are defined.
 // Only append to this list!  Do not modify or remove list entries;
 // doing so is likely change the meaning and semantics of user databases.
 const MIGRATIONS = [
   updateLoginStyleToRedirect,
@@ -811,10 +821,11 @@ const MIGRATIONS = [
   clearAppIndex,
   assignEmailVerifierIds,
   setNewServer,
   addMembraneRequirementsToIdentities,
   addEncryptionToFrontendRefIpNetwork,
+  setIpBlacklist,
 ];
 
 const NEW_SERVER_STARTUP = [
   startPreinstallingApps,
 ];
diff --git a/shell/imports/server/networking.js b/shell/imports/server/networking.js
new file mode 100644
index 000000000..352f97bac
--- /dev/null
+++ b/shell/imports/server/networking.js
@@ -0,0 +1,164 @@
+// 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 };
diff --git a/shell/server/00-startup.js b/shell/server/00-startup.js
index 21d3a8e04..df6468084 100644
--- a/shell/server/00-startup.js
+++ b/shell/server/00-startup.js
@@ -18,10 +18,11 @@ import "/imports/db-deprecated.js";
 import { FrontendRefRegistry } from "/imports/server/frontend-ref.js";
 import { PersistentImpl } from "/imports/server/persistent.js";
 import { migrateToLatest } from "/imports/server/migrations.js";
 import { ACCOUNT_DELETION_SUSPENSION_TIME } from "/imports/constants.js";
 import { onInMeteor } from "/imports/server/async-helpers.js";
+import { monkeyPatchHttp } from "/imports/server/networking.js";
 let url = require("url");
 const Capnp = require("capnp");
 
 globalFrontendRefRegistry = new FrontendRefRegistry();
 
@@ -57,10 +58,12 @@ const deleteAccount = global.BlackrockPayments && global.BlackrockPayments.delet
 SandstormDb.periodicCleanup(24 * 60 * 60 * 1000, () => {
   globalDb.deletePendingAccounts(ACCOUNT_DELETION_SUSPENSION_TIME, globalBackend,
     deleteAccount);
 });
 
+monkeyPatchHttp(globalDb, HTTP);
+
 Meteor.startup(() => { migrateToLatest(globalDb, globalBackend); });
 
 // If there are multiple replicas, prefix every log message with our replica number.
 if ("replicaNumber" in Meteor.settings) {
   const prefix = "replica" + Meteor.settings.replicaNumber.toString() + ":";
diff --git a/shell/server/drivers/external-ui-view.js b/shell/server/drivers/external-ui-view.js
index fc5267e93..b68d44a58 100644
--- a/shell/server/drivers/external-ui-view.js
+++ b/shell/server/drivers/external-ui-view.js
@@ -13,10 +13,11 @@
 // 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 { PersistentImpl } from "/imports/server/persistent.js";
+import { ssrfSafeLookup } from "/imports/server/networking.js";
 
 const Future = Npm.require("fibers/future");
 const Capnp = Npm.require("capnp");
 const Url = Npm.require("url");
 const Http = Npm.require("http");
@@ -42,11 +43,16 @@ ExternalUiView = class ExternalUiView {
       options.headers = {
         authorization: "Bearer " + this.token,
       };
     }
 
-    return { session: new Capnp.Capability(new ExternalWebSession(this.url, options), ApiSession) };
+    return inMeteor(() => {
+      return {
+        session: new Capnp.Capability(new ExternalWebSession(this.url, options, globalDb),
+                                      ApiSession)
+      };
+    });
   }
 };
 
 function getOAuthServiceInfo(url) {
   // TODO(soon): Define a table somewhere (probably in a .capnp file) mapping API hosts to OAuth
@@ -285,16 +291,24 @@ const responseCodes = {
 
 ExternalWebSession = class ExternalWebSession extends PersistentImpl {
   constructor(url, options, db, saveTemplate) {
     super(db, saveTemplate);
 
+    // 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];
+
     if (!saveTemplate) {
       // enable backwards-compatibilty tweaks.
       this.fromHackSession = true;
     }
 
-    const parsedUrl = Url.parse(url);
+    const parsedUrl = Url.parse(safe.url);
     this.host = parsedUrl.hostname;
     if (this.fromHackSession) {
       // HackSessionContext.getExternalUiView() apparently ignored any path on the URL. Whoops.
     } else {
       if (parsedUrl.path === "/") {
@@ -306,11 +320,11 @@ ExternalWebSession = class ExternalWebSession extends PersistentImpl {
       }
     }
 
     this.port = parsedUrl.port;
     this.protocol = parsedUrl.protocol;
-    this.options = options || {};
+    this.options = options;
   }
 
   get(path, context) {
     return this._requestHelper("GET", path, context);
   }
diff --git a/shell/server/hack-session.js b/shell/server/hack-session.js
index 2b18b20ed..f8b442b69 100644
--- a/shell/server/hack-session.js
+++ b/shell/server/hack-session.js
@@ -20,10 +20,11 @@ const Https = Npm.require("https");
 const Net = Npm.require("net");
 const Dgram = Npm.require("dgram");
 const Capnp = Npm.require("capnp");
 import { hashSturdyRef, checkRequirements, fetchApiToken } from "/imports/server/persistent.js";
 import { inMeteor, waitPromise } from "/imports/server/async-helpers.js";
+import { ssrfSafeLookup } from "/imports/server/networking.js";
 
 const EmailRpc = Capnp.importSystem("sandstorm/email.capnp");
 const HackSessionContext = Capnp.importSystem("sandstorm/hack-session.capnp").HackSessionContext;
 const Supervisor = Capnp.importSystem("sandstorm/supervisor.capnp").Supervisor;
 const SystemPersistent = Capnp.importSystem("sandstorm/supervisor.capnp").SystemPersistent;
@@ -409,72 +410,80 @@ HackSessionContextImpl = class HackSessionContextImpl extends SessionContextImpl
 
   httpGet(url) {
     const _this = this;
     const session = _this;
 
-    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;
+    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);
         }
-      });
-
-      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);
+        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();
       });
-
-      req.end();
     });
   }
 
   getUserAddress() {
     return inMeteor((function () {
diff --git a/shell/server/installer.js b/shell/server/installer.js
index 9cd301bd3..882831063 100644
--- a/shell/server/installer.js
+++ b/shell/server/installer.js
@@ -19,10 +19,11 @@ import Path from "path";
 import Crypto from "crypto";
 import ChildProcess from "child_process";
 import Url from "url";
 
 import { inMeteor, waitPromise } from "/imports/server/async-helpers.js";
+import { ssrfSafeLookupOrProxy } from "/imports/server/networking.js";
 
 const Request = HTTPInternals.NpmModules.request.module;
 
 const Capnp = Npm.require("capnp");
 
@@ -296,86 +297,71 @@ AppInstaller = class AppInstaller {
     this.uploadStream = globalBackend.cap().installPackage().stream;
     return this.doDownloadTo(this.uploadStream);
   }
 
   doDownloadTo(out) {
-    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);
+    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 = protocol.get(this.url);
-
-    request.on("response", this.wrapCallback((response) => {
-      if ("content-length" in response.headers) {
-        bytesExpected = parseInt(response.headers["content-length"]);
-      }
-    }));
+      }), 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("data", this.wrapCallback((chunk) => {
+        hasher.update(chunk);
+        out.write(chunk);
+        bytesReceived += chunk.length;
+        updateDownloadProgress();
+      }));
 
-    request.on("end", this.wrapCallback(() => {
-      out.done();
+      request.on("end", this.wrapCallback(() => {
+        out.done();
 
-      if (hasher.digest("hex").slice(0, 32) !== this.packageId) {
-        throw new Error("Package hash did not match.");
-      }
+        if (hasher.digest("hex").slice(0, 32) !== this.packageId) {
+          throw new Error("Package hash did not match.");
+        }
 
-      done = true;
-      delete this.downloadRequest;
+        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;
+        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; }));
+      request.on("error", this.wrapCallback((err) => { throw err; }));
 
-    this.downloadRequest = request;
+      this.downloadRequest = request;
+    }));
   }
 
   done(manifest) {
     console.log("App ready:", this.packageId);
     this.updateProgress("ready", 1, undefined, manifest);

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;
    }));