Получение кроссдоменных данных в Google Chrome через юзерскрипт (обход бага)

в 13:20, , рубрики: bug, chrome, crossdomain, Google Chrome, greasemonkey, javascript, postMessage, userscripts, юзерскрипты, метки: , , , , ,

В Хроме и Хромиуме уже 2.5 года существует баг отсутствия кроссдоменного доступа к другому фрейму из контекстного скрипта (юзерскрипта). То, что нормально работает в скрипте обычной страницы, например, межсайтовая передача данных с помощью postMessage и что без проблем работает в других браузерах, в Хроме иногда считается «ограничением безопасности», но на самом деле это обычный и признанный баг, отмеченный с 4-й версии.

Известно, что подобные проблемы решаются в расширениях Хрома, когда им пропишут права доступа, но суть вопроса в том, что дополнительных прав доступа для таких рядовых задач не требуется, нужен всего лишь обход бага в одном браузере. И тогда мы одним файлом можем записать расширение, работающее во всех браузерах, поддерживающих юзерскрипты. Пример такой задачи, не решаемой обычными скриптами — получение данных о числе «лайков» из кнопки Google Plus (без специальной авторизации в Google Apps и без серверных технологий). Для такой и подобных задач, в которых «вендор» не предоставляет API, нужен (необходим) юзерскрипт, и он не обязан быть индивидуальным для каждого браузера, как этого требуют расширения.

Пример получения числа «лайков» из кнопки Google+ в юзерскрипте, работающий во всех браузерах (кроме IE), имеется в HabrAjax. Там значение «лайков» читается внутри фрейма внедрённым скриптом и выносится наружу для более компактного отобращения прямо на поверхности кнопки. Боковая сноска для 4-5-значного числа шириной около 50 пикс. скрывается стилями, чем сильно экономится место на кнопку. Ниже — скриншот.
Получение кроссдоменных данных в Google Chrome через юзерскрипт (обход бага)

Как получают данные из кроссдоменного фрейма в остальных браузерах

Данные получают достаточно просто, используя метод postMessage, поддерживаемый в Firefox 3+, IE8+, Safari4+, Opera 9.5+, Chrome1+. Для более старых браузеров пользуются хаками типа Iframe hash, Iframe name. В окне-приёмнике ставят слушатель события "message":

window.addEventListener("message", /*Function*/receiveMessage, false);

В окне- (или фрейме-) источнике выполняют отправку текстового сообщения:

(target_window).postMessage(/*Srting*/data, /*Srting*/domainTarget);

Контекст источника должен указывать на целевое окно, а во втором аргументе рекомендуется указывать явно в целях безопасности домен приёмника, а не писать строку, содержащую звёздочку (в смысле, «любой домен»). Тут-то в Хроме в контекстных скриптах всплывает баг — браузер должен обеспечить понимание объекта (целевое окно).postMessage, и в обычных скриптах в нём так и происходит. Для юзерскриптов у Хрома возникает баг неопределённости объекта (целевое окно).postMessage, если окно содержит другой домен — иной, чем источник сообщения. Поэтому к достаточно стройной схеме передачи сообщения требуется «костыль».

Дополнение процедуры кроссдоменной передачи для Хрома

На стороне приёмника — никаких дополнений, потому что проблема не в нём. В источнике используем загрузку скрипта в окружение window.
Во внедрённом в чужой фрейм скрипте читаем, ожидая появления, нужные нам данные.

