company_banner

Доступ к контенту iFrame с другого домена

    Сегодня я хочу рассказать о том, как мы в своем проекте indexisto.com сделали аналог инструмента Google Webmaster Marker. Напомню, что Marker это инструмент в кабинете Google Webmaster, который позволяет аннотировать ваши страницы Open Graph тегами. Для этого вы просто выделяете мышкой кусок текста на странице и указываете что это title, а это рейтинг. Ваша страница при этом грузится в Iframe в кабинете вебмастера.



    Теперь Google, встретив подобную страницу на вашем сайте, уже знает, что за контент на ней опубликован, и как его красиво распарсить в сущность (статью, товар, видео..)

    Нам был нужен подобный функционал. Задача казалась несложной и исключительно клиентсайд. Однако на практике решение лежит на стыке клиентсайда и серверсайда («чистые» JS программисты могу ничего не знать про различные прокси серверы и очень долго подходить к снаряду). При этом я не нашел в интернетах статью которая описывала бы всю технологию от начала до конца. Также хочется сказать спасибо пользователю BeLove и нашим безопасникам за помощь.



    В нашем случае хотелось чтобы вебмастер мог удобно (пометив мышкой) получить значение xPath к определенным элементам на своей странице.

    Iframe «Same Origin»


    И так в нашей админке человек должен ввести URL страницы своего сайта, мы отобразим ее в iFrame, человек тыкнет мышкой куда надо, мы получим искомый xPath. Все бы ОК, но у нас нет доступа к контенту страницы с другого домена загруженной в iframe в нашей админке (наш домен), из за политики безопасности браузера.

    CORS — Cross origin resource sharing


    Одни люди мне посоветовали использовать CORS. Модная технология которая решает множество проблем с доступом к контенту с другого домена в браузере и позволяет обойти same origin policy ограничения.
    Сайт который хочет дать доступ к своему контенту на страницах чужого домена просто пишет в http заголовок:
    Access-Control-Allow-Origin: http://example.com
    

    А в заголовоке http запроса идущего со страницы другого домена из браузера должно быть поле origin:
    Origin: www.mysupersite.com
    

    понятно что поле origin к запросу браузер дописывает сам. Плюсанем статью на Хабре и увидим что современные браузеры добавляют Origin даже к запросу на тот же самый домен:


    однако:
    1. браузер не проставляет origin в загловке запроса на страницу загружаемую в iframe (кто-нибудь может пояснить почему?)
    2. мы не хотим просить вебмастеров прописывать заголовок Access-Control-Allow-Origin


    Iframe sandbox


    Еще одна модная технология. Sandbox это атрибут тега Iframe. В качестве одного из значений этого атрибута можно выставить значение allow-same-origin. До того как я начал копать эту тему я не знал что точно делает этот аттрибут, а звучало очень заманчиво. Однако атрибут sandbox просто ограничивает то, что может делать страница загруженная в iframe и не имеет отношения к проблеме доступа из родительского документа к содержимому фрейма.

    Конкретно значение allow-same-origin (вернее его отсутствие) всего лишь говорит что iframe нужно всегда расценивать как загруженный с чужого домена (нельзя например из такого фрейма послать AJAX запрос на домен родительского документа)

    Посмотрим как сделано у Google


    Время рисеча того, как сделано у большого брата


    Обратим внимание на аттрибут src элемента iframe: src="https://wmthighlighter.googleusercontent.com/webmasters/data-highlighter/RenderFrame/007....." — наша страница грузится в админку с домена Google. Далее еще более сурово: даже скрипты и картинки в исходном документе прогоняются через прокси. Все src, href… заменены в html на проксированные. Примерно вот так:





    Все ресурсы которые использует ваша страница еще и сохраняются на прокси серверах Гугл. Вот например наш логотип на прокси сервере Google.

    CGIProxy?


    Сразу показалось, чтобы сделать тоже самое нужно поднимать полноценный прокси по типу CGIProxy. Этот прокси сервер делает примерно тоже самое, чем промышляет гугловский wmthighlighter.googleusercontent.com
    Visit the script's URL to start a browsing session. Once you've gotten a page through the proxy, everything it links to will automatically go through the proxy. You can bookmark pages you browse to, and your bookmarks will go through the proxy as they did the first time.


    Свой Proxy!


    Однако, если сузить задачу, написать простой proxy намного проще самому. Дело в том, что делать так делает Google, прогоняя весь контент страницы через прокси совершенно не обязательно. Нам просто нужно чтобы html любой страницы отдавался с нашего домена, а ресурсы можно подгрузить и из оригинального домена. Https мы пока отбросили.
    Задача супер производительности или удобств настроек здесь не стоит, и сделать это можно по быстрому и на чем угодно, от node.js до php. Мы написали сервлет на Java.

    Качаем страницу


    Что должен делать прокси сервлет? Через get параметр получаем url страницы которую нужно загрузить, далее качаем страницу.

    Обязательно определяем кодировку страницы (через http ответ или charset в html) — наш прокси должен ответить в той же кодировке что и страница которую мы загрузили. Так же определим Content-Type на всякий случай, хотя и так понятно что мы получаем страницу в text/html и отдадим ее так же.
            final String url = request.getParameter("url");
            final HttpGet requestApache = new HttpGet(url);
            final HttpClient httpClient = new DefaultHttpClient();
            final HttpResponse responseApache = httpClient.execute(requestApache);
            final HttpEntity entity = responseApache.getEntity();
            final String encoding =  EntityUtils.getContentCharSet( entity );
            final String mime = EntityUtils.getContentMimeType(entity);
            String responseText =  IOUtils.toString(entity.getContent(), encoding);
    

    *Для любителей оценить чужой код: у нас в команде у всех одинаковые настройки форматирования кода eclicpse, и при сохранении файла эклипс сам дописывает ко всем переменным final если они более нигде не меняются. Что кстати довольно удобно в итоге.

    Меняем относительные URL на абсолютные в коде страницы


    Надо пройтись по всем атрибутам с src и href в странице (пути файлов стилей, картинки), и заменить относительные урлы на абсолютные. Иначе страница будет пытаться загрузить картинки с каких-то папок на нашем прокси, которых у нас естественно нет. В любом языке есть готовые классы или можно найти сниппеты кода для этого дела на stackoverflow:
                final URI uri = new URI(url);
                final String host = uri.getHost();
                responseText = replaceRelativeLinks(host,responseText);
    


    Отсылаем html


    Вот и все, прокси сервлет готов. Посылаем ответ, выставив нужную кодировку и mime.
        protected void sendResponse(HttpServletResponse response, String responseText, String encoding, String mime) throws ServletException, IOException {
            response.setContentType(mime);
            response.setCharacterEncoding(encoding);
            response.setStatus(HttpServletResponse.SC_OK);
            response.getWriter().print(responseText );
            response.flushBuffer();
        }
    


    Деплоим и тестим


    Деплоим наш прокси сервлет на том же адресе, что и админка adminpanel.indexisto.com, грузим в наш iframe страницу сайта вебмастера через прокси и все кроссдоменные проблемы исчезают.
    Наш прокси работает по адресу
    http://adminpanel.indexisto.com/highlighter?url=http://habrahabr.ru
    

    — вот так хабр загрузится с нашего домена. Отдаем этот адрес в iframe и пробуем получить доступ к DOM дереву хабра через JS в админке — все работает. CSRF естественно не пройдет так как страница загружена с нашего прокси у которого нет куки.

    Проблемка SSRF


    Загрузим в наш iframe сайт с адресом «localhost» — опа, вот стартовая страница нашего nginx. Попробуем какой-нибудь внутренний (не видный наружу) ресурс в той же сети, что и наш прокси сервер. Например secured_crm.indexisto.com — все на месте.
    Конечно пытаемся запретить эти вещи в нашем прокси, в случае если кто-то пытается запроксировать localhost мы выходим ничего не возвращая:
    if (url.contains("localhost")||url.contains("127")||url.contains("highlighter")||url.contains("file")) {
                LOG.debug("Trying to get local resource. Url = " + url);
                return;
    }
    

    но всех ресурсов сети мы здесь явно не перечислим. Значит надо вынести прокси в совершенно изолированную среду, чтобы машина ничего не видела кроме интернета, себя самой и нашего прокси. Выделяем машину, настраиваем и заводим наш сервлет там.

    Проблемка XSS


    Загрузим в наш iframe свою страничку на которой напишем:
    <script>alert('xss')</script>
    

    Всплывает алерт. Печально. Это можно обойти атрибутом iframe sandbox allow-scripts , однако как быть со старыми браузерами которые этот атрибут не очень понимают? Угнать можно только свои куки, но все равно так оставлять нельзя.
    Выносим сервлет не только на отдельную машину, но и делаем ей отдельный поддомен highlighter.indexisto.com.

    Приплыли, мы разломали наше собственное решение с обходом кроссдоменных ограничений. Теперь мы снова не можем достучаться до контента iframe.

    Интересная мысль.


    Продолжая ресечить решение от гугла я открыл нашу страницу отдающуюся через прокси в отдельном окне



    и обратил внимание на странную ошибку в консоли.
    CrossPageChannel: Can't connect, peer window-object not set. 
    

    Стало ясно что все организованно сложнее, чем просто загрузить страницу в iframe со своего домена. Страницы общаются между собой. Соответственно двигаемся в сторону window.postMessage

    Post Message


    Заставлять внедрять вебмастера в свою страницу наш скрипт который бы обеспечивал выделение элементов страницы мышкой, а потом бы отсылал xPath этих элементов к нам в родительский документ через postMessage было не гуманно. Однако никто не мешает нашему прокси внедрить любые скрипты на загружаемую в iFrame страницу.
    Все необходимые для внедрения скрипты сохраняем в файл, и вставляем их перед закрывающим body:
    final int positionToInsert = responseText.indexOf("</body>");
            final InputStream inputStream = getServletContext().getResourceAsStream("/WEB-INF/inject.js");
            final StringWriter writer = new StringWriter();
            IOUtils.copy(inputStream, writer);
            final String jsToInsert = writer.toString();
            responseText = responseText.substring(0, positionToInsert) + jsToInsert + responseText.substring(positionToInsert, responseText.length());
    

    для пробы вставляем alert — все работает.

    JS часть — подсвечиваем дом элемент под мышкой и получаем xpath


    Окей, переходим собственно к JS который мы вставили на страницу вебмастера.
    Нам необходимо подсвечивать dom элементы над которыми человек водит мышкой. Лучше делать это с помощью shadow так как тогда элемент не будет смещаться, а вся страница прыгать. Вешаем onmouseover на body и смотрим на target события. В этом же обработчике я вычисляю xpath элемента. Вычислять xPath элемента лучше на клик, но никаких тормозов и в такой реализации я не заметил.
    elmFrame.contentWindow.document.body.onmouseover= function(ev){
        ev.target.style.boxShadow = "0px 0px 5px red";
        curXpath = getXPathFromElement(ev.target);
    }
    

    Я не привожу здесь реализацию получения xPath элемента DOM. Существует множество сниппетов того как это сделать. Эти сниппеты можно модифицировать под свои задачи, например вам нужны в xpath только тэги. Или вам нужны id если они есть и классы если нет id — у всех свои требования.

    Вот пример запроксированной главной страницы Хабра с внедренным скриптом:
    http://highlighter.indexisto.com/?md5=6ec7rdHxUfRkrFy55jrJQA==&url=http%3A%2F%2Fhabrahabr.ru&expires=1390468360

    JS часть — обрабатываем клик


    Клик человека на странице в iframe сразу же «гасится» (перехода по ссылке в iframe не произойдет). А так же мы посылаем в родительское окно строку полученного xPath (мы его сохранили еще на этапе вождения мышкой над элементом)
    document.body.onclick = function(ev){
    		window.parent.postMessage( curXpath, "*");
    		ev.preventDefault();
    		ev.stopPropagation();
    	}
    


    Profit!


    Вот и все, теперь в нашей админке вебмастер может намного проще быстро получить xpath пути к элементам на своих страницах.


    Добавим еще секьюрности


    Окей, у нас все заработало, однако есть момент с тем, что наш прокси смотрит в мир совершенно незащищенный. Кто угодно может запроксировать что угодно.

    Ставим перед прокси nginx, он слушает 80 порт, сам прокси убираем на другой порт. Все другие порты кроме 80 закрываем от внешнего мира.

    Теперь сделаем так чтобы прокси работал только через панель администратора. В момент когда вебмастер вводит URL своего сайта мы быстро бегаем на сервер где генерим md5 хэш от текущего TimeStamp + 1 час, самой URL и суперсекретного когда:
     final String md5Me = timeStampExpires + urlEncoded + "SUPERSECRET";
                    final MessageDigest md = MessageDigest.getInstance("MD5");
                    md.reset();
                    md.update(md5Me.getBytes("UTF-8"));
    
                    String code = Base64.encodeBase64String(md.digest());
                    code = code.replaceAll("/", "_");
                    code = code.replaceAll("\\+","-");
    

    Так же обратите внимание что в коде мы получачаем md5 строку не как привычный hex, а в base64 кодировке, плюс в полученном md5 мы делаем странные замены символов слэша и плюса на подчеркивание и тире.
    Дело в том что ngnix использует base64 Filename Safe Alphabet tools.ietf.org/html/rfc3548#page-6
    А Java дает каноничсекий base64.

    Получив в нашей админке ответ от сервера с секьюрной md5, мы пытаемся загрузить в iframe вот такой url:
    highlighter.indexisto.com/?md5=Dr4u2Yeb3NrBQLgyDAFrHg==&url=http%3A%2F%2Fhabrahabr.ru&expires=1389791582

    Теперь настраиваем модуль nginx HttpSecureLinkModule. Этот модуль сверяет md5 всех параметров которые к нему пришли (в модуле прописан тот же секретный ключ что и в сервлете админки), проверяет не проэкпарйилась ли ссылка и только в этом случае пробрасывает запрос на наш прокси сервлет.

    Теперь никто не может воспользоваться нашим прокси не из админки, а так же не может куда-нибудь вставить запроксированную на наш сервер картинку — она все равно умрет через час.

    That's all folks!

    Гугл в своем инструменте marker естественно ушел намного дальше. Для того чтобы четко идентифицировать элемент на странице, нужно пометить один и тот же элемент (например, заголовок статьи) на нескольких однотипных страницах, чтобы можно было точнее построить xpath и отбросить разные id типа «post-2334» которые явно сработают только на одной странице. В нашей админке пока xpath надо поправить руками, чтобы получить приемлимый результат
    Mail.ru Group
    1752,00
    Строим Интернет
    Поделиться публикацией

    Комментарии 35

      +3
      Для более распространенных случаев, например, размещения приложений и виджетов на сторонних сайтах, хочется порекомендовать Cross-window messaging.

      Не требует костылей в виде скрытого сервер-серверного общения. Не требует больших костылей, называемых проксированием. Поддержка отличная.
        +2
        дак мы это и используем
         window.parent.postMessage( curXpath, "*");
        

        вопрос в том что кто-то должен принять ваше сообщение на строннем сайте.
        В нашем случае вмя история с прокси нужна для того чтобы как раз внедрить в чужой html наш JS который будет слать postMessage
          +1
          Конечно, всё именно так. Потому я и оговорился про более распространенные случаи — где содержимое ифрейма поддается контролю, и его владелец может самостоятельно внедрить в него источник сообщения.
            0
            это да, в идеале так и должно быть )
        0
        Небольшое замечание, может быть, полезное. Наличие/отсутствие base при генерации абсолютных адресов учитывается?
          0
          у нас там все по простому, без base. На самом деле сейчас использовать относительные урлы не модно, и часто приходится смотреть в код всяких сайтов — относительных почти нигде уже нет
          +1
          Круто! буквально вчера делал подобную вещицу. Только задача была несколько иная сделать визуальный редактор для HTML страничек. Есть HTML код, отдельно файлы стилей и скриптов. Ситли и скрипты инжектятся в хтмл + добавляется скрипт по выделению елментов и обмену сообщений все это добро пишется в iframe. У себя по HTML строим еще одну DOM. По клику из ифрейма летит через postMessage css селектором. По селектору находим елемент в DOM делаем изменеия и отсылаем обратно через postMessage в iframe. В iframe визуально отображаем изменеия. По окончании редактирования DOM сохраняем в стрингу и отправляем на сервак — все рады.
            0
            да, неплохая схема. Только не очень понял зачем в родителе держать dom клон того что в iframe (если я правильно понял). Еще вариант целиком редактор страницы заембедить в редактируемую страницу (это к комменту выше)
              0
              В ифрейме отрендеренный дом со скриптами, стилями и картинками например

              real jquery code

              А который мы правим и сохраняйм потом в строку имеет метки куда вставлять скрипты картинки и т.д.

              Есть рендерер, который на все это смотрит и вставляет нужные скрипты и картинки.

              Таким образом или имеем копию или делаем обратное преобразовние, что мне показалось, затратнее.

                0
                Iframe
                [script]real jquery code[/script]
                [img src=«our-cdn/image100500.jpg» /]

                DOM
                [script src=«jquery.2.0.0.js»][/script]
                [img src=«image100500.jpg» /]

                Карма не дает оформить код(
                  0
                  немного поправил карму )
              +1
              Странно. Впечатление, что пост этот уже публиковался тут.
                +1
                он был очень ненадолго, потом мы его убрали и кое-что доделали по безопасности )
                +1
                У меня есть решение попроще.

                Через nginx можно сделать просто и топорно.
                (целевой хост задаёте как ?host=wikipedia.org, всё остальное идентично таргету).
                HTTPSecureLink будет работать, решение вполне рабочее.
                Однако, тяжело сделать плюшки типа замены URL на серверсайде, но вполне можно на клиенте (это, конечно, не самый быстрый процесс).

                server {
                    listen       8081;
                    server_name  proxy.qwerty;
                    resolver 8.8.8.8;
                error_log /var/log/nginx/debug.log debug;
                    location / {
                        #allow 1.2.1.1;deny all;
                        set $newproto "http";
                        if ($arg_secure = "Y"){ set $newproto "https";}
                        set $newhost $arg_host;
                        if ($newhost !~* ^(.+)$ )  {return 406;}
                
                        proxy_pass $newproto://$newhost:80$request_uri;
                    }
                }
                


                В любом случае спасибо за подробный пост и ценное напоминание про безопасность.
                А что будет, если через этот прокси попробовать загрузить его самого (http://highlighter.indexisto.com/?md5=6ec7rdHxUfRkrFy55jrJQA==&url=http%3A%2F%2Fhabrahabr.ru&expires=1390468360)?
                  0
                  Сложно заменить урлы на сервер-сайде? Да одна строчка :)
                  sub_filter 'google.ru' 'zn.sergeybelove.ru';
                  sub_filter_once off;
                  

                  Demo: zn.sergeybelove.ru
                    0
                    На момент написания конфига sub_filter не поддерживал переменные в аргументах.
                    То, что написали вы работает только для конкретного домена. Плюс. Вы не думаете, что это может заменить не только ссылки?
                    0
                    я на самом деле в nginx не супер силен, финальный аккорд настраивали наши администраторы.
                    Я не уловил идеи, что этот конфиг делает?
                      0
                      Смотрите, всё очень просто.

                      Допустим вам нужно отпроксировать test.ru/page/?param=1&val=2

                      Это превращается в

                      proxy.qwertypage/?param=1&val=2&host=test.ru
                    +2
                    Теперь настраиваем модуль nginx HttpSecureLinkModule.
                    Мы думали, что пугающей красной надписи в начале страницы достаточно:
                    WARNING: this article is obsoleted. Please refer to nginx.org/en/docs/ for the latest official documentation.

                    Но оказывается нет. И для кого тратим силы на поддержку русской версии документации? Видимо она никому не нужна.
                    0
                    Молодцы. Жаль, что Яндекс не реализовал подобную идею в Веб-Визоре.
                      0
                      Хм. А что по вашему Яндекс реализовал в Вебвизоре? В вебвизоре, например, есть режим, в котором проигрываемая страница грузится прямо с сайта, без всяких прокси.
                        0
                        Да, и правда, моя ошибка. Просто, псоле возникновения ошибки обратился к справке, в ней они предлагают попросить администора сайта убрать «Same Origin» :) А оказалось, что есть опция кэширования (правда с ограничением в 200 Кб).
                          0
                          Да, если страница не даёт загрузить себя в ифрейм, то воспроизвести на ней ничего не получится.
                          0
                          Вебвизору/аналитике проще, потому что на сайте УЖЕ установлен JS с домена яндекса. Так что им достаточно в URL передать зашифрованную команду «запусти виджеты аналитики/вебвизора» и всё — проксирование в общем-то и не нужно становится.
                            0
                            Согласен, проще. Но, 1) в урле нельзя ничего передавать, некоторые сайты от этого ломаются, 2) показывать интерфейс внутри чужой страницы плохо, поэтому проксирование всё-таки нужно.
                        +1
                        В этот раз код для критики не выкладываете? :) Тогда покритикую практику финализации всего и вся — она не дает никаких преимуществ, но усложняет чтение и без того многословного ява кода. В самом деле, какого размера у вас методы, что вы боитесь случайно изменить локальную переменную или аргумент? Я называю этот антипаттерн «финал головного мозга».
                        Также есть еще ощущение, что я смогу запроксировать произвольный урл сроком годности до 2410 года, тоесть бессрочно. При подписывании параметров всегда два правила: использовать разделители и не ставить ключ впереди при конкатенации (hash extension attack).
                        Проверить с планшета затруднительно, могу и ошибаться.
                          0
                          «финал головного мозга» ))
                          я привык к final, с ним все ясно и понятно
                          акже есть еще ощущение, что я смогу запроксировать произвольный урл сроком годности до 2410 года
                          не должно, timeStampExpires генерится на сервере
                        0
                        Можно урлы кодировать в поддомен, т.е. http://example.com/page.html подменять на http://example.com.proxy.indexisto.com/page.html — тогда достаточно подменять только абсолютные FQDN ссылки (<a href="http://..."), а относительные (<a href="/page.html") будут автоматически наследовать родительский домен и протокол.

                        Ну и многие сайты практикуют детектирование и выпрыгивание из айфреймов, нужно иметь в виду.
                          +1
                          Для любителей оценить чужой код: у нас в команде у всех одинаковые настройки форматирования кода eclicpse, и при сохранении файла эклипс сам дописывает ко всем переменным final если они более нигде не меняются. Что кстати довольно удобно в итоге.
                          Вот совершенно не понимаю использования final для переменных. У вас настолько большие и сложные методы, что контракт использования переменной не очевиден?

                          Если вы ставите final для переменных, почему не ставите final для параметров?
                            0
                            хороший вопрос, по логике если если переменная параметра не меняется в методе то можно было бы и поставить.
                            Вот совершенно не понимаю использования final для переменных.

                            с другой стороны удобно. Если смотришь чужой код ты точно знаешь что меняя эту переменную (которая была final) ты можешь где-нибудь накосячить и кто-то в коде ниже надеется что она final
                              0
                              В коде «надеяться» на final для переменной можно только в одном случае: когда переменная через замыкание передается во вложенный анонимный класс. Именно в этом случае final нужен, очевиден и будет явно потребован компилятором. Во всех остальных случаях final для переменных несет приблизительно 0 смысла.

                              Если в коде в какой-то момент возникает мысль обновить значение переменной, то в любом случае необходимо проверить, каким явным и неявным контрактам она должна соответстовать. Nullable? Immutable? Thread safe? Valid for context?.. Но final по сути ничего не говорит о контрактах. Только о том, что ссылка на объект не меняется. В итоге ключевое слово final на переменной вам поможет только в случае, если метод настолько большой, что визуально найти переменную вы не в состоянии, а IDE их (переменные) подсвечивать не умеет. А вы не хотите проверять контракты.

                              Да и в большинстве проектов если начать расставлять final на переменные — то весь код совершенно бесполезно будет пестреть ими, так как 95% переменных должны будут помечены как final.
                                0
                                Выше писал уже про финал головного мозга; дополню про final вообще, вдруг джуниор какой будет к собеседованию готовится :)

                                Когда целесообразно использовать финал?
                                1. Объявление полей-констант
                                2. Объявление полей неизменяемого (immutable) класса
                                3. Передача ссылки на переменную в анонимный класс
                                4. Запрещение наследования
                                5. Запрещение переопределения метода

                          Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                          Самое читаемое