CVE-2017-6201 (published 2018-02-06T16:29Z)

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 v2 base score: 5.5 (AV:N/AC:L/Au:S/C:P/I:P/A:N)
CVSS v3 base score: 8.1, HIGH (CVSS:3.0/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:N)

Weakness information:
cwe_id: CWE-918
cwe_name: Server-Side Request Forgery (SSRF)
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.
extended_description: By providing URLs to unexpected hosts or ports, attackers can make it appear that the server is sending the request, possibly bypassing access controls such as firewalls that prevent the attackers from accessing the URLs directly. The server can be used as a proxy to conduct port scanning of hosts in internal networks, use other URLs such as that can access documents on the system (using file://), or use other protocols such as gopher:// or tftp://, which may provide greater control over the contents of requests.
         

Fix information:
repo_url: https://github.com/sandstorm-io/sandstorm
commit_details: repo_url: https://github.com/sandstorm-io/sandstorm
author: Kenton Varda
author_date: 2017-03-01 22:27:12-08:00
msg: Implement IP blacklist for user-initiated requests.
repository: repo_url: https://github.com/sandstorm-io/sandstorm
repo_name: sandstorm-io/sandstorm
description: Sandstorm is a self-hostable web productivity suite. It's implemented as a security-hardened web app package manager.
repo_language: JavaScript
owner: sandstorm-io
file_changes: filename: admin-client.js
change_type: ModificationType.MODIFY
diff: @@ -150,6 +150,10 @@ Router.map(function () {
     path: "/admin/preinstalled-apps",
     controller: newAdminRoute,
   });
+  this.route("newAdminNetworking", {
+    path: "/admin/networking",
+    controller: newAdminRoute,
+  });
   this.route("newAdminMaintenance", {
     path: "/admin/maintenance",
     controller: newAdminRoute,

programming_language: TypeScript
filename: admin.html
change_type: ModificationType.MODIFY
diff: @@ -99,6 +99,10 @@
             <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>

programming_language: HTML
filename: networking-client.js
change_type: ModificationType.ADD
diff: @@ -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);
+  },
+});

programming_language: TypeScript
filename: networking.html
change_type: ModificationType.ADD
diff: @@ -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>

programming_language: HTML
filename: _admin-networking.scss
change_type: ModificationType.ADD
diff: @@ -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;
+  }
+}

programming_language: CSS
filename: _new-admin.scss
change_type: ModificationType.MODIFY
diff: @@ -104,3 +104,4 @@
 @import "_admin-users.scss";
 @import "_admin-stats.scss";
 @import "_admin-hosting-management.scss";
+@import "_admin-networking.scss";

programming_language: CSS
filename: constants.js
change_type: ModificationType.MODIFY
diff: @@ -14,6 +14,56 @@
 // 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
+};

programming_language: Go
filename: migrations.js
change_type: ModificationType.MODIFY
diff: @@ -9,6 +9,7 @@ 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");
@@ -777,6 +778,15 @@ function removeFeatureKeys(db, backend) {
   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.
@@ -813,6 +823,7 @@ const MIGRATIONS = [
   setNewServer,
   addMembraneRequirementsToIdentities,
   addEncryptionToFrontendRefIpNetwork,
+  setIpBlacklist,
 ];
 
 const NEW_SERVER_STARTUP = [

programming_language: JavaScript
filename: networking.js
change_type: ModificationType.ADD
diff: @@ -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 };

programming_language: TypeScript
filename: 00-startup.js
change_type: ModificationType.MODIFY
diff: @@ -20,6 +20,7 @@ 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");
 
@@ -59,6 +60,8 @@ SandstormDb.periodicCleanup(24 * 60 * 60 * 1000, () => {
     deleteAccount);
 });
 
+monkeyPatchHttp(globalDb, HTTP);
+
 Meteor.startup(() => { migrateToLatest(globalDb, globalBackend); });
 
 // If there are multiple replicas, prefix every log message with our replica number.

programming_language: JavaScript
filename: external-ui-view.js
change_type: ModificationType.MODIFY
diff: @@ -15,6 +15,7 @@
 // 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");
@@ -44,7 +45,12 @@ ExternalUiView = class ExternalUiView {
       };
     }
 
-    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)
+      };
+    });
   }
 };
 
@@ -287,12 +293,20 @@ 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.
@@ -308,7 +322,7 @@ ExternalWebSession = class ExternalWebSession extends PersistentImpl {
 
     this.port = parsedUrl.port;
     this.protocol = parsedUrl.protocol;
-    this.options = options || {};
+    this.options = options;
   }
 
   get(path, context) {

programming_language: TypeScript
filename: hack-session.js
change_type: ModificationType.MODIFY
diff: @@ -22,6 +22,7 @@ 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;
@@ -411,68 +412,76 @@ HackSessionContextImpl = class HackSessionContextImpl extends SessionContextImpl
     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();
     });
   }
 

programming_language: TypeScript
filename: installer.js
change_type: ModificationType.MODIFY
diff: @@ -21,6 +21,7 @@ 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;
 
@@ -298,82 +299,67 @@ AppInstaller = class AppInstaller {
   }
 
   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) {

programming_language: TypeScript