Двуликий REQUEST_URI или в поисках корректного HTTP/1.1 сервера

    Вы знаете, чем отличается %{REQUEST_URI} в Apache mod_rewrite от $_SERVER["REQUEST_URI"] в PHP?

    Сможете в .htaccess на уровне Apache сделать корректную переадресацию 301 с домена с префиксом www или на него?

    Для последнего вопроса я и сейчас не смогу предложить решение. Причина в протоколе HTTP/1.1, который пришлось изучить подробнее, когда «изобретал велосипед» (создавал ядро для сайта).

    Всё дело в HTTP-заголовке запроса «Host:». При определённых условиях там может быть всё, что угодно, причём сервер должен полностью это проигнорировать согласно HTTP/1.1. Большинство же разработчиков используют значение этого поля, например, для SEO-оптимизаций. Забегая вперёд, скажу, что дополнительный прокси (например, nginx) позволит решить эту проблему.

    Для иллюстрации некорректного поведения серверов решил перебрать сайты компаний Хабра. Для дюжины сайтов сделал это вручную, а потом обнаружил, что некоторые сайты на ошибочные запросы отвечают «правильно». После этого была написана небольшая утилита для тестирования, что позволило увеличить количество тестовых шаблонов и проверяемых сайтов.

    Что же скрывает REQUEST_URI в HTTP/1.1?



    Теория



    HTTP/1.0


    Начну с протокола HTTP/1.0, который описан в RfC1945 www.w3.org/Protocols/rfc1945/rfc1945 и датирован маем 1996 года. Для получения нужной страницы достаточно было подключиться к серверу и отправить одну строку:
    GET /path/to/resource.html HTTP/1.0

    При обращении к прокси-серверу необходимо было использовать не абсолютный путь, а полный адрес:
    GET http://domain.name/path/to/resource.html HTTP/1.0

    Это всё описано в разделе 5.1.2 «Request-URI».

    Появление Host


    Чтобы один сервер мог обслуживать сразу несколько доменных имён создатели протокола добавили заголовок запроса «Host:», который должен был содержать домен, к которому идёт обращение. Хотя данный заголовок и не является частью стандарта HTTP/1.0, но некоторыми серверами и клиентами он стал поддерживаться. Например, wget отправляет запросы по протоколу HTTP/1.0, но добавляет «Host:».

    HTTP/1.1


    В июне 1999 года (четырнадцать лет назад) появился протокол HTTP/1.1, который описан в RfC2616 www.w3.org/Protocols/rfc2616/rfc2616.html. В разделе 14.23 новый протокол потребовал, чтобы каждый заголовок запроса содержал поле «Host»:
    A client MUST include a Host header field in all HTTP/1.1 request messages. If the requested URI does not include an Internet host name for the service being requested, then the Host header field MUST be given with an empty value.


    Кроме этого значительные изменения коснулись Request-URI из строки запросов (раздел 5.1.2). Как и в предыдущем протоколе полный адрес требуется при запросах к прокси серверам («The absoluteURI form is REQUIRED when the request is being made to a proxy.»). Но отвечать на подобные запросы должны все сервера, хотя оформлять подобные запросы клиенты будут лишь к прокси серверам:
    To allow for transition to absoluteURIs in all requests in future versions of HTTP, all HTTP/1.1 servers MUST accept the absoluteURI form in requests, even though HTTP/1.1 clients will only generate them in requests to proxies.


    Обращаю внимание, что предполагался переход на полные адреса (absoluteURI, например, http://www.w3.org/pub/WWW/TheProject.html), поэтому от клиентов не требуется обязательного использования лишь абсолютных путей (abs_path, например, /pub/WWW/TheProject.html). Кроме того, от сервера в явном виде требуется умение отвечать на запросы клиентов с absoluteURI, поэтому возражение, что в данном случае запрос клиента не является корректным, исключаю сразу, поскольку «клиент всегда прав».

    Host в HTTP/1.1


    Изменения в Request-URI могут показаться безобидными, но раздел 5.2 содержит одно важное требование: «If Request-URI is an absoluteURI, the host is part of the Request-URI. Any Host header field value in the request MUST be ignored.» То есть интерпретация запроса
    GET http://domain.name/path/to/resource.html HTTP/1.1
    Host: любой_текст_тут

    должна совпадать с запросом
    GET /path/to/resource.html HTTP/1.1
    Host: domain.name


    Вы игнорируете «Host:» при запросах с absoluteURI?

    %{REQUEST_URI} и $_SERVER["REQUEST_URI"]


    В документации по mod_rewrite написано следующее:
    THE_REQUEST
    The full HTTP request line sent by the browser to the server (e.g., «GET /index.html HTTP/1.1»). This does not include any additional headers sent by the browser. This value has not been unescaped (decoded), unlike most other variables below.

    REQUEST_URI
    The path component of the requested URI, such as "/index.html". This notably excludes the query string which is available as as its own variable named QUERY_STRING.

    То есть в %{REQUEST_URI} всегда будет абсолютный путь и никогда полного адреса.

    Попробуйте стандартную SEO задачу по добавлению «www» к домену без него решить с помощью mod_rewrite, если пользователь отправит следующий запрос:
    GET http://domain.name/path/to/resource.html HTTP/1.1
    Host: www.domain.name


    Вначале статьи спрашивал про отличие %{REQUEST_URI} в Apache mod_rewrite от $_SERVER["REQUEST_URI"] в PHP, поэтому приведу выдержку из документации к PHP:
    REQUEST_URI
    The URI which was given in order to access this page; for instance, '/index.html'.

    Может быть это где-нибудь и настраивается, но у меня PHP/5.3.13 возвращает absoluteURI при запросе с полным адресом.

    Практика


    Давайте теперь рассмотрим, что же происходит при запросах к реальным серверам. Адреса сайтов взял со страницы компаний Хабра (там список меняется, брал в конце прошлой недели). Набросал небольшой скрипт на Node.JS, в котором функция http_check отправляет одиночные запросы, а full_http_check формирует к одному серверу несколько запросов по определённым шаблонам.

    код скрипта
    var net = require('net');
    
    var default_result = function(title) {
    	if (title) {
    		return 	{'title': 'title', 'step': 'step', 'host': 'host', 'request': 'request', 'header': 'header', 'full_response': 'full_response', 'response': 'response', 'server': 'server', 'length': 'length', 'location': 'location', 'error': 'error'};
    	} else {
    		return 	{'title': '', 'step': '', 'host': '', 'request': '', 'header': '', 'full_response': '', 'response': '', 'server': '', 'length': '', 'location': '', 'error': ''};
    	}
    };
    
    var format_result = function(result) {
    	return '' + result['title'].toString() + '\t'
    		+ result['step'] + '\t'
    		+ result['host'] + '\t'
    		+ result['request'].toString() + '\t' 
    		+ result['header'].toString() + '\t' 
    		+ result['response'].toString() + '\t' 
    		+ result['server'].toString() + '\t' 
    		+ result['length'].toString() + '\t'
    		+ result['error'].toString() + '\t' 
    		+ result['location'].toString() + '\t' 
    		+ result['full_response'].toString();
    };
    
    var http_check = function(title, step, host, req, host_hdr)
    {
    	var host_header = host_hdr || '';
    	var result = default_result(false);
    	result['title'] = title;
    	result['step'] = step;
    	result['host'] = host;
    	result['request'] = req;
    	result['header'] = host_header;
    	var dat = '';
    	
    
    	var client = net.connect({port: 80, host: host},
    		function() { //'connect' listener
    			client.on('data', function (data) {
    				dat = dat + data;
    				var lines = dat.toString().split('\r\n');
    				result['full_response'] = JSON.stringify(dat.toString().split('\r\n\r\n')[0]);
    				result['response']  = lines[0] || false;
    				if (lines[0].substring(0, 5) == 'HTTP/') {
    					var i = 1;
    					while (lines[i] != '') {
    						var title = lines[i].match(/^([^:]+:)\s(.+)$/);
    						if (title[1] == 'Location:') {
    							result['location'] = title[2];
    						} else if (title[1] == 'Server:') {
    							result['server'] = title[2];
    						} else if (title[1] == 'Content-Length:') {
    							result['length'] = title[2];
    						}
    						i++;
    					}
    					if (dat.indexOf('\r\n\r\n') >= 0) {
    						client.end();
    						client.destroy();
    					}
    				} else {
    					client.end();
    					client.destroy();
    				}
    				
    			});
    			client.on('end', function () {
    			  console.log('client disconnected');
    			});
    
    			client.on('error', function (error) {
    				console.log('ERROR: ' + error.toString());
    			});
    
    			client.on('timeout', function () {
    				console.log('Timeout');
    			});
    				
    			client.on('close', function (had_error)	{
    				result['error'] = result['error'] || had_error || '';
    				console.log(format_result(result));
    			});
    
    				client.write(req + '\r\n');
    				host_hdr && client.write('Host: ' + host_hdr + '\r\n');
    				client.write('\r\n');
    			});
    
    	
    };
    
    var full_http_check = function(title, url) {
    	var parts = url.match(/^http:\/\/([^\/]+)(.+)$/);
    
    // 1
    // GET /path/to/resource.html HTTP/1.1
    // Host: domain.name
    	http_check(title, '01', parts[1], 'GET ' + parts[2] + ' HTTP/1.1', parts[1]);
    	
    // 2
    // GET http://domain.name/path/to/resource.html HTTP/1.1
    // Host: domain.name
    	http_check(title, '02', parts[1], 'GET http://' + parts[1] + parts[2] + ' HTTP/1.1', parts[1]);
    
    // 3
    // GET /path/to/resource.html HTTP/1.0
    	http_check(title, '03', parts[1], 'GET ' + parts[2] + ' HTTP/1.0', '');
    
    // 4
    // GET /path/to/resource.html HTTP/1.0
    // Host: domain.name
    	http_check(title, '04', parts[1], 'GET ' + parts[2] + ' HTTP/1.0', parts[1]);
    
    // 5
    // GET http://domain.name/path/to/resource.html HTTP/1.0
    	http_check(title, '05', parts[1], 'GET http://' + parts[1] + parts[2] + ' HTTP/1.0', '');
    
    // 6
    // GET http://domain.name/path/to/resource.html HTTP/1.0
    // Host: domain.name
    	http_check(title, '06', parts[1], 'GET http://' + parts[1] + parts[2] + ' HTTP/1.0', parts[1]);
    
    // 7
    // GET http://domain.name/path/to/resource.html HTTP/1.1
    // Host: void.domain.name
    	http_check(title, '07', parts[1], 'GET http://' + parts[1] + parts[2] + ' HTTP/1.1', 'void.' + parts[1]);
    
    // 8
    // GET http://domain.name/path/to/resource.html HTTP/1.1
    // Host: local.fake
    	http_check(title, '08', parts[1], 'GET http://' + parts[1] + parts[2] + ' HTTP/1.1', 'local.fake');
    
    // 9
    // GET http://domain.name/path/to/resource.html HTTP/1.1
    // Host: l-IjFN=fiG(w+J2p:#.{92!m`d^?
    	http_check(title, '09', parts[1], 'GET http://' + parts[1] + parts[2] + ' HTTP/1.1', 'l-IjFN=fiG(w+J2p:#.{92!m`d^?');
    
    // 10
    // GET http://fake.domain.name/path/to/resource.html HTTP/1.1
    // Host: domain.name
    	http_check(title, '10', parts[1], 'GET http://fake.' + parts[1] + parts[2] + ' HTTP/1.1', parts[1]);
    
    // 11
    // GET http://local.fake/path/to/resource.html HTTP/1.1
    // Host: domain.name
    	http_check(title, '11', parts[1], 'GET http://local.fake' + parts[2] + ' HTTP/1.1', parts[1]);
    
    // 12
    // GET http://l-IjFN=fiG(w+J2p:#.{92!m`d^?/path/to/resource.html HTTP/1.1
    // Host: domain.name
    	http_check(title, '12', parts[1], 'GET http://l-IjFN=fiG(w+J2p:#.{92!m`d^?' + parts[2] + ' HTTP/1.1', parts[1]);
    
    // 13
    // GET http://local.fake/path/to/resource.html HTTP/1.1
    // Host: void.domain.name
    	http_check(title, '13', parts[1], 'GET http://local.fake' + parts[2] + ' HTTP/1.1', 'void.' + parts[1]);
    
    // 14
    // GET habr://domain.name/path/to/resource.html HTTP/1.1
    // Host: domain.name
    	http_check(title, '14', parts[1], 'GET habr://' + parts[1] + parts[2] + ' HTTP/1.1', parts[1]);
    
    // 15
    // GET habr://void.domain.name/path/to/resource.html HTTP/1.1
    // Host: domain.name
    	http_check(title, '15', parts[1], 'GET habr://void.' + parts[1] + parts[2] + ' HTTP/1.1', parts[1]);
    
    // 16
    // GET habr://local.fake/path/to/resource.html HTTP/1.1
    // Host: domain.name
    	http_check(title, '16', parts[1], 'GET habr://local.fake' + parts[2] + ' HTTP/1.1', parts[1]);
    
    // 17
    // GET habr://l-IjFN=fiG(w+J2p:#.{92!m`d^?/path/to/resource.html HTTP/1.1
    // Host: domain.name
    	http_check(title, '17', parts[1], 'GET habr://l-IjFN=fiG(w+J2p:#.{92!m`d^?' + parts[2] + ' HTTP/1.1', parts[1]);
    
    // 18
    // GET habr://l-IjFN=fiG(w+J2p:#.{92!m`d^?/path/to/resource.html HTTP/1.1
    // Host: local.fake
    	http_check(title, '18', parts[1], 'GET habr://l-IjFN=fiG(w+J2p:#.{92!m`d^?' + parts[2] + ' HTTP/1.1', 'local.fake');
    };
    
    
    
    console.log(format_result(default_result(true)));
    
    /*
    http_check('IBM Fake', 'www.ibm.com', 'GET ttp://com/midmarket/ru/ru/ HTTP/1.1', 'ibm');
    full_http_check('IBM', 'http://www.ibm.com/midmarket/ru/ru/');
    */
    
    full_http_check('Яндекс', 'http://company.yandex.ru/about/main/');
    full_http_check('JetBrains', 'http://www.jetbrains.com/products.html');
    full_http_check('Box Overview', 'http://7del.net/texts/galaxy-note.html');
    full_http_check('KolibriOS Project Team', 'http://kolibrios.org/en/download.htm');
    full_http_check('Opera Software ASA', 'http://www.opera.com/about');
    full_http_check('Apps4All', 'http://apps4all.ru/news/apple/apple-ios-7-beta.html');
    full_http_check('Нордавинд', 'http://nordavind.ru/node/207');
    full_http_check('Mail.Ru Group', 'http://corp.mail.ru/about/');
    full_http_check('Microsoft', 'http://windows.microsoft.com/ru-RU/windows/home');
    full_http_check('Zfort Group', 'http://www.zfort.com.ua/company/about/');
    full_http_check('IBM', 'http://www.ibm.com/contact/ru/ru/');
    full_http_check('UIDG', 'http://uidesign.ru/about/');
    full_http_check('Intel', 'http://www.intel.ru/content/www/ru/ru/company-overview/company-overview.html');
    full_http_check('Rusonyx', 'http://www.rusonyx.ru/company/reasons/');
    full_http_check('Мосигра', 'http://www.mosigra.ru/page/about/');
    full_http_check('DevConf', 'http://devconf.ru/about/');
    full_http_check('e-Legion Ltd.', 'http://www.e-legion.ru/contacts/');
    full_http_check('Badoo', 'http://corp.badoo.com/company/');
    full_http_check('ВымпелКом (Билайн)', 'http://mobile.beeline.ru/msk/setup/index.wbp');
    


    Теперь рассмотрим подробнее каждый из шаблонов и реакцию сайтов.

    Запрос 1


    Самый распространённый вариант запроса HTTP/1.1, включающий абсолютный путь и правильный заголовок Host. На него должен корректно отвечать любой сервер, то есть ждём «HTTP/1.1 200 OK».
    GET /path/to/resource.html HTTP/1.1
    Host: domain.name


    Все сервера вернули «HTTP/1.1 200 OK». Ниже представлена таблица значений заголовка ответа «Server»:
    Компания Заголовок «Server:»
    Apps4All nginx/1.0.15
    Badoo nginx
    Box Overview nginx/1.2.1
    DevConf nginx/1.0.15
    e-Legion Ltd. nginx/1.0.5
    IBM IBM_HTTP_Server
    Intel Microsoft-IIS/7.5
    JetBrains nginx
    KolibriOS Project Team lighttpd/1.4.32
    Mail.Ru Group nginx/1.2.5
    Microsoft Microsoft-IIS/7.5
    Opera Software ASA nginx
    Rusonyx nginx
    UIDG Apache
    Zfort Group nginx/1.4.1
    ВымпелКом (Билайн) Microsoft-IIS/7.5
    Мосигра nginx/1.4.1
    Нордавинд nginx/1.0.4
    Яндекс nginx/1.2.1


    Запрос 2


    Вариант первого типа запросов, но вместо абсолютного пути указываем полный адрес.
    GET http://domain.name/path/to/resource.html HTTP/1.1
    Host: domain.name


    В ответ на этот запрос все сервера опять проявили единодушие. «Лёгкие» запросы разбирать каждый сервер умеет.

    Запрос 3


    Запрос на HTTP/1.0 с абсолютным путём, без «Host:». Должны получить «HTTP/1.0 200 OK».
    GET /path/to/resource.html HTTP/1.0


    На третьем запросе сервера «посыпались». И нет ни одного ответа «HTTP/1.0 200 OK».
    Компания Ответ сервера
    Apps4All HTTP/1.1 301 Moved Permanently
    Badoo HTTP/1.1 302 Moved Temporarily
    Box Overview HTTP/1.1 200 OK
    DevConf HTTP/1.1 404 Not Found
    e-Legion Ltd. HTTP/1.1 301 Moved Permanently
    IBM HTTP/1.1 200 OK
    Intel HTTP/1.0 400 Bad Request
    JetBrains HTTP/1.1 301 Moved Permanently
    KolibriOS Project Team HTTP/1.0 404 Not Found
    Mail.Ru Group HTTP/1.1 200 OK
    Microsoft HTTP/1.1 200 OK
    Opera Software ASA HTTP/1.1 404 Not Found
    Rusonyx HTTP/1.1 301 Moved Permanently
    UIDG HTTP/1.1 404 Not Found
    Zfort Group HTTP/1.1 404 Not Found
    ВымпелКом (Билайн) HTTP/1.1 302 Redirect
    Мосигра HTTP/1.1 404 Not Found
    Нордавинд HTTP/1.1 200 OK
    Яндекс HTTP/1.1 404 Not Found


    Запрос 4


    Предыдущий запрос, но добавим «Host:». От первого запроса отличается лишь версией протокола.
    GET /path/to/resource.html HTTP/1.0
    Host: domain.name


    Очень положительным образом подействовал Host на сервера — у всех ответ «200 OK», но HTTP/1.0 был лишь у следующих: Intel и KolibriOS Project Team.

    Запрос 5


    Запрос на HTTP/1.0 с полным адресом, без «Host:». Было бы здорово прочитать «HTTP/1.0 200 OK».
    GET http://domain.name/path/to/resource.html HTTP/1.0


    Картина полностью совпадает с результатами предыдущего запроса, но вот e-Legion Ltd. выдал «HTTP/1.1 500 INTERNAL SERVER ERROR».

    Запрос 6


    Предыдущий запрос, но добавим «Host:». От второго запроса отличается лишь версией протокола.
    GET http://domain.name/path/to/resource.html HTTP/1.0
    Host: domain.name


    Результаты полностью совпадают с четвёртым запросом, то есть «Host:» исправил внутреннюю ошибку у сервера e-Legion Ltd.

    Запрос 7


    Вариант второго запроса с полным адресом, но в «Host:» запишем несуществующий поддомен. Запрос абсолютно корректный, поэтому сервер должен отвечать «HTTP/1.1 200 OK».
    GET http://domain.name/path/to/resource.html HTTP/1.1
    Host: void.domain.name


    Запрос 8


    Теперь в качестве «Host:» укажем несуществующий домен. В запросе ничего не изменилось, но некоторым серверам это может уже не понравиться.
    GET http://domain.name/path/to/resource.html HTTP/1.1
    Host: local.fake


    Запрос 9


    Заголовок «Host:» должен полностью игнорироваться, поэтому запишем произвольный текст, которому позавидуют многие пароли. По стандарту будем ожидать «HTTP/1.1 200 OK».
    GET http://domain.name/path/to/resource.html HTTP/1.1
    Host: l-IjFN=fiG(w+J2p:#.{92!m`d^?


    На запросы 7-9 сервера отвечали одинаково следующим образом:

    Компания Ответ сервера Заголовок «Server:»
    Apps4All HTTP/1.1 200 OK nginx/1.0.15
    Badoo HTTP/1.1 200 OK nginx
    Box Overview HTTP/1.1 200 OK nginx/1.2.1
    DevConf HTTP/1.1 500 Internal Server Error nginx/1.0.15
    e-Legion Ltd. HTTP/1.1 500 INTERNAL SERVER ERROR nginx/1.0.5
    IBM HTTP/1.1 200 OK IBM_HTTP_Server
    Intel HTTP/1.0 400 Bad Request AkamaiGHost
    JetBrains HTTP/1.1 200 OK nginx
    KolibriOS Project Team HTTP/1.1 200 OK lighttpd/1.4.32
    Mail.Ru Group HTTP/1.1 200 OK nginx/1.2.5
    Microsoft HTTP/1.1 200 OK Microsoft-IIS/7.5
    Opera Software ASA HTTP/1.1 200 OK nginx
    Rusonyx HTTP/1.1 200 OK nginx
    UIDG HTTP/1.1 200 OK Apache
    Zfort Group HTTP/1.1 200 OK nginx/1.4.1
    ВымпелКом (Билайн) HTTP/1.1 200 OK Microsoft-IIS/7.5
    Мосигра HTTP/1.1 200 OK nginx/1.4.1
    Нордавинд HTTP/1.1 200 OK nginx/1.0.4
    Яндекс HTTP/1.1 200 OK nginx/1.2.1


    Запрос 10


    Первый из неправильных запросов. Отправим правильный «Host:», но в полном адресе добавим несуществующий поддомен.
    GET http://fake.domain.name/path/to/resource.html HTTP/1.1
    Host: domain.name


    Поскольку начались запросы с ошибками, то результаты пугать не должны.
    Компания Ответ сервера
    Apps4All HTTP/1.1 301 Moved Permanently
    Badoo HTTP/1.1 301 Moved Permanently
    Box Overview HTTP/1.1 200 OK
    DevConf HTTP/1.1 404 Not Found
    e-Legion Ltd. HTTP/1.1 301 Moved Permanently
    IBM HTTP/1.1 200 OK
    Intel HTTP/1.1 200 OK
    JetBrains HTTP/1.1 301 Moved Permanently
    KolibriOS Project Team HTTP/1.1 404 Not Found
    Mail.Ru Group HTTP/1.1 200 OK
    Microsoft HTTP/1.1 200 OK
    Opera Software ASA HTTP/1.1 404 Not Found
    Rusonyx HTTP/1.1 301 Moved Permanently
    UIDG HTTP/1.1 404 Not Found
    Zfort Group HTTP/1.1 404 Not Found
    ВымпелКом (Билайн) HTTP/1.1 302 Redirect
    Мосигра HTTP/1.1 301 Moved Permanently
    Нордавинд HTTP/1.1 200 OK
    Яндекс HTTP/1.1 404 Not Found


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

    Запрос 11


    Теперь попробуем отправить несуществующий домен.
    GET http://local.fake/path/to/resource.html HTTP/1.1
    Host: domain.name


    Здесь результаты полностью совпадают с предыдущим запросом, но Мосигра вместо «HTTP/1.1 301 Moved Permanently» выдала уже «HTTP/1.1 404 Not Found».

    Запрос 12


    А сработает ли вообще произвольный текст в качестве домена?
    GET http://l-IjFN=fiG(w+J2p:#.{92!m`d^?/path/to/resource.html HTTP/1.1
    Host: domain.name


    Ответ «HTTP/1.1 200 OK» пришёл от Intel и Opera Software ASA. IBM и Мосигра вернули «HTTP/1.1 404 Not Found». Все остальные написали 404 Bad Request, причём часть вообще без заголовка (возможный вариант в HTTP/1.0).

    Запрос 13


    Копия одинадцатого запроса, но ещё и с поддоменом в качестве «Host:». Вряд ли имеет смысл проверять другие некорректные комбинации.
    GET http://local.fake/path/to/resource.html HTTP/1.1
    Host: void.domain.name


    Результаты тоже стали копией запроса 11, но сдался Intel и вернул «HTTP/1.0 400 Bad Request».

    Запрос 14


    Второй запрос, но воспользуемся несуществующим протоколом при указании полного адреса. Здесь-то уже точно должна быть ошибка.
    GET habr://domain.name/path/to/resource.html HTTP/1.1
    Host: domain.name


    Оказалось, что довольно много сайтов воспринимают протокол HABR:

    Компания Ответ сервера
    Apps4All HTTP/1.1 200 OK
    Badoo HTTP/1.1 200 OK
    Box Overview HTTP/1.1 200 OK
    DevConf HTTP/1.1 200 OK
    e-Legion Ltd. HTTP/1.1 200 OK
    IBM HTTP/1.1 200 OK
    Intel HTTP/1.0 400 Bad Request
    JetBrains HTTP/1.1 200 OK
    KolibriOS Project Team HTTP/1.1 301 Moved Permanently
    Mail.Ru Group HTTP/1.1 200 OK
    Microsoft HTTP/1.1 400 Bad Request
    Opera Software ASA HTTP/1.1 400 BAD_REQUEST
    Rusonyx HTTP/1.1 200 OK
    UIDG HTTP/1.1 200 OK
    Zfort Group HTTP/1.1 200 OK
    ВымпелКом (Билайн) HTTP/1.1 400 Bad Request
    Мосигра HTTP/1.1 400 BAD_REQUEST
    Нордавинд HTTP/1.1 200 OK
    Яндекс HTTP/1.1 200 OK


    Запрос 15


    Попробуем окончательно сломить сопротивление сервера и отправим предыдущий запрос, но с некорректным поддоменом.
    GET habr://void.domain.name/path/to/resource.html HTTP/1.1
    Host: domain.name


    Результаты похожи на десятый запрос, но есть и изменения:

    Компания Запрос 10 Запрос 15
    Apps4All HTTP/1.1 301 Moved Permanently HTTP/1.1 301 Moved Permanently
    Badoo HTTP/1.1 301 Moved Permanently HTTP/1.1 301 Moved Permanently
    Box Overview HTTP/1.1 200 OK HTTP/1.1 200 OK
    DevConf HTTP/1.1 404 Not Found HTTP/1.1 404 Not Found
    e-Legion Ltd. HTTP/1.1 301 Moved Permanently HTTP/1.1 301 Moved Permanently
    IBM HTTP/1.1 200 OK HTTP/1.1 200 OK
    Intel HTTP/1.1 200 OK HTTP/1.0 400 Bad Request
    JetBrains HTTP/1.1 301 Moved Permanently HTTP/1.1 301 Moved Permanently
    KolibriOS Project Team HTTP/1.1 404 Not Found HTTP/1.1 301 Moved Permanently
    Mail.Ru Group HTTP/1.1 200 OK HTTP/1.1 200 OK
    Microsoft HTTP/1.1 200 OK HTTP/1.1 400 Bad Request
    Opera Software ASA HTTP/1.1 404 Not Found HTTP/1.1 400 BAD_REQUEST
    Rusonyx HTTP/1.1 301 Moved Permanently HTTP/1.1 301 Moved Permanently
    UIDG HTTP/1.1 404 Not Found HTTP/1.1 404 Not Found
    Zfort Group HTTP/1.1 404 Not Found HTTP/1.1 404 Not Found
    ВымпелКом (Билайн) HTTP/1.1 302 Redirect HTTP/1.1 400 Bad Request
    Мосигра HTTP/1.1 301 Moved Permanently HTTP/1.1 400 BAD_REQUEST
    Нордавинд HTTP/1.1 200 OK HTTP/1.1 200 OK
    Яндекс HTTP/1.1 404 Not Found HTTP/1.1 404 Not Found


    Запрос 16


    Попробуем использовать произвольный домен.
    GET habr://local.fake/path/to/resource.html HTTP/1.1
    Host: domain.name


    Результаты совпали с предыдущим запросом.

    Запрос 17


    И в третий раз попробуем заменить домен на произвольный текст.
    GET habr://l-IjFN=fiG(w+J2p:#.{92!m`d^?/path/to/resource.html HTTP/1.1
    Host: domain.name


    Уже ни одного положительного ответа от сервера. По сравнению с запросом 12 изменения есть у следующих сайтов:

    Компания Запрос 12 Запрос 17
    Intel HTTP/1.1 200 OK HTTP/1.0 400 Bad Request
    KolibriOS Project Team HTTP/1.1 400 Bad Request HTTP/1.1 301 Moved Permanently
    Opera Software ASA HTTP/1.1 200 OK HTTP/1.1 400 BAD_REQUEST
    Мосигра HTTP/1.1 404 Not Found HTTP/1.1 400 BAD_REQUEST


    Запрос 18


    А теперь попробуем избавиться и от корректного заголовка «Host:».
    GET habr://l-IjFN=fiG(w+J2p:#.{92!m`d^?/path/to/resource.html HTTP/1.1
    Host: local.fake


    Всего одно изменение по сравнению с предыдущим результатом — сервер KolibriOS Project Team стал возвращать «HTTP/1.1 404 Not Found» вместо «HTTP/1.1 301 Moved Permanently».

    Запрос N


    Напишите, если хотите попробовать какие-нибудь ещё варианты запросов. А можете сделать это и сами.

    Заключение


    Попробуем подвести некоторые итоги. Почти все рассмотренные сервера корректно отвечали на HTTP/1.1 запросы. Исключение составили DevConf, e-Legion Ltd. и Intel. Первые два используют nginx, поэтому проблема, скорее всего, именно в его настройке. Intel же использует AkamaiGHost, который либо неправильно настроен, либо плохо поддерживает HTTP/1.1. Допускаю, что одной из причин корректного прохождения тестов является именно nginx (его использовали 14 из 19 серверов). Из-за разницы в версиях обнаружилась цепочка из nginx/1.0.10 и nginx/1.4.1 у UIDG.

    Считаете, что всё просто? Попробуйте настроить Apache с учётом SEO так, чтобы он корректно обрабатывал запросы с ошибочным «Host:» и основывался лишь на полном адресе в строке запроса.

    Какой практический смысл от «неправильных» корректных запросов? Сомневаюсь, что получится найти какую-нибудь уязвимость. Но неужели почти за пятнадцать лет никто не научился создавать корректные HTTP/1.1 сервера?

    P.S. Помните про различия между %{REQUEST_URI} в Apache mod_rewrite и $_SERVER["REQUEST_URI"] в PHP.

    UPD1:

    Запрос 19


    По совету AEP взял второй запрос, но к хосту добавил ещё нулевой байт и некоторую строку. Тут зависело от того, насколько хорошо сервер будет игнорировать хост с нулевым байтом.
    GET http://domain.name/path/to/resource.html HTTP/1.1
    Host: domain.name{нулевой байт}fake_and_void

    В скрипт добавил следующий шаблон:
    
    	http_check(title, '19', parts[1], 'GET http://' + parts[1] + parts[2] + ' HTTP/1.1', parts[1] + '\0fake_and_void_text');
    


    Все сервера вернули «HTTP/1.1 400 Bad Request», кроме IBM, Opera Software ASA и Мосигра.
    Когда попробовал нулевой байт добавить в запрос, то кроме IBM и Opera Software все сообщили об ошибке 400.
    Share post

    Similar posts

    AdBlock has stolen the banner, but banners are not teeth — they will be back

    More
    Ads

    Comments 27

      –1
      Незнаю как у остальных, раньше не интересовался, но у меня касательно этого «пунктик». Всегда провожу тестирование не только рабочего процесса, а и симулирую неверные запросы, чтобы сайт/приложение работал как часики.
        +2
        Это интересно. Ещё есть забавный момент с передачей двух заголовков Host. В случае неправильной (той, которая по умолчанию, и в мануалах в интернетах) конфигурации nginx, он увидел первый заголовок, а на бэк-энд отправит второй.
          0
          Надо ли понимать это как то, что никто в интернете, в том числе сам Сысоев, не знает, как правильно настраивать nginx, а знаете только вы?

          Ну так тогда поделитесь, что там неправильного и как должно быть.
            +4
            Нет, это нужно понимать совершенно не так.

            При использовании fastcgi, в файле fastcgi_params отсутствует установка заголовка Host. Должно быть:
            fastcgi_param HTTP_HOST $host;

            При использовании proxy_pass нужно обязательно указывать:
            proxy_set_header Host $host;
            0
            В случае неправильной (той, которая по умолчанию, и в мануалах в интернетах) конфигурации nginx, он увидел первый заголовок, а на бэк-энд отправит второй.
            Покажите мне такую конфигурацию, при которой на бэкенд будет отправлен второй заголовок.
              0
              На примере nginx + fastcgi + php-fpm устоит?

              Берём fastcgi_params из wiki.nginx.org/PHPFcgiExample (соответствует конфигу по умолчанию)

              В конфиге хоста пишем примерно следующее:
              		server_name xenv.darkbyte.ru;
              		location / {
              			fastcgi_pass unix:/var/run/php5-fpm.sock;
              			fastcgi_index index.php;
              			include fastcgi_params;
              		}
              


              Скрипт index.php выводит некоторые переменные из массива $_SERVER, включая HTTP_HOST.

              Делаем запрос:
              # nc darkbyte.ru 80
              GET / HTTP/1.1
              Host: xenv.darkbyte.ru
              Host: evil.com
              Connection: close


              В ответе видим: HTTP_HOST=evil.com

              Т.е. nginx в переменной server_name видел xenv.darkbyte.ru, а PHP увидел уже evil.com.
              Если добавить строку: fastcgi_param HTTP_HOST $host, то проблема решается, PHP видит тоже самое, что и nginx.
              Это можно проверить, сделав такой же запрос, но в первом Host указать env.darkbyte.ru.

              Если поменять заголовки местами, то будет 404, ибо evil.com у меня на сервере не объявлен.
                +4
                Так и причем тут nginx? Параметры «HTTP_*» — это маппинг http-заголовков один к одному. Nginx совершенно справедливо передает все заголовки, что были в запросе, включая и оба заголовка «Host:». То, что php видит только последний — исключительно беда php, а может быть и беда конкретно php-fpm. В любом случае, программист, который обращается к HTTP_* должен ожидать там всё, что угодно.

                Посмотрите тем же tcpdump-ом хотя бы, что именно nginx отправил.
                  0
                  Возможно оно так и есть, но ведь решить проблему можно только на уровне nginx. И в конфиге по умолчанию, который так же предлагается во многих мануалах по настройке nginx+php-fpm, этот момент упущен.

                  А tcpdump умеет слушать файловые сокеты? Если нет, то чем можно? Сходу не смог найти подходящей утилиты.
                    +1
                    1. Что у вас за приложение такое, что ему нужен HTTP_HOST? Взяли вы netcat и специально послали два заголовка, получили 404. Нормальные браузеры так себя не ведут. В чем состоит проблема?

                    Оставьте с хостами разбираться веб-серверу, не надо этим в приложении заниматься. Сегодня Host, а завтра ещё какой-нибудь заголовок. Я так подозреваю, что эффект будет одинаковый в отношении любого заголовка: php-скрипт увидит только последний из присланных. Содержимое HTTP_ — это в любом случае данные полученные от пользователя, и программист, который не выучил, что нельзя доверять данным присланным пользователем — просто некомпетентен. Возможно, что это повод вообще отказаться от такого приложения, безоспасность которого опирается на HTTP_*.

                    2. fastcgi_params содержит исключительно подборку переменных окружения из RFC 3875 и не более.

                    3. Каждый второй школьник, впервые в жизни настроивший что-то на локалхосте, скорее пишет мануал или руководство. Чему тут удивляться? 9 из 10 плохи в той или иной степени, отнюдь не из-за HTTP_HOST.

                    А tcpdump умеет слушать файловые сокеты? Если нет, то чем можно? Сходу не смог найти подходящей утилиты.
                    Можно strace-ом посмотреть.
                      0
                      Перенастроил fastcgi на обычные сокеты, посмотрел трафик — действительно, передаётся оба заголовка HTTP_HOST. Но опять таки, то, что nginx и php в разном порядке рассматривают заголовки — это не правильно, как мне кажется.

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

                      Конечно браузер не будет посылать два заголовка Host, но ведь не только браузеры заходят на сайты. Внимание на данную проблему я обратил после того, как на одном сайте обнаружил «хитрый» механизм входа в админку, основанный на домене. Авторизация для входа на поддомен админки была настроена в nginx, а проверка домена в самом приложении.
                        0
                        И если есть возможность на уровне веб сервера хоть немного прикрыть дыры приложений, которые за ним прячутся, то я не упускаю такой возможности.

                        Да, но наверное не стоит при этом упрекать всех, кто использует nginx просто по прямому назначению, а не для валидации входных данных ради небезопасных приложений. Возможности nginx в этом смысле достаточно велики даже из коробки. Можно понаписать регулярок, и ещё проверять $args на SQL инъекции. А особо озабоченные проблемой люди даже специальный модуль разработали. Но это палка о двух концах.

                        Я даже специально ещё раз подчеркну, что вообще присутствие HTTP_HOST среди переменных окружения никто не обещал. Попытка её активно использовать — это уже нестандарт, и если вам действительно нужно как-то пробрасывать относительно валидный хост на бэкенд и вы по каким-то причинам очень не желаете использовать SERVER_NAME (специально для того предназначенную, и описанную в спецификации с словом MUST), то лучше для этого придумать какую-нибудь свою переменную, не пересекающуюся с заголовками:
                        fastcgi_param MY_HOST xenv.darkbyte.ru; — будет намного правильнее.
                          0
                          Хорошо, когда за nginx находятся свои приложения, которым можно доверять. Но бывает, что ставятся чужие решения, и не всегда они достаточно качественные, чтобы просто их поставить и забыть.

                          Пример с авторизацией через поддомен — это частный случай. Но можно взять более распространённый пример — сбор статистики средствами самого движка. Очень часто приходится сталкиваться с тем, что переменной HTTP_HOST доверяют, считая, что раз веб сервер отправил на их сайт запрос, значит в HOST будет точно их хост.

                          Опять же, одно дело дорогие регулярки и модули, а другое дело, практически бесплатный костыль :)

                          В моём случае, в server_name написано "~^((.*)\.)*(.+\..+)$", а валидный хост передаётся отдельным заголовком.
            0
            Вот интересно почему нет массовой популярности у lighttpd как у nginx? Неужели последний гораздо круче?
              +9
              да
                +2
                Очень аргументировано.
                0
                немного странно вообще сравнивать эти две вещи — nginx мощен тем что поддерживает всяко кэширование, прокси, балансировку, и почти всё что умеет апач.

                а lighttpd — это веб-сервер — который поддерживает те технологии, которые нужны современному лёгкому веб-серверу.
                  0
                  >> а lighttpd — это веб-сервер — который поддерживает те технологии, которые нужны современному лёгкому веб-серверу.
                  Что не мешает ему также поддерживать «кэширование, прокси, балансировку, и почти всё что умеет апач.»
                    +2
                    Тогда он становится не таким уж и легким :)
                    Ну а если серьезно, то вот человек делал обзор blog.monitis.com/index.php/2012/08/22/nginx-vs-lighttpd/ — все правильно пишет в последнем абзаце.
                  0
                  Разработка lighttpd 1.x прекращена много лет назад (только иногда багфиксы случаются). А релиз lighttpd 2.0 так и не увидел свет.
                  +1
                  Первое что приходит в голову: передать неправильный заголовок Host:, состоящий из правильного имени хоста, нулевого байта и мусора.
                    0
                    Спасибо, добавил в основной текст в качестве «Запроса 19».
                    0
                    На третьем запросе сервера «посыпались». И нет ни одного ответа «HTTP/1.0 200 OK».
                    И не должно быть.

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

                    Исключение составили DevConf, e-Legion Ltd. и Intel. Первые два используют nginx, поэтому проблема, скорее всего, именно в его настройке
                    Нет, проблема может быть, и скорее всего, в бэкенде. Nginx сам никогда не выдаст INTERNAL SERVER ERROR в верхнем регистре.
                      0
                      На третьем запросе сервера «посыпались». И нет ни одного ответа «HTTP/1.0 200 OK».

                      И не должно быть.

                      Я пожалуй даже поясню этот пункт, ибо видимо не все понимают.

                      Кто-то видимо ошибочно полагает, что указываемая в запросе или ответе версия протокола — это версия протокола запроса или ответа (использумая при формировании самого запроса или ответа). Нет, это заблуждение.

                      Этот механизм нужен, чтобы сервер и клиент сообщили друг-другу наивысшую версию протокола, с которой они совместимы. Если клиент отправил HTTP/1.0 запрос, то nginx не будет использовать ничего в ответе, что могло бы оказаться несовместимым с HTTP/1.0 (такие вещи, например, как chunked encoding).

                      Подробнее читайте в RFC 2145.

                      P.S. Кстати, не стоит ещё удивляться также, что в запросах без хоста (в строке запроса или заголовке Host) в принципе не работает виртуальный хостинг.
                      –2
                      > На третьем запросе сервера «посыпались». И нет ни одного ответа «HTTP/1.0 200 OK».

                      Я там отдаю 403 Forbidden. Сервер не должен отвечать на запрос, в котором нет корректного хоста. См. DNS Rebinding.
                        0
                        Неправильно.

                        HTTP/1.1 — это не 403 Forbidden, а именно 400 Bad Request. Потому, что это неправильно составленный запрос
                        В HTTP/1.0 заголовок Host необязателен, так что GET / HTTP/1.0 без Host — вполне валидный запрос
                          0
                          С точки зрения стандартов Вы правы.

                          Но это ещё и вопрос безопасности. Во избежание атак DNS Rebinding запросы, не направленные на правильный хост (или его поддомены), не должны отдавать ничего хорошего. Иначе это потенциальная дыра.

                          Другой вопрос — будут ли клиенты, умеющие только HTTP/1.0, кэшировать результаты DNS-запросов (тем самым добавляя «клиентскую сторону» уязвимости). Мы никак не можем гарантировать, что не будут. Даже если клиент этого не делает, может влезть nscd или что-то в этом роде.

                          Статический сайт с общедоступной информацией и отсутствием логина/форм/скриптов может от DNS Rebinding и не защищаться. Перечисленные в Вашем посте сервисы — обязаны.
                            +1
                            Во-первых, пост не мой.

                            Во вторых, кэширование dns-ответов на клиентской системе как раз наоборот блокирует атаку. Если у меня ответ 192.0.2.12 закэшировался в браузере, атакующий может хоть кол на голове dns-сервера тесать и подставлять в зону любые адреса, хоть 127.0.0.1 — мой браузер будет обращаться туда, куда закэшировал.

                            В третьих, те серверы, которые в реальности могли бы пострадать, не удастся настроить таким образом. Я имею ввиду веб-интерфейсы, которые действительно находятся в локальной сети: модемы, nas, точки доступа, веб-камеры и прочее подобное оборудование. Однако, чтобы с ними что-то сделать, нужно авторизоваться, что сделать будет сложно.

                            А в четвёртых, те веб-серверы, которые всё-таки можно так настроить, обычно находятся не в локальной сети, к ним можно сделать запрос без Host или с неверным Host и безо всякой DNS Rebinding. Они могут в ответ на HTTP/1.0 без Host: выдавать не 403, а 302, 204, 205, или даже 200 с объяснением, что происходит — мы получим полное соответствие протоколу и отсутствие возможности такой атаки. В моём случае клиент обычно увидит default virtualhost, который говорит «It works!» — какая уж тут уязвимость.

                      Only users with full accounts can post comments. Log in, please.