Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5f95afd0a3 | |||
| 132cbe9b20 | |||
| ada69f773e | |||
| c5f3e53d88 |
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "stapler"
|
name = "stapler"
|
||||||
version = "1.4.2"
|
version = "1.5.0"
|
||||||
description = "Static pages as simple as a gzip file"
|
description = "Static pages as simple as a gzip file"
|
||||||
requires-python = ">=3.14"
|
requires-python = ">=3.14"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
|||||||
+54
-1
@@ -1,5 +1,6 @@
|
|||||||
import abc
|
import abc
|
||||||
import contextlib
|
import contextlib
|
||||||
|
import datetime
|
||||||
import http
|
import http
|
||||||
import http.cookiejar
|
import http.cookiejar
|
||||||
import http.server
|
import http.server
|
||||||
@@ -289,6 +290,7 @@ class RequestHandler(http.server.SimpleHTTPRequestHandler, BaseHandler):
|
|||||||
protocol_version = "HTTP/1.1"
|
protocol_version = "HTTP/1.1"
|
||||||
server_version = "StaplerServer/" + PKG_VERSION
|
server_version = "StaplerServer/" + PKG_VERSION
|
||||||
CERTBOT_CHALLENGE_PATH = "/.well-known/acme-challenge"
|
CERTBOT_CHALLENGE_PATH = "/.well-known/acme-challenge"
|
||||||
|
STAPLER_CHALLENGE_PATH = "/.well-known/stapler"
|
||||||
UPDATE_PATH_REGEX = re.compile(r"^\/([\w-]+)\/?$")
|
UPDATE_PATH_REGEX = re.compile(r"^\/([\w-]+)\/?$")
|
||||||
GET_PATH_REGEX = re.compile(r"^\/([\w-]+)($|\/)")
|
GET_PATH_REGEX = re.compile(r"^\/([\w-]+)($|\/)")
|
||||||
HOST_PART_REGEX = re.compile(r"^([a-z0-9]|[a-z0-9][a-z0-9-]{,61}[a-z0-9])$")
|
HOST_PART_REGEX = re.compile(r"^([a-z0-9]|[a-z0-9][a-z0-9-]{,61}[a-z0-9])$")
|
||||||
@@ -299,6 +301,7 @@ class RequestHandler(http.server.SimpleHTTPRequestHandler, BaseHandler):
|
|||||||
REDIRECT_HEADER = "X-Redirect"
|
REDIRECT_HEADER = "X-Redirect"
|
||||||
PROXY_HEADER = "X-Proxy"
|
PROXY_HEADER = "X-Proxy"
|
||||||
SPA_HEADER = "X-SPA"
|
SPA_HEADER = "X-SPA"
|
||||||
|
RATE_LIMIT = datetime.timedelta(seconds=1)
|
||||||
|
|
||||||
@typing.override
|
@typing.override
|
||||||
def __init__(
|
def __init__(
|
||||||
@@ -322,6 +325,7 @@ class RequestHandler(http.server.SimpleHTTPRequestHandler, BaseHandler):
|
|||||||
self.__target_redirect: str | None = None
|
self.__target_redirect: str | None = None
|
||||||
self.__target_proxy: str | None = None
|
self.__target_proxy: str | None = None
|
||||||
self.__target_spa: str | None = None
|
self.__target_spa: str | None = None
|
||||||
|
self.rate_limits: dict[str, datetime.datetime] = {}
|
||||||
try:
|
try:
|
||||||
super().__init__(*args, directory=params.data_dir, **kwargs, params=params) # ty:ignore[unknown-argument]
|
super().__init__(*args, directory=params.data_dir, **kwargs, params=params) # ty:ignore[unknown-argument]
|
||||||
except (BrokenPipeError, ConnectionResetError) as e:
|
except (BrokenPipeError, ConnectionResetError) as e:
|
||||||
@@ -405,6 +409,8 @@ class RequestHandler(http.server.SimpleHTTPRequestHandler, BaseHandler):
|
|||||||
self._pre_log_request()
|
self._pre_log_request()
|
||||||
if self._proxy_or_redirect():
|
if self._proxy_or_redirect():
|
||||||
return None
|
return None
|
||||||
|
if self._is_stapler_challenge(self.path):
|
||||||
|
return self.send_status_only(http.HTTPStatus.NO_CONTENT)
|
||||||
if self.path == "/" and self.host == self.default_host:
|
if self.path == "/" and self.host == self.default_host:
|
||||||
return self.send_basic_body(self.server_signature())
|
return self.send_basic_body(self.server_signature())
|
||||||
super().do_HEAD()
|
super().do_HEAD()
|
||||||
@@ -417,6 +423,8 @@ class RequestHandler(http.server.SimpleHTTPRequestHandler, BaseHandler):
|
|||||||
self._pre_log_request()
|
self._pre_log_request()
|
||||||
if self._proxy_or_redirect():
|
if self._proxy_or_redirect():
|
||||||
return None
|
return None
|
||||||
|
if self._is_stapler_challenge(self.path):
|
||||||
|
return self.send_status_only(http.HTTPStatus.NO_CONTENT)
|
||||||
if self.path == "/" and self.host == self.default_host:
|
if self.path == "/" and self.host == self.default_host:
|
||||||
return self.send_basic_body(self.server_signature())
|
return self.send_basic_body(self.server_signature())
|
||||||
super().do_GET()
|
super().do_GET()
|
||||||
@@ -545,7 +553,11 @@ class RequestHandler(http.server.SimpleHTTPRequestHandler, BaseHandler):
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
def _proxy_or_redirect(self) -> bool:
|
def _proxy_or_redirect(self) -> bool:
|
||||||
if self.has_token or self._is_certbot_challenge(self.path):
|
if (
|
||||||
|
self.has_token
|
||||||
|
or self._is_certbot_challenge(self.path)
|
||||||
|
or self._is_stapler_challenge(self.path)
|
||||||
|
):
|
||||||
return False
|
return False
|
||||||
if (page := self.__get_page(self.path)) is None:
|
if (page := self.__get_page(self.path)) is None:
|
||||||
return False
|
return False
|
||||||
@@ -570,6 +582,9 @@ class RequestHandler(http.server.SimpleHTTPRequestHandler, BaseHandler):
|
|||||||
self.certbot_www + path
|
self.certbot_www + path
|
||||||
).resolve().is_relative_to(self.certbot_www)
|
).resolve().is_relative_to(self.certbot_www)
|
||||||
|
|
||||||
|
def _is_stapler_challenge(self, path: str) -> bool:
|
||||||
|
return path.startswith(self.STAPLER_CHALLENGE_PATH)
|
||||||
|
|
||||||
@typing.override
|
@typing.override
|
||||||
def translate_path(self, path: str) -> str:
|
def translate_path(self, path: str) -> str:
|
||||||
if self._is_certbot_challenge(path):
|
if self._is_certbot_challenge(path):
|
||||||
@@ -596,7 +611,20 @@ class RequestHandler(http.server.SimpleHTTPRequestHandler, BaseHandler):
|
|||||||
path = f"/{page.path}/{page.spa}"
|
path = f"/{page.path}/{page.spa}"
|
||||||
return super().translate_path(path)
|
return super().translate_path(path)
|
||||||
|
|
||||||
|
def __check_rate_limit(self) -> bool:
|
||||||
|
now = datetime.datetime.now(tz=datetime.UTC)
|
||||||
|
address = self.address_string()
|
||||||
|
last_atempt = self.rate_limits.get(address, None)
|
||||||
|
self.rate_limits[address] = now
|
||||||
|
return last_atempt is None or now - last_atempt > self.RATE_LIMIT
|
||||||
|
|
||||||
|
def __clear_rate_limit(self) -> None:
|
||||||
|
del self.rate_limits[self.address_string()]
|
||||||
|
|
||||||
def __check_update_request(self) -> str | None:
|
def __check_update_request(self) -> str | None:
|
||||||
|
if not self.__check_rate_limit():
|
||||||
|
self.send_error(http.HTTPStatus.TOO_MANY_REQUESTS, "Rate limit exceeded")
|
||||||
|
return None
|
||||||
if not self._has_header(self.TOKEN_HEADER):
|
if not self._has_header(self.TOKEN_HEADER):
|
||||||
self.send_error(
|
self.send_error(
|
||||||
http.HTTPStatus.BAD_REQUEST, f"No {self.TOKEN_HEADER} header in request"
|
http.HTTPStatus.BAD_REQUEST, f"No {self.TOKEN_HEADER} header in request"
|
||||||
@@ -605,6 +633,7 @@ class RequestHandler(http.server.SimpleHTTPRequestHandler, BaseHandler):
|
|||||||
if not self.token_manager.is_valid(self.token):
|
if not self.token_manager.is_valid(self.token):
|
||||||
self.send_error(http.HTTPStatus.UNAUTHORIZED, "Invalid token")
|
self.send_error(http.HTTPStatus.UNAUTHORIZED, "Invalid token")
|
||||||
return None
|
return None
|
||||||
|
self.__clear_rate_limit()
|
||||||
if (sub_path := self.__get_path(self.path, self.UPDATE_PATH_REGEX)) is None:
|
if (sub_path := self.__get_path(self.path, self.UPDATE_PATH_REGEX)) is None:
|
||||||
self.send_error(http.HTTPStatus.BAD_REQUEST, "Invalid path")
|
self.send_error(http.HTTPStatus.BAD_REQUEST, "Invalid path")
|
||||||
return None
|
return None
|
||||||
@@ -664,6 +693,9 @@ class UpgradeHandler(RequestHandler):
|
|||||||
def do_HEAD(self) -> None:
|
def do_HEAD(self) -> None:
|
||||||
with self.handle_errors():
|
with self.handle_errors():
|
||||||
self._pre_log_request()
|
self._pre_log_request()
|
||||||
|
if self._is_stapler_challenge(self.path):
|
||||||
|
self.send_status_only(http.HTTPStatus.NO_CONTENT)
|
||||||
|
else:
|
||||||
self.send_redirect(f"https://{self.host}{self.path}")
|
self.send_redirect(f"https://{self.host}{self.path}")
|
||||||
self.close_connection = True
|
self.close_connection = True
|
||||||
|
|
||||||
@@ -674,3 +706,24 @@ class UpgradeHandler(RequestHandler):
|
|||||||
self.close_connection = True
|
self.close_connection = True
|
||||||
else:
|
else:
|
||||||
self.do_HEAD()
|
self.do_HEAD()
|
||||||
|
|
||||||
|
def do_POST(self) -> None:
|
||||||
|
self.do_HEAD()
|
||||||
|
|
||||||
|
def do_PUT(self) -> None:
|
||||||
|
self.do_HEAD()
|
||||||
|
|
||||||
|
def do_DELETE(self) -> None:
|
||||||
|
self.do_HEAD()
|
||||||
|
|
||||||
|
def do_OPTIONS(self) -> None:
|
||||||
|
self.do_HEAD()
|
||||||
|
|
||||||
|
def do_PATCH(self) -> None:
|
||||||
|
self.do_HEAD()
|
||||||
|
|
||||||
|
def do_CONNECT(self) -> None:
|
||||||
|
self.do_HEAD()
|
||||||
|
|
||||||
|
def do_TRACE(self) -> None:
|
||||||
|
self.do_HEAD()
|
||||||
|
|||||||
+54
-4
@@ -1,6 +1,7 @@
|
|||||||
import abc
|
import abc
|
||||||
import collections
|
import collections
|
||||||
import contextlib
|
import contextlib
|
||||||
|
import datetime
|
||||||
import http
|
import http
|
||||||
import http.server
|
import http.server
|
||||||
import io
|
import io
|
||||||
@@ -204,6 +205,17 @@ class TestRequestHandler(BaseHandlerTestCase):
|
|||||||
):
|
):
|
||||||
handler.do_HEAD()
|
handler.do_HEAD()
|
||||||
|
|
||||||
|
def test_do_head_stapler(self) -> None:
|
||||||
|
handler = self._get_handler("/.well-known/stapler/something")
|
||||||
|
with (
|
||||||
|
self.expects_status_only(
|
||||||
|
handler,
|
||||||
|
http.HTTPStatus.NO_CONTENT,
|
||||||
|
),
|
||||||
|
self.seal_mocks(),
|
||||||
|
):
|
||||||
|
handler.do_HEAD()
|
||||||
|
|
||||||
def test_do_head_forward(self) -> None:
|
def test_do_head_forward(self) -> None:
|
||||||
handler = self._get_handler("/file")
|
handler = self._get_handler("/file")
|
||||||
with (
|
with (
|
||||||
@@ -222,6 +234,17 @@ class TestRequestHandler(BaseHandlerTestCase):
|
|||||||
):
|
):
|
||||||
handler.do_GET()
|
handler.do_GET()
|
||||||
|
|
||||||
|
def test_do_get_stapler(self) -> None:
|
||||||
|
handler = self._get_handler("/.well-known/stapler/something")
|
||||||
|
with (
|
||||||
|
self.expects_status_only(
|
||||||
|
handler,
|
||||||
|
http.HTTPStatus.NO_CONTENT,
|
||||||
|
),
|
||||||
|
self.seal_mocks(),
|
||||||
|
):
|
||||||
|
handler.do_GET()
|
||||||
|
|
||||||
def test_do_get_forward_on_other_path(self) -> None:
|
def test_do_get_forward_on_other_path(self) -> None:
|
||||||
handler = self._get_handler("/file")
|
handler = self._get_handler("/file")
|
||||||
with (
|
with (
|
||||||
@@ -253,6 +276,7 @@ class TestRequestHandler(BaseHandlerTestCase):
|
|||||||
self.seal_mocks(),
|
self.seal_mocks(),
|
||||||
):
|
):
|
||||||
handler.do_PUT()
|
handler.do_PUT()
|
||||||
|
assert "127.0.0.1" in handler.rate_limits
|
||||||
|
|
||||||
def test_do_post_is_do_put(self) -> None:
|
def test_do_post_is_do_put(self) -> None:
|
||||||
handler = self._get_handler("/path")
|
handler = self._get_handler("/path")
|
||||||
@@ -284,6 +308,18 @@ class TestRequestHandler(BaseHandlerTestCase):
|
|||||||
self.seal_mocks(),
|
self.seal_mocks(),
|
||||||
):
|
):
|
||||||
handler.do_PUT()
|
handler.do_PUT()
|
||||||
|
assert "127.0.0.1" in handler.rate_limits
|
||||||
|
|
||||||
|
def test_do_put_rate_limit(self) -> None:
|
||||||
|
handler = self._get_handler("/path", {"X-Token": "secret"})
|
||||||
|
handler.rate_limits["127.0.0.1"] = datetime.datetime.now(tz=datetime.UTC)
|
||||||
|
with (
|
||||||
|
self.expects_error(
|
||||||
|
handler, http.HTTPStatus.TOO_MANY_REQUESTS, "Rate limit exceeded"
|
||||||
|
),
|
||||||
|
self.seal_mocks(),
|
||||||
|
):
|
||||||
|
handler.do_PUT()
|
||||||
|
|
||||||
def test_do_put_invalid_path(self) -> None:
|
def test_do_put_invalid_path(self) -> None:
|
||||||
handler = self._get_handler("/pa.th", {"X-Token": "secret"})
|
handler = self._get_handler("/pa.th", {"X-Token": "secret"})
|
||||||
@@ -770,6 +806,7 @@ class TestRequestHandler(BaseHandlerTestCase):
|
|||||||
self.seal_mocks(),
|
self.seal_mocks(),
|
||||||
):
|
):
|
||||||
handler.do_DELETE()
|
handler.do_DELETE()
|
||||||
|
assert "127.0.0.1" in handler.rate_limits
|
||||||
|
|
||||||
def test_do_delete_invalid_token(self) -> None:
|
def test_do_delete_invalid_token(self) -> None:
|
||||||
handler = self._get_handler("/path", {"X-Token": "secret"})
|
handler = self._get_handler("/path", {"X-Token": "secret"})
|
||||||
@@ -779,6 +816,7 @@ class TestRequestHandler(BaseHandlerTestCase):
|
|||||||
self.seal_mocks(),
|
self.seal_mocks(),
|
||||||
):
|
):
|
||||||
handler.do_DELETE()
|
handler.do_DELETE()
|
||||||
|
assert "127.0.0.1" in handler.rate_limits
|
||||||
|
|
||||||
def test_do_delete_invalid_path(self) -> None:
|
def test_do_delete_invalid_path(self) -> None:
|
||||||
handler = self._get_handler("/pa.th", {"X-Token": "secret"})
|
handler = self._get_handler("/pa.th", {"X-Token": "secret"})
|
||||||
@@ -1336,21 +1374,22 @@ class TestUpgradeHandler(BaseHandlerTestCase):
|
|||||||
handler.data_dir = self.data_dir
|
handler.data_dir = self.data_dir
|
||||||
return handler
|
return handler
|
||||||
|
|
||||||
def test_do_get(self) -> None:
|
def test_do_upgrade(self) -> None:
|
||||||
handler = self._get_handler("/file")
|
handler = self._get_handler("/file")
|
||||||
|
for method in [method.value for method in http.HTTPMethod]:
|
||||||
with (
|
with (
|
||||||
|
self.subTest(None, method=method),
|
||||||
self.expects_status_only(
|
self.expects_status_only(
|
||||||
handler,
|
handler,
|
||||||
http.HTTPStatus.MOVED_PERMANENTLY,
|
http.HTTPStatus.MOVED_PERMANENTLY,
|
||||||
headers={"Location": "https://localhost/file"},
|
headers={"Location": "https://localhost/file"},
|
||||||
),
|
),
|
||||||
self.patch(
|
self.patch(
|
||||||
"http.server.SimpleHTTPRequestHandler.do_GET",
|
f"http.server.SimpleHTTPRequestHandler.do_{method}", count=0
|
||||||
count=0,
|
|
||||||
),
|
),
|
||||||
self.seal_mocks(),
|
self.seal_mocks(),
|
||||||
):
|
):
|
||||||
handler.do_GET()
|
getattr(handler, f"do_{method}")()
|
||||||
|
|
||||||
def test_do_get_certbot(self) -> None:
|
def test_do_get_certbot(self) -> None:
|
||||||
handler = self._get_handler("/.well-known/acme-challenge/abcde")
|
handler = self._get_handler("/.well-known/acme-challenge/abcde")
|
||||||
@@ -1373,3 +1412,14 @@ class TestUpgradeHandler(BaseHandlerTestCase):
|
|||||||
self.seal_mocks(),
|
self.seal_mocks(),
|
||||||
):
|
):
|
||||||
handler.do_HEAD()
|
handler.do_HEAD()
|
||||||
|
|
||||||
|
def test_do_head_stapler(self) -> None:
|
||||||
|
handler = self._get_handler("/.well-known/stapler/something")
|
||||||
|
with (
|
||||||
|
self.expects_status_only(
|
||||||
|
handler,
|
||||||
|
http.HTTPStatus.NO_CONTENT,
|
||||||
|
),
|
||||||
|
self.seal_mocks(),
|
||||||
|
):
|
||||||
|
handler.do_HEAD()
|
||||||
|
|||||||
Reference in New Issue
Block a user