if(gPlusFrame){
	/**
	 * check occurrence of third-party event with growing interval
	 * @constructor
	 * @param{Number} t start period of check
	 * @param{Number} i number of checks
	 * @param{Number} m multiplier of period increment
	 * @param{Function} checkOccur event handler
	 * @param{Function} check event condition
	 */
	var Tout = function(h){
		var th = this; 
		(function(){
			if((h.dat = h.check() )) //data place
				h.occur();
			else
				if(h.i-- >0)
					th.ww = setTimeout(arguments.callee, (h.t *= h.m) );
		})();
	};
	new Tout({t:320, i:6, m: 1.6
		,occur: function(){
			var id = location.hash.match(/(?|#|&)id=([^&]+)/) //frame id [or name]
				, w = win;
			id = id && id.length && id[2];
			var s = w.JSON && w.JSON.stringify && w.JSON.stringify( //must supported earlier
					{likes: this.dat.innerHTML, frme: id}) //data format
				, pHost = (function(a){ //host extract from parameter (#|&)parent
						if(!a.match(/^https?:///)) return'';
						var b = document.createElement('a');
						b.href = a;
						b.pathname = b.search = b.hash ='';
						return b.href.replace(//??#?$/,'')
					})( decodeURIComponent( (w.location.href.match(/.*(?|#|&)parent=([^&]+)/) ||[])[2] ||'') );
			try{
			//'s'.wcl(s)
				if(!isChrome || w.parent && w.parent.postMessage){
					s && w.parent.postMessage(s, pHost); //all browsers except Chrome
					//wcl('postpost')
				}else if(s)
					winEval(function(args){
						var w = window
							, p1 = arguments[0]
							, p2 = arguments[1];
						if(w.postMessage && p1 && w != w.parent){
							function wpm(){
								w.parent.postMessage(p1, p2); //msg with a glance Chrome bug
							}
							w.document.all ? w.setTimeout(wpm, 0) : wpm();
						}
					}, [s, pHost]);
			}catch(er){wcl(er)}
		}
		,check: function(){
			return document && document.querySelector('#aggregateCount');
		}
	});
}

И при обнаружении требуемого данного (число лайков) запускается часть приведённого кода, начинающаяся с «try{». Для всех браузеров, кроме Хрома, выполняется создание события postMessage() в одну строчку. Для Хрома выполняется обход бага, описанный в функции winEval().

/**
 * evaluate script in window scope
 * @param{Function} fs function or string is body of function
 * @param{String|Array} s string or array of strings for arguments
 * @param{Boolean} noOnce not delete script after exec
 */
var winEval = function(fs, s, noOnce){ //exec function/text in other scope
		s = (s ||[]) instanceof Array? s ||[] : [s]; //wrap by array
		var fs2 = typeof fs=='function'
			? (fs +'').replace(/(^s*functions*([^)]*)s*{s*|s*}s*$)/g,'') //clean wrapper
			: fs
			, as ='';
		for(var i =0, sL =s.length; i < sL; i++) //sequential array
			as += (i?',':'') +"'"+ s[i].replace(/'/g,"\'").replace(/(rn|r|n)/g,"\n") +"'";
		fs = '(function(){'+ fs2 +'}).apply(window,['+ as +']);';
		//'fs'.wcl(fs, fs2)
		var d = document
			, scr = d.createElement('script');
		scr.setAttribute('type','application/javascript');
		scr.textContent = fs;
		var dPlace = d.body || d.getElementsByTagName('head') && d.getElementsByTagName('head')[0];
		dPlace.appendChild(scr);
		if(!noOnce) dPlace.removeChild(scr);
	};

Вот и все премудрости обхода бага. Как видно, требуется дополнительных 30-40 строчек. Есть одно утешение, что эти строчки представляют собой функции, которые могут пригодиться и в других местах юзерскрипта.

Если потребуется транспорт данных в другую сторону и на ней тоже потребуется юзерскрипт (если нет возможности написать обычный скрипт на странице), точно такое же дополнение будет нужно и для второго домена. Логично использовать ту же самую процедуру и тот же самый скрипт, добавив в мета-директиву его второй домен.

Требования к формату фреймов или скриптов

Метод postMessage требует знания домена и пути к целевому окну. Поэтому в юзерскрипт требуется передать каким-то образом эти 2 параметра. Они должны быть указаны или явно (например, parent), или быть взяты из доступного окружения. Так как скрипт запускается в кроссдоменном фрейме (или окне) и не имеет (для Хрома) возможности доступа к другим окнам, на практике указывают домен в URL фрейма (так сделано, например, в кнопке Google+). Поэтому предположим, что имя домена целевого окна записано в URl в формате /.*(?|#|&)parent=([^&]+)/ (это — рег. выражение, которое распознаёт параметр. Например, виджет будет вызван в фрейме с URL:
some-widget-site.com/abcd.php?a=1&b=2&parent=http://some-my-site.com/some-path.
Или то же самое, но будет добавлен якорь:
some-widget-site.com/abcd.php?a=1&b=2#parent=http://some-my-site.com/some-path.
Если нет такой возможности задать домен цели, скрипт должен быть переписан так, чтобы задать домен явно или другим путём (как вариант — принять юзерскриптом имя домена через тот же postMessage из верхнего окна). В дальнейшем предполагается, что домен указан в параметре parent. Так, в частности, сделано в кнопке Google+.

Обратим внимание, что этот сложный механизм будет работать во всех браузерах, но он требует больше ресурсов, т.к. неявно выполняется eval(), поэтому загрузку скрипта в [window scope] следует делать только там, где необходимо, а именно, в браузере Chrome, до тех пор, пока будет существовать баг 20773. Поэтому в рабочем скрипте сделано ветвление с проверкой доступности postMessage в кроссдоменном фрейме и выполнение обхода бага, если метод недоступен.

В скрипте имеется пара полезных функций, которые бывают нужны в других местах юзерскрипта и могли бы быть взяты как библиотечные:
1) загрузка программ в окружение [window scope] ([global_scope]);
2) слежение за появлением независимых асинхронных данных с помощью замедляющегося таймера.

Вторая процедура — не лучшее решение, но иногда нет иной возможности узнать о наступлении независимых от своего скрипта условий, как в случае проверки результатов работы стороннего виджета. К такому случаю как раз относится чтение числа «лайков» в фрейме Google+.

PS. Админы, переименуйте блог GreaseMonkey, пожалуйста, наконец-то, в «Юзерскрипты».

Автор: spmbt


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js