Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,9 @@ jobs:
- tests/mock_vws/test_content_length.py::TestIncorrect::test_not_integer
- tests/mock_vws/test_content_length.py::TestIncorrect::test_too_large
- tests/mock_vws/test_content_length.py::TestIncorrect::test_too_small
- tests/mock_vws/test_header_size.py::TestOversizedHeaderLine::test_header_too_large
- tests/mock_vws/test_header_size.py::TestOversizedHeaderLine::test_cookie_too_large
- tests/mock_vws/test_header_size.py::TestOversizedHeaderLine::test_large_header_within_limit
- tests/mock_vws/test_database_summary.py::TestDatabaseSummary::test_success
- tests/mock_vws/test_database_summary.py::TestDatabaseSummary::test_active_images
- tests/mock_vws/test_database_summary.py::TestDatabaseSummary::test_failed_images
Expand Down
24 changes: 17 additions & 7 deletions docs/source/differences-to-vws.rst
Original file line number Diff line number Diff line change
Expand Up @@ -134,16 +134,26 @@ The mock uses the fixed sample value ``us-east-2, us-west-2`` for
``x-aws-region`` response headers. The regions returned by the real Vuforia
Web Services can differ, so tests should not rely on the mock's exact value.

.. _differences-nginx-error-cases:

NGINX Error cases
-----------------

Vuforia uses NGINX.
This has error handling which is not duplicated in the mock.
For example, Vuforia is documented as returning a 400 (``BAD REQUEST``) response if a header or cookie is given which is larger than 8 KiB.

.. admonition:: Unverified assumption

:ref:`unverified-nginx-oversized-header-or-cookie`
Vuforia uses NGINX in front of both the Target API and the Query API.
NGINX reads each request header line into an 8 KiB buffer, and returns a 400 (``BAD REQUEST``) response with an HTML body titled ``400 Request Header Or Cookie Too Large`` for a line which does not fit.
The line's terminating CRLF also counts towards the buffer, so the longest accepted line is 8190 bytes, where a line is the header name, a colon, a space and the value.
This was observed against real Vuforia on 2026-09-08.

The mock returns that response for any header line longer than 8190 bytes.
The mock does not implement the following related behaviors, which were observed in the same session:

* The Target API's Envoy layer lets a ``Cookie`` line slightly over the limit through.
A ``Cookie`` line of 8193 bytes was accepted and one of 8300 bytes was rejected.
* The Target API's AWS load balancer rejects a header line of 16384 bytes or more itself, with a shorter HTML body and a ``Server: awselb/2.0`` header.
A ``Cookie`` line of that size passes the load balancer and is rejected by NGINX instead.
* The Query API's application server rejects a request whose headers total about 8 KiB with a 431 (``REQUEST HEADER FIELDS TOO LARGE``) HTML response before the NGINX limit is reached.
With the headers which a query normally has, a header line of 7500 bytes was accepted and one of 8000 bytes was rejected this way.
* The Model Target Web API, the OAuth2 token endpoint and reco counts report downloads in the mock do not apply the limit.

Result codes
------------
Expand Down
13 changes: 0 additions & 13 deletions docs/source/unverified-behavior.rst
Original file line number Diff line number Diff line change
Expand Up @@ -77,19 +77,6 @@ The mock does not implement this, so a user of the mock sees a successful respon

A database with more than a million images would verify this, which a test account cannot hold.

.. _unverified-nginx-oversized-header-or-cookie:

Large headers and cookies
-------------------------

:Category: never-attempted
:API: Cross-cutting request handling

Vuforia runs behind NGINX, which is documented as returning a 400 (``BAD REQUEST``) response for a header or a cookie larger than 8 KiB.
The mock does not implement this, and no test sends such a request to either.

Sending a request with a header larger than 8 KiB to a real database would verify this.

.. _unverified-reco-counts-report-not-ready:

A reco counts report which is not ready
Expand Down
1 change: 1 addition & 0 deletions newsfragments/3571.change
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
The mock now returns NGINX's ``400 Request Header Or Cookie Too Large`` response for any request header line longer than 8190 bytes, as real Vuforia does.
27 changes: 26 additions & 1 deletion src/mock_vws/_mock_common.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
import uuid
from collections.abc import Iterable, Mapping
from dataclasses import dataclass
from typing import Any, override
from typing import Any, Final, override

from beartype import beartype

Expand Down Expand Up @@ -144,3 +144,28 @@ def json_dump(*, body: dict[str, Any]) -> str:
JSON dump of data in the same way that Vuforia dumps data.
"""
return json.dumps(obj=body, separators=(",", ":"))


# NGINX, which sits in front of both Vuforia APIs, reads each header line
# into an 8 KiB buffer which also holds the line's terminating CRLF.
# A line of 8190 bytes is accepted and a line of 8191 bytes is rejected.
MAX_HEADER_LINE_LENGTH: Final[int] = 8190


@beartype
def has_oversized_header_line(*, request_headers: Mapping[str, str]) -> bool:
"""Whether any header line is too long for NGINX's header buffer.

A header line is the header name, a colon, a space and the value, as
sent on the wire.

Args:
request_headers: The headers sent with the request.

Returns:
Whether any header line is longer than ``MAX_HEADER_LINE_LENGTH``.
"""
return any(
len(f"{name}: {value}".encode()) > MAX_HEADER_LINE_LENGTH
for name, value in request_headers.items()
)
5 changes: 5 additions & 0 deletions src/mock_vws/_query_validators/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
validate_date_in_range,
)
from .fields_validators import validate_extra_fields
from .header_size_validators import validate_header_lines_not_too_large
from .image_validators import (
validate_image_dimensions,
validate_image_field_given,
Expand All @@ -49,13 +50,17 @@ def run_query_validators(
) -> None:
"""Run all validators.

NGINX rejects a request with an over-long header line before it reaches
Vuforia, so that is checked first.

Args:
request_path: The path of the request.
request_headers: The headers sent with the request.
request_body: The body of the request.
request_method: The HTTP method of the request.
databases: All Vuforia databases.
"""
validate_header_lines_not_too_large(request_headers=request_headers)
validate_content_length_header_is_int(request_headers=request_headers)
validate_content_length_header_not_too_large(
request_headers=request_headers,
Expand Down
37 changes: 37 additions & 0 deletions src/mock_vws/_query_validators/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -457,6 +457,43 @@ def __init__(self) -> None:
}


@beartype
class RequestHeaderOrCookieTooLargeError(ValidatorError):
"""Exception raised when a request header line is too long for NGINX.

NGINX rejects the request before it reaches the Vuforia application,
so this takes precedence over every other validation, including
authorization.
"""

def __init__(self) -> None:
"""Initialize an NGINX request header too large response."""
super().__init__()
self.status_code = HTTPStatus.BAD_REQUEST
self.response_text = "".join(
f"{line}\r\n"
for line in (
"<html>",
(
"<head><title>400 Request Header Or Cookie Too Large"
"</title></head>"
),
"<body>",
"<center><h1>400 Bad Request</h1></center>",
"<center>Request Header Or Cookie Too Large</center>",
"<hr><center>nginx</center>",
"</body>",
"</html>",
)
)
self.headers = {
**_BASE_HEADERS,
"Content-Type": "text/html",
"Date": http_date(),
"Content-Length": str(object=len(self.response_text)),
}


@beartype
class RequestEntityTooLargeError(ValidatorError):
"""Exception raised when the given image file size is too large."""
Expand Down
32 changes: 32 additions & 0 deletions src/mock_vws/_query_validators/header_size_validators.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
"""Validators for the size of request headers."""

import logging
from collections.abc import Mapping

from beartype import beartype

from mock_vws._mock_common import has_oversized_header_line
from mock_vws._query_validators.exceptions import (
RequestHeaderOrCookieTooLargeError,
)

_LOGGER = logging.getLogger(name=__name__)


@beartype
def validate_header_lines_not_too_large(
*,
request_headers: Mapping[str, str],
) -> None:
"""Validate that no header line is too long for NGINX.

