Имеется приложение, которое должно принимать данные через POST-запросы от клиентов.
Перед этим приложением имеется некий прокси, неважно какой — AWS Application Load Balancer, NGINX или любой другой. Мы изначально столкнулись с проблемой на AWS ALB, потом я начал тестить на NGINX, что бы искючить влияение самого AWS-сервиса — воспроизводится везде, т.к. не зависит от проксирующей службы.
Прокси помимо проксирования трафика к приложению выполняет редирект с HTTP (80) на HTTPS (443).
Собственно, проблема возникает именно во время такого редиректа:
клиент отправляет POST по HTTP
прокси возвращает клиенту редирект301 или 302 на HTTPS
клиент отправляет запрос на HTTPS, но:
либо запрос превращается в GET
либо он остаётся POST, но все данные после редиректа «пропадают»
Contents
Setup — тестовая площадка
Для тестирования будем использовать следующий сетап:
на входе API-запросы принимаются NGINX
NGINX через proxy_pass по HTTP передаёт запрос бекенду
в роли бекенда используем Go-приложение в Docker-контейнере, что бы воспроизвести проблему с POST, который превращается в GET
и Python-приложение, что бы воспроизвести проблему с «потерянными» данными и пустым Сontent-length
NGINX
Тут всё совершенно стандартно — обычный NGINX, который принимает соединения на порт 80, и редиректит их на HTTPS:
Там на самом деле несколько контейнеров, нас интересует их проксирующий go-queue-consumer, в котором крутися стандартный NGINX (весь этот сетап ещё в Proof of Concept, потому не удивляйтесь количеству NGINX в этой схеме).
Нам от него интересны логи, в которых будет POST или GET.
Python web-server
И для тестирования HTTP message body — используем нагугленный Python-скрипт:
#!/usr/bin/env python3
"""
Very simple HTTP server in python for logging requests
Usage::
./server.py [<port>]
"""
from http.server import BaseHTTPRequestHandler, HTTPServer
import logging
class S(BaseHTTPRequestHandler):
def _set_response(self):
self.send_response(200)
self.send_header('Content-type', 'text/html')
self.end_headers()
def do_GET(self):
logging.info("GET request,\nPath: %s\nHeaders:\n%s\n", str(self.path), str(self.headers))
self._set_response()
self.wfile.write("GET request for {}".format(self.path).encode('utf-8'))
def do_POST(self):
content_length = int(self.headers['Content-Length']) # <--- Gets the size of data
post_data = self.rfile.read(content_length) # <--- Gets the data itself
logging.info("POST request,\nPath: %s\nHeaders:\n%s\n\nBody:\n%s\n",
str(self.path), str(self.headers), post_data.decode('utf-8'))
self._set_response()
self.wfile.write("POST request for {}".format(self.path).encode('utf-8'))
def run(server_class=HTTPServer, handler_class=S, port=8081):
logging.basicConfig(level=logging.INFO)
server_address = ('', port)
httpd = server_class(server_address, handler_class)
logging.info('Starting httpd...\n')
try:
httpd.serve_forever()
except KeyboardInterrupt:
pass
httpd.server_close()
logging.info('Stopping httpd...\n')
if __name__ == '__main__':
from sys import argv
if len(argv) == 2:
run(port=int(argv[1]))
else:
run()
Примеры — воспроизводим проблему
POST теряет данные после редиректа
Первое, с чем столкнулись, и что вообще заставило углубиться в проблему — после выполнения редиректа с HTTP на HTTPS — POST-запрос терял данные.
Т.е, обратился бекенд-разработчик, который сказал, что после редиректа на бекенд не приходят данные.
Что бы воспроизвести проблему — используем Python-скрипт, приведённый выше.
В процессе дебага описанной выше проблемы, когда на бекенд не доходили данные при POST, нашлось ещё одно интересное поведение запросов при HTTP-редиректах.
Теперь посмотрим, как POST превращается в …. GET 🙂
«Всё сложно». Детали поведения рассмотрим в конце этого поста, сейчас — давайте попробуем один и тот же запрос на один и тот же ендпоинт, но используя два разных клиента — curl, и Postman (в виде standalone-приложения на рабочей машине).
curl
Выполняем запрос с типом POST на HTTP, добавляем -L, что бы проследовать по редиректам на HTTPS.
Тут в роли бекенда уже используем не Python-скрипт, как в примерах выше, а наш реальный бекенд в Docker-контейнере, что бы продемонстрировать его нормальную и не очень работу.
Сами данные значения и ошибки сейчас не имеют — нам интересен только тип запроса в логах NGINX:
curl -vL -X POST http://dev.poc.example.com/skin/api/v1/receipt -d "{}"
...
> POST /skin/api/v1/receipt HTTP/1.1
> Host: dev.poc.example.com
> User-Agent: curl/7.67.0
> Accept: */*
>
* Mark bundle as not supporting multiuse
< HTTP/1.1 400 Bad Request
< Server: nginx/1.10.3
< Date: Sat, 23 Nov 2019 10:07:37 GMT
< Content-Type: application/json; charset=utf-8
< Content-Length: 58
< Connection: keep-alive
<
* Connection #1 to host dev.poc.example.com left intact
{"message":"Validation failed: unable to parse json body"}
Ещё раз — на ошибки не обращаем внимания, т.к. реальные данные к бекенду не отправили, нас сейчас интересует исключительно тип запроса в логах, давайте ещё глянем в логах NGINX:
Note: When automatically redirecting a POST request after
receiving a 301 status code, some existing HTTP/1.0 user agents will erroneously change it into a GET request.
Т.е. некоторые существующие агенты HTTP/1.0 после выполнения POST и получения 301 — меняют его тип (хотя не должны, см. ниже), и выполняют GET.
Note: RFC 1945 and RFC 2068 specify that the client is not allowed to change the method on the redirected request. However, most existing user agent implementations treat 302 as if it were a 303
response, performing a GET on the Location field-value regardless of the original request method. The status codes 303 and 307 have
been added for servers that wish to make unambiguously clear which
kind of reaction is expected of the client.
The response to the request can be found under a different URI and SHOULD be retrieved using a GET method on that resource.
Т.е., когда клиент считает, что он получил 303 код — он всегда выполняет GET.
Т.е, что мы видим в случае с Postman (и нашими мобильными клиентами, на которых проблема и проявилась изначально):
клиент отправляет POST на HTTP
получает редирект на HTTPS с кодом 301 или 302
воспринимает его, как редирект 303
и меняет тип своего запроса уже к HTTPS на GET, с «потерей» отправленных данных
Решение
Найти решение помогла документация от Mozilla (хотя подсказка была и в Notes RFC 2016 по 302), которая в своей документации к 301 и 302 явно говорит:
It is therefore recommended to set the 302 code only as a response for GET or HEAD methods and to use 307 Temporary Redirect instead, as the method change is explicitly prohibited in that case.