From 6e1e5e69968224511cc48c87a57a7268540b27e7 Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Fri, 19 Jun 2020 17:57:34 +1200 Subject: [PATCH] Fix exploit and add tests for proxy URL validator --- src/lib/utils/proxyurl.js | 32 +++++++++++++++++++++++- src/site/api/proxy.js | 21 ++-------------- test/proxyurl.js | 51 +++++++++++++++++++++++++++++++++++++-- 3 files changed, 82 insertions(+), 22 deletions(-) diff --git a/src/lib/utils/proxyurl.js b/src/lib/utils/proxyurl.js index dbac697..6265297 100644 --- a/src/lib/utils/proxyurl.js +++ b/src/lib/utils/proxyurl.js @@ -1,3 +1,31 @@ +/** + * Check that a host is part of Instagram's CDN. + * @param {string} host + */ +function verifyHost(host) { + const domains = ["fbcdn.net", "cdninstagram.com"] + return domains.some(against => host === against || host.endsWith("." + against)) +} + +/** + * Check that a resource is on Instagram. + * @param {URL} completeURL + */ +function verifyURL(completeURL) { + const params = completeURL.searchParams + if (!params.get("url")) return {status: "fail", value: [400, "Must supply `url` query parameter"]} + try { + var url = new URL(params.get("url")) + } catch (e) { + return {status: "fail", value: [400, "`url` query parameter is not a valid URL"]} + } + // check url protocol + if (url.protocol !== "https:") return {status: "fail", value: [400, "URL protocol must be `https:`"]} + // check url host + if (!verifyHost(url.host)) return {status: "fail", value: [400, "URL host is not allowed"]} + return {status: "ok", url} +} + function proxyImage(url, width) { const params = new URLSearchParams() if (width) params.set("width", width) @@ -23,7 +51,7 @@ function proxyVideo(url) { */ function proxyExtendedOwner(owner) { const clone = {...owner} - clone.profile_pic_url = proxyImage(clone.profile_pic_url) + clone.profile_pic_url = proxyProfilePic(clone.profile_pic_url, clone.id) return clone } @@ -31,3 +59,5 @@ module.exports.proxyImage = proxyImage module.exports.proxyProfilePic = proxyProfilePic module.exports.proxyVideo = proxyVideo module.exports.proxyExtendedOwner = proxyExtendedOwner +module.exports.verifyHost = verifyHost +module.exports.verifyURL = verifyURL diff --git a/src/site/api/proxy.js b/src/site/api/proxy.js index 52887c2..a2ff6a7 100644 --- a/src/site/api/proxy.js +++ b/src/site/api/proxy.js @@ -3,27 +3,10 @@ const sharp = require("sharp") const constants = require("../../lib/constants") const collectors = require("../../lib/collectors") const {request} = require("../../lib/utils/request") +const {verifyURL} = require("../../lib/utils/proxyurl") const db = require("../../lib/db") -require("../../lib/testimports")(constants, request, db) +require("../../lib/testimports")(constants, request, db, verifyURL) -/** - * Check that a resource is on Instagram. - * @param {URL} completeURL - */ -function verifyURL(completeURL) { - const params = completeURL.searchParams - if (!params.get("url")) return {status: "fail", value: [400, "Must supply `url` query parameter"]} - try { - var url = new URL(params.get("url")) - } catch (e) { - return {status: "fail", value: [400, "`url` query parameter is not a valid URL"]} - } - // check url protocol - if (url.protocol !== "https:") return {status: "fail", value: [400, "URL protocol must be `https:`"]} - // check url host - if (!["fbcdn.net", "cdninstagram.com"].some(host => url.host.endsWith(host))) return {status: "fail", value: [400, "URL host is not allowed"]} - return {status: "ok", url} -} function statusCodeIsAcceptable(status) { return (status >= 200 && status < 300) || status === 304 diff --git a/test/proxyurl.js b/test/proxyurl.js index cad85e9..4e330c3 100644 --- a/test/proxyurl.js +++ b/test/proxyurl.js @@ -1,5 +1,5 @@ const tap = require("tap") -const {proxyImage, proxyVideo, proxyExtendedOwner} = require("../src/lib/utils/proxyurl") +const {proxyImage, proxyVideo, proxyProfilePic, proxyExtendedOwner, verifyHost, verifyURL} = require("../src/lib/utils/proxyurl") tap.equal( proxyImage("https://scontent-syd2-1.cdninstagram.com/v/t51.2885-15/e35/p1080x1080/83429487_106792960779790_3699017977444758529_n.jpg?_nc_ht=scontent-syd2-1.cdninstagram.com&_nc_cat=1&_nc_ohc=YYmv6lkrblAAX_9u9Kt&oh=81a70f2b92e70873b5ebc9253e7df937&oe=5EBC230A"), @@ -29,7 +29,7 @@ tap.test("proxy extended owner", childTest => { username: "instagram", is_verified: true, full_name: "Instagram", - profile_pic_url: "/imageproxy?url=https%3A%2F%2Fscontent-syd2-1.cdninstagram.com%2Fv%2Ft51.2885-19%2Fs150x150%2F59381178_2348911458724961_5863612957363011584_n.jpg%3F_nc_ht%3Dscontent-syd2-1.cdninstagram.com%26_nc_ohc%3DTrMM-1zPSA4AX857GJB%26oh%3D15b843b0c1033784492b64b1170cd048%26oe%3D5EC9125D" + profile_pic_url: "/imageproxy?userID=25025320&url=https%3A%2F%2Fscontent-syd2-1.cdninstagram.com%2Fv%2Ft51.2885-19%2Fs150x150%2F59381178_2348911458724961_5863612957363011584_n.jpg%3F_nc_ht%3Dscontent-syd2-1.cdninstagram.com%26_nc_ohc%3DTrMM-1zPSA4AX857GJB%26oh%3D15b843b0c1033784492b64b1170cd048%26oe%3D5EC9125D" }, "owner was proxied" ) @@ -40,3 +40,50 @@ tap.test("proxy extended owner", childTest => { ) childTest.end() }) + +tap.test("check host validation", async childTest => { + { + const url = new URL("https://instance.tld/imageproxy?width=320&url=https%3A%2F%2Fscontent-syd2-1.cdninstagram.com%2Fv%2Ft51.2885-15%2Fe35%2Fc0.180.1440.1440a%2Fs320x320%2F89848538_212928513111869_8518822308890932076_n.jpg%3F_nc_ht%3Dscontent-syd2-1.cdninstagram.com%26_nc_cat%3D105%26_nc_ohc%3DjPxGnFMF_ZoAX_Q_-jf%26oh%3D018fd6d752e15dedbdf132e004356c98%26oe%3D5F178951") + childTest.equal(verifyURL(url).status, "ok", "real cdninstagram syd region") + } + { + const url = new URL("https://instance.tld/imageproxy?url=https%3A%2F%2Fscontent-amt2-1.cdninstagram.com%2Fv%2Ft51.2885-15%2Fe35%2Fp1080x1080%2F101427269_544579909564468_979862184432362192_n.jpg%3F_nc_ht%3Dscontent-amt2-1.cdninstagram.com%26_nc_cat%3D1%26_nc_ohc%3DF-j-3JXkOVgAX_MMJHb%26oh%3De9e0d4ab65a53c15926349bceea9f09d%26oe%3D5F14C4F3") + childTest.equal(verifyURL(url).status, "ok", "real cdninstagram amt region") + } + { + const url = new URL("https://instance.tld/imageproxy?url=https%3A%2F%2Fscontent-frx5-1.cdninstagram.com%2Fv%2Ft51.2885-19%2Fs150x150%2F29090066_159271188110124_1152068159029641216_n.jpg%3F_nc_ht%3Dscontent-frx5-1.cdninstagram.com%26_nc_ohc%3DDC7QZiTfNtsAX_ZN33H%26oh%3D77fb5103f058121f7afac07e8e11af44%26oe%3D5F153193") + childTest.equal(verifyURL(url).status, "ok", "real cdninstagram frx region") + } + { + const url = new URL("https://instance.tld/imageproxy?url=wow im cool") + childTest.same( + verifyURL(url), + { + status: "fail", + value: [400, "`url` query parameter is not a valid URL"], + }, + "invalid url" + ) + } + { + const url = new URL("https://instance.tld/imageproxy?url=http%3A%2F%2Fscontent-frx5-1.cdninstagram.com%2Fv%2Ft51.2885-19%2Fs150x150%2F29090066_159271188110124_1152068159029641216_n.jpg%3F_nc_ht%3Dscontent-frx5-1.cdninstagram.com%26_nc_ohc%3DDC7QZiTfNtsAX_ZN33H%26oh%3D77fb5103f058121f7afac07e8e11af44%26oe%3D5F153193") + childTest.same( + verifyURL(url), + { + status: "fail", + value: [400, "URL protocol must be `https:`"] + }, + "http protocol" + ) + } + { + const url = new URL("https://instance.tld/imageproxy?url=https%3A%2F%2Fnotcdninstagram.com") + childTest.same( + verifyURL(url), + { + status: "fail", + value: [400, "URL host is not allowed"] + } + ) + } +})