Args:
request_headers: The headers sent with the request.

Raises:
RequestHeaderOrCookieTooLargeError: A header line is longer than
NGINX's header buffer.
"""
if has_oversized_header_line(request_headers=request_headers):
_LOGGER.warning(msg="A request header line is too large.")
raise RequestHeaderOrCookieTooLargeError
6 changes: 5 additions & 1 deletion src/mock_vws/_services_validators/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
validate_authorization,
)
from .context import ValidatorContext
from .header_size_validators import validate_header_lines_not_too_large
from .request_rate_limiter import RequestRateLimiter
from .routes import match_route

Expand All @@ -29,7 +30,9 @@ def run_services_validators[DatabaseT: AnyDatabase](
) -> DatabaseT:
"""Run the validators which apply to the request.

Every request is authorized first, because the validators which follow
NGINX rejects a request with an over-long header line before it reaches
Vuforia, so that is checked first.
Every request is then authorized, because the validators which follow
are given the database which the request's server keys belong to. Which
validators follow, and in which order, is decided by the route the
request was made to. See :py:mod:`mock_vws._services_validators.routes`.
Expand All @@ -45,6 +48,7 @@ def run_services_validators[DatabaseT: AnyDatabase](
Returns:
The database which the request's server keys belong to.
"""
validate_header_lines_not_too_large(request_headers=request_headers)
validate_auth_header_exists(request_headers=request_headers)
validate_auth_header_has_signature(request_headers=request_headers)
validate_access_key_exists(
Expand Down
37 changes: 37 additions & 0 deletions src/mock_vws/_services_validators/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -356,6 +356,43 @@ def __init__(self) -> None: # pragma: no cover
}


@beartype
class RequestHeaderOrCookieTooLargeError(ValidatorError):
"""Exception raised when a request header line is too long for NGINX.

NGINX rejects the request before it reaches the Vuforia application,
so this takes precedence over every other validation, including
authorization.
"""

def __init__(self) -> None:
"""Initialize an NGINX request header too large response."""
super().__init__()
self.status_code = HTTPStatus.BAD_REQUEST
self.response_text = "".join(
f"{line}\r\n"
for line in (
"<html>",
(
"<head><title>400 Request Header Or Cookie Too Large"
"</title></head>"
),
"<body>",
"<center><h1>400 Bad Request</h1></center>",
"<center>Request Header Or Cookie Too Large</center>",
"<hr><center>nginx</center>",
"</body>",
"</html>",
)
)
self.headers = {
**_STANDARD_HEADERS,
"Content-Type": "text/html",
"Date": http_date(),
"Content-Length": str(object=len(self.response_text)),
}


@beartype
class ContentLengthHeaderNotIntError(ValidatorError):
"""
Expand Down
32 changes: 32 additions & 0 deletions src/mock_vws/_services_validators/header_size_validators.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
"""Validators for the size of request headers."""

import logging
from collections.abc import Mapping

from beartype import beartype

from mock_vws._mock_common import has_oversized_header_line
from mock_vws._services_validators.exceptions import (
RequestHeaderOrCookieTooLargeError,
)

_LOGGER = logging.getLogger(name=__name__)


@beartype
def validate_header_lines_not_too_large(
*,
request_headers: Mapping[str, str],
) -> None:
"""Validate that no header line is too long for NGINX.

Args:
request_headers: The headers sent with the request.

Raises:
RequestHeaderOrCookieTooLargeError: A header line is longer than
NGINX's header buffer.
"""
if has_oversized_header_line(request_headers=request_headers):
_LOGGER.warning(msg="A request header line is too large.")
raise RequestHeaderOrCookieTooLargeError
Loading