Skip to content

Commit 4e92230

Browse files
committed
Handle sites that pull globals from an iframe
Also improve fixUrl and make it's test run with the rest of the tests
1 parent dfef03b commit 4e92230

File tree

4 files changed

+198
-70
lines changed

4 files changed

+198
-70
lines changed

examples/express/server.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ app.get("/", (req, res) =>
1414
);
1515

1616
// start the server and allow unblocker to proxy websockets:
17-
app.listen(8080).on("upgrade", unblocker.onUpgrade);
17+
app.listen(process.env.PORT || 8080).on("upgrade", unblocker.onUpgrade);
1818
// or
1919
// const http = require("http");
2020
// const server = http.createServer(app);

lib/client-scripts.js

Lines changed: 20 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,6 @@ module.exports = function ({ prefix }) {
2121
maxAge: "10m",
2222
};
2323

24-
const INJECTION_SNIPET = `
25-
<script>var unblocker=${JSON.stringify({ prefix })}</script>
26-
<script src="${clientScriptPathWeb}"></script>`;
27-
2824
function server(req, res, next) {
2925
if (req.url === clientScriptPathWeb) {
3026
send(req, clientScriptPathFs, sendOpts).pipe(res);
@@ -33,30 +29,33 @@ module.exports = function ({ prefix }) {
3329
next();
3430
}
3531

36-
function createStream() {
37-
return new Transform({
38-
decodeStrings: false,
39-
transform: function (chunk, encoding, next) {
40-
// todo: only inject once (maybe make an "injects into head" helper)
41-
var updated = chunk
42-
.toString()
43-
.replace(/(<head[^>]*>)/i, "$1" + INJECTION_SNIPET + "\n");
44-
this.push(updated, "utf8");
45-
next();
46-
},
47-
});
48-
}
49-
5032
function injector(data) {
5133
if (contentTypes.html.includes(data.contentType)) {
52-
data.stream = data.stream.pipe(createStream());
34+
data.stream = data.stream.pipe(
35+
new Transform({
36+
decodeStrings: false,
37+
transform: function (chunk, encoding, next) {
38+
// todo: only inject once (maybe make an "injects into head" helper)
39+
var updated = chunk.toString().replace(
40+
/(<head[^>]*>)/i,
41+
`$1
42+
<script src="${clientScriptPathWeb}"></script>
43+
<script>unblockerInit(${JSON.stringify({
44+
prefix,
45+
url: data.url,
46+
})}, window);</script>
47+
`
48+
);
49+
this.push(updated, "utf8");
50+
next();
51+
},
52+
})
53+
);
5354
}
5455
}
5556

56-
injector.createStream = createStream;
5757
return {
5858
server,
5959
injector,
60-
createStream, // for testing
6160
};
6261
};

lib/client/unblocker-client.js

Lines changed: 159 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,50 @@
1-
(function (exports) {
1+
(function (global) {
22
"use strict";
3-
/*global unblocker*/
43

54
// todo:
6-
// - fetch
7-
// - history API
85
// - postMessage
96
// - open
107
// - split each part into separate files (?)
118
// - wrap other JS and provide proxies to fix writes to window.location and document.cookie
129
// - will require updating contentTypes.html.includes(data.contentType) to include js
1310
// - that, in turn will require decompressing js....
11+
// call() and apply() on `this || original_thing`
12+
// prevent a failure in one initializer from stopping subsequent initializers
13+
14+
function fixUrl(urlStr, config, location) {
15+
var currentRemoteHref;
16+
if (location.pathname.substr(0, config.prefix.length) === config.prefix) {
17+
currentRemoteHref =
18+
location.pathname.substr(config.prefix.length) +
19+
location.search +
20+
location.hash;
21+
} else {
22+
// in case sites (such as youtube) manage to bypass our history wrapper
23+
currentRemoteHref = config.url;
24+
}
1425

15-
console.log("begin unblocker client scripts", unblocker);
26+
// check if it's already proxied (root-relative)
27+
if (urlStr.substr(0, config.prefix.length) === config.prefix) {
28+
return urlStr;
29+
}
1630

17-
function fixUrl(target, prefix, location) {
18-
var currentRemoteHref =
19-
location.pathname.substr(prefix.length) + location.search + location.hash;
20-
var url = new URL(target, currentRemoteHref);
31+
var url = new URL(urlStr, currentRemoteHref);
2132

22-
//todo: handle already proxied urls (will be important for checking current dom)
33+
// check if it's already proxied (absolute)
34+
if (
35+
url.origin === location.origin &&
36+
url.pathname.substr(0, config.prefix.length) === config.prefix
37+
) {
38+
return urlStr;
39+
}
2340

24-
// don't break data: urls
25-
if (url.protocol === "data:") {
26-
return target;
41+
// don't break data: urls, about:blank, etc
42+
// todo: do modify ws: and wss: protocols
43+
if (url.protocol !== "http:" && url.protocol !== "https:") {
44+
return urlStr;
2745
}
2846

29-
// sometimes websites are tricky
47+
// sometimes websites are tricky and use the current host or hostname + a relative url
3048
// check hostname (ignoring port)
3149
if (url.hostname === location.hostname) {
3250
var currentRemoteUrl = new URL(currentRemoteHref);
@@ -36,47 +54,101 @@
3654
url.protocol = currentRemoteUrl.protocol;
3755
// todo: handle websocket protocols
3856
}
39-
return prefix + url.href;
57+
return config.prefix + url.href;
4058
}
4159

42-
function initXMLHttpRequest(config) {
43-
var _XMLHttpRequest = XMLHttpRequest;
60+
function initXMLHttpRequest(config, window) {
61+
if (!window.XMLHttpRequest) return;
62+
var _XMLHttpRequest = window.XMLHttpRequest;
4463

4564
window.XMLHttpRequest = function (opts) {
4665
var xhr = new _XMLHttpRequest(opts);
4766
var _open = xhr.open;
4867
xhr.open = function () {
4968
var args = Array.prototype.slice.call(arguments);
50-
args[1] = fixUrl(args[1], config.prefix, location);
69+
args[1] = fixUrl(args[1], config, location);
5170
return _open.apply(xhr, args);
5271
};
5372
return xhr;
5473
};
5574
}
5675

57-
function initCreateElement(config) {
58-
var prefix = config.prefix;
59-
var _createElement = document.createElement;
60-
61-
document.createElement = function (tagName, options) {
62-
var element = _createElement.call(document, tagName, options);
63-
// todo: whitelist elements with href or src attributes and only check those
64-
setTimeout(function () {
65-
if (element.src) {
66-
element.src = fixUrl(element.src, prefix, location);
67-
}
68-
if (element.href) {
69-
element.href = fixUrl(element.href, prefix, location);
70-
}
71-
// todo: support srcset and ..?
72-
}, 0);
73-
// todo: handle urls that aren't set immediately
76+
function initFetch(config, window) {
77+
if (!window.fetch) return;
78+
var _fetch = window.fetch;
79+
80+
window.fetch = function (resource, init) {
81+
if (resource.url) {
82+
resource.url = fixUrl(resource.url, config, location);
83+
} else {
84+
resource = fixUrl(resource.toString(), config, location);
85+
}
86+
return _fetch(resource, init);
87+
};
88+
}
89+
90+
function initCreateElement(config, window) {
91+
if (!window.document || !window.document.createElement) return;
92+
var _createElement = window.document.createElement;
93+
94+
window.document.createElement = function (tagName, options) {
95+
if (tagName.toLowerCase() === "iframe") {
96+
initAppendBodyIframe(config, window);
97+
}
98+
var element = _createElement.call(window.document, tagName, options);
99+
Object.defineProperty(element, "src", {
100+
set: function (src) {
101+
delete element.src; // remove this setter so we don't get stuck in an infinite loop
102+
element.src = fixUrl(src, config, location);
103+
},
104+
configurable: true,
105+
});
106+
Object.defineProperty(element, "href", {
107+
set: function (href) {
108+
delete element.href; // remove this setter so we don't get stuck in an infinite loop
109+
element.href = fixUrl(href, config, location);
110+
},
111+
configurable: true,
112+
});
113+
// todo: consider restoring the setter in case the client js changes the value later...
114+
// todo: support srcset
74115
return element;
75116
};
76117
}
77118

78-
function initWebsockets(config) {
79-
var _WebSocket = WebSocket;
119+
// js on some sites, such as youtube, uses an iframe to grab native APIs such as history, so we need to fix those also.
120+
// document.body isn't available when this script is first executed,
121+
// so we'll also try when createElement is called, but set a flag to ensure it only installs once
122+
function initAppendBodyIframe(config, window) {
123+
if (
124+
!window.document ||
125+
!window.document.body ||
126+
!window.document.body.appendChild ||
127+
window.document.body.unblockerIframeAppendListenerInstalled
128+
) {
129+
return;
130+
}
131+
132+
var _appendChild = window.document.body.appendChild;
133+
134+
window.document.body.appendChild = function (element) {
135+
var ret = _appendChild.call(window.document.body, element);
136+
if (
137+
element.tagName &&
138+
element.tagName.toLowerCase() === "iframe" &&
139+
element.src === "about:blank" &&
140+
element.contentWindow
141+
) {
142+
initForWindow(config, element.contentWindow);
143+
}
144+
return ret;
145+
};
146+
window.document.body.unblockerIframeAppendListenerInstalled = true;
147+
}
148+
149+
function initWebSockets(config, window) {
150+
if (!window.WebSocket) return;
151+
var _WebSocket = window.WebSocket;
80152
var prefix = config.prefix;
81153
var proxyHost = location.host;
82154
var isSecure = location.protocol === "https";
@@ -118,13 +190,56 @@
118190
};
119191
}
120192

121-
initXMLHttpRequest(unblocker);
122-
initCreateElement(unblocker);
123-
initWebsockets(unblocker);
193+
// todo: figure out how youtube bypasses this
194+
// notes: look at bindHistoryStateFunctions_ - it looks like it checks the contentWindow.history of an iframe *fitst*, then it's __proto__, then the global history api
195+
// - so, we need to inject this into iframes also
196+
function initPushState(config, window) {
197+
if (!window.history || !window.history.pushState) return;
198+
199+
var _pushState = window.history.pushState;
200+
window.history.pushState = function (state, title, url) {
201+
if (url) {
202+
url = fixUrl(url, config, location);
203+
config.url = new URL(url, config.url);
204+
return _pushState.call(history, state, title, url);
205+
}
206+
};
207+
208+
if (!window.history.replaceState) return;
209+
var _replaceState = window.history.replaceState;
210+
window.history.replaceState = function (state, title, url) {
211+
if (url) {
212+
url = fixUrl(url, config, location);
213+
config.url = new URL(url, config.url);
214+
return _replaceState.call(history, state, title, url);
215+
}
216+
};
217+
}
124218

125-
console.log("unblocker client scripts initialized");
219+
function initForWindow(config, window) {
220+
console.log("begin unblocker client scripts", config, window);
221+
initXMLHttpRequest(config, window);
222+
initFetch(config, window);
223+
initCreateElement(config, window);
224+
initAppendBodyIframe(config, window);
225+
initWebSockets(config, window);
226+
initPushState(config, window);
227+
if (window === global) {
228+
// leave no trace
229+
delete global.unblockerInit;
230+
}
231+
console.log("unblocker client scripts initialized");
232+
}
126233

127-
// export things for testing if loaded via commonjs
128-
exports.fixUrl = fixUrl;
234+
// either export things for testing or put the init method into the global scope to be called
235+
// with config by the next script tag in a browser
129236
/*globals module*/
130-
})((typeof module === "object" && module.exports) || {});
237+
if (typeof module === "undefined") {
238+
global.unblockerInit = initForWindow;
239+
} else {
240+
module.exports = {
241+
initForWindow: initForWindow,
242+
fixUrl: fixUrl,
243+
};
244+
}
245+
})(this); // window in a browser, global in node.js
Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
"use strict";
22
const { test } = require("tap");
3-
const { fixUrl } = require("../../lib/client/unblocker-client.js");
4-
const proxy = "http://localhost";
53
const prefix = "/proxy/";
4+
const proxy = "http://localhost";
65
const target = "http://example.com/page.html?query#hash";
76
const location = new URL(proxy + prefix + target);
87

8+
const config = (global.unblocker = { prefix, url: target });
9+
const { fixUrl } = require("../lib/client/unblocker-client.js");
10+
911
// 1x1 transparent gif
1012
const pixel =
1113
" data:image/gif;base64,R0lGODlhAQABAIAAAP///wAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw==";
@@ -36,16 +38,28 @@ const testCases = [
3638
{ url: "http://localhost/path", expected: "/proxy/http://example.com/path" },
3739
// don't break data: urls
3840
{ url: pixel, expected: pixel },
41+
// don't break protocol-relative urls
42+
{ url: "//example.com/foo", expected: "/proxy/http://example.com/foo" },
43+
// don't break about:blank urls
44+
{ url: "about:blank", expected: "about:blank" },
45+
// don't break already proxied URLs
46+
{
47+
url: proxy + prefix + "http://example.com/foo",
48+
expected: proxy + prefix + "http://example.com/foo",
49+
},
50+
{
51+
url: prefix + "http://example.com/foo",
52+
expected: prefix + "http://example.com/foo",
53+
},
3954
// todo: port numbers
4055
// todo: more https tests
4156
// todo: websockets(?)
42-
// todo: already proxied input url
4357
];
4458

4559
testCases.forEach((tc) => {
4660
test(JSON.stringify(tc), (t) => {
4761
// todo: replace || with ??
48-
const actual = fixUrl(tc.url, tc.prefix || prefix, tc.location || location);
62+
const actual = fixUrl(tc.url, tc.config || config, tc.location || location);
4963
t.equal(actual, tc.expected);
5064
t.end();
5165
});

0 commit comments

Comments
 (0)