Skip to content

Commit c2e832a

Browse files
authored
Merge pull request #49 from michalpokusa/prevent-parent-directory-access
Prevent parent directory access, custom Errors
2 parents a8b68f1 + 9e173ef commit c2e832a

12 files changed

+200
-75
lines changed

adafruit_httpserver/exceptions.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
# SPDX-FileCopyrightText: Copyright (c) 2022 Dan Halbert for Adafruit Industries
2+
#
3+
# SPDX-License-Identifier: MIT
4+
"""
5+
`adafruit_httpserver.exceptions`
6+
====================================================
7+
* Author(s): Michał Pokusa
8+
"""
9+
10+
11+
class InvalidPathError(Exception):
12+
"""
13+
Parent class for all path related errors.
14+
"""
15+
16+
17+
class ParentDirectoryReferenceError(InvalidPathError):
18+
"""
19+
Path contains ``..``, a reference to the parent directory.
20+
"""
21+
22+
def __init__(self, path: str) -> None:
23+
"""Creates a new ``ParentDirectoryReferenceError`` for the ``path``."""
24+
super().__init__(f"Parent directory reference in path: {path}")
25+
26+
27+
class BackslashInPathError(InvalidPathError):
28+
"""
29+
Backslash ``\\`` in path.
30+
"""
31+
32+
def __init__(self, path: str) -> None:
33+
"""Creates a new ``BackslashInPathError`` for the ``path``."""
34+
super().__init__(f"Backslash in path: {path}")
35+
36+
37+
class ResponseAlreadySentError(Exception):
38+
"""
39+
Another ``HTTPResponse`` has already been sent. There can only be one per ``HTTPRequest``.
40+
"""
41+
42+
43+
class FileNotExistsError(Exception):
44+
"""
45+
Raised when a file does not exist.
46+
"""
47+
48+
def __init__(self, path: str) -> None:
49+
"""
50+
Creates a new ``FileNotExistsError`` for the file at ``path``.
51+
"""
52+
super().__init__(f"File does not exist: {path}")

adafruit_httpserver/response.py

Lines changed: 67 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
"""
99

1010
try:
11-
from typing import Optional, Dict, Union, Tuple
11+
from typing import Optional, Dict, Union, Tuple, Callable
1212
from socket import socket
1313
from socketpool import SocketPool
1414
except ImportError:
@@ -17,12 +17,33 @@
1717
import os
1818
from errno import EAGAIN, ECONNRESET
1919

20+
from .exceptions import (
21+
BackslashInPathError,
22+
FileNotExistsError,
23+
ParentDirectoryReferenceError,
24+
ResponseAlreadySentError,
25+
)
2026
from .mime_type import MIMEType
2127
from .request import HTTPRequest
2228
from .status import HTTPStatus, CommonHTTPStatus
2329
from .headers import HTTPHeaders
2430

2531

32+
def _prevent_multiple_send_calls(function: Callable):
33+
"""
34+
Decorator that prevents calling ``send`` or ``send_file`` more than once.
35+
"""
36+
37+
def wrapper(self: "HTTPResponse", *args, **kwargs):
38+
if self._response_already_sent: # pylint: disable=protected-access
39+
raise ResponseAlreadySentError
40+
41+
result = function(self, *args, **kwargs)
42+
return result
43+
44+
return wrapper
45+
46+
2647
class HTTPResponse:
2748
"""
2849
Response to a given `HTTPRequest`. Use in `HTTPServer.route` decorator functions.
@@ -73,8 +94,8 @@ def route_func(request):
7394
"""
7495
Defaults to ``text/plain`` if not set.
7596
76-
Can be explicitly provided in the constructor, in `send()` or
77-
implicitly determined from filename in `send_file()`.
97+
Can be explicitly provided in the constructor, in ``send()`` or
98+
implicitly determined from filename in ``send_file()``.
7899
79100
Common MIME types are defined in `adafruit_httpserver.mime_type.MIMEType`.
80101
"""
@@ -94,7 +115,7 @@ def __init__( # pylint: disable=too-many-arguments
94115
Sets `status`, ``headers`` and `http_version`
95116
and optionally default ``content_type``.
96117
97-
To send the response, call `send` or `send_file`.
118+
To send the response, call ``send`` or ``send_file``.
98119
For chunked response use
99120
``with HTTPRequest(request, content_type=..., chunked=True) as r:`` and `send_chunk`.
100121
"""
@@ -115,7 +136,7 @@ def _send_headers(
115136
) -> None:
116137
"""
117138
Sends headers.
118-
Implicitly called by `send` and `send_file` and in
139+
Implicitly called by ``send`` and ``send_file`` and in
119140
``with HTTPResponse(request, chunked=True) as response:`` context manager.
120141
"""
121142
headers = self.headers.copy()
@@ -141,6 +162,7 @@ def _send_headers(
141162
self.request.connection, response_message_header.encode("utf-8")
142163
)
143164

165+
@_prevent_multiple_send_calls
144166
def send(
145167
self,
146168
body: str = "",
@@ -152,8 +174,6 @@ def send(
152174
153175
Should be called **only once** per response.
154176
"""
155-
if self._response_already_sent:
156-
raise RuntimeError("Response was already sent")
157177

158178
if getattr(body, "encode", None):
159179
encoded_response_message_body = body.encode("utf-8")
@@ -167,12 +187,41 @@ def send(
167187
self._send_bytes(self.request.connection, encoded_response_message_body)
168188
self._response_already_sent = True
169189

170-
def send_file(
190+
@staticmethod
191+
def _check_file_path_is_valid(file_path: str) -> bool:
192+
"""
193+
Checks if ``file_path`` is valid.
194+
If not raises error corresponding to the problem.
195+
"""
196+
197+
# Check for backslashes
198+
if "\\" in file_path: # pylint: disable=anomalous-backslash-in-string
199+
raise BackslashInPathError(file_path)
200+
201+
# Check each component of the path for parent directory references
202+
for part in file_path.split("/"):
203+
if part == "..":
204+
raise ParentDirectoryReferenceError(file_path)
205+
206+
@staticmethod
207+
def _get_file_length(file_path: str) -> int:
208+
"""
209+
Tries to get the length of the file at ``file_path``.
210+
Raises ``FileNotExistsError`` if file does not exist.
211+
"""
212+
try:
213+
return os.stat(file_path)[6]
214+
except OSError:
215+
raise FileNotExistsError(file_path) # pylint: disable=raise-missing-from
216+
217+
@_prevent_multiple_send_calls
218+
def send_file( # pylint: disable=too-many-arguments
171219
self,
172220
filename: str = "index.html",
173221
root_path: str = "./",
174222
buffer_size: int = 1024,
175223
head_only: bool = False,
224+
safe: bool = True,
176225
) -> None:
177226
"""
178227
Send response with content of ``filename`` located in ``root_path``.
@@ -181,25 +230,26 @@ def send_file(
181230
182231
Should be called **only once** per response.
183232
"""
184-
if self._response_already_sent:
185-
raise RuntimeError("Response was already sent")
233+
234+
if safe:
235+
self._check_file_path_is_valid(filename)
186236

187237
if not root_path.endswith("/"):
188238
root_path += "/"
189-
try:
190-
file_length = os.stat(root_path + filename)[6]
191-
except OSError:
192-
# If the file doesn't exist, return 404.
193-
HTTPResponse(self.request, status=CommonHTTPStatus.NOT_FOUND_404).send()
194-
return
239+
if filename.startswith("/"):
240+
filename = filename[1:]
241+
242+
full_file_path = root_path + filename
243+
244+
file_length = self._get_file_length(full_file_path)
195245

196246
self._send_headers(
197247
content_type=MIMEType.from_file_name(filename),
198248
content_length=file_length,
199249
)
200250

201251
if not head_only:
202-
with open(root_path + filename, "rb") as file:
252+
with open(full_file_path, "rb") as file:
203253
while bytes_read := file.read(buffer_size):
204254
self._send_bytes(self.request.connection, bytes_read)
205255
self._response_already_sent = True

adafruit_httpserver/server.py

Lines changed: 47 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616

1717
from errno import EAGAIN, ECONNRESET, ETIMEDOUT
1818

19+
from .exceptions import FileNotExistsError, InvalidPathError
1920
from .methods import HTTPMethod
2021
from .request import HTTPRequest
2122
from .response import HTTPResponse
@@ -26,18 +27,19 @@
2627
class HTTPServer:
2728
"""A basic socket-based HTTP server."""
2829

29-
def __init__(self, socket_source: Protocol) -> None:
30+
def __init__(self, socket_source: Protocol, root_path: str) -> None:
3031
"""Create a server, and get it ready to run.
3132
3233
:param socket: An object that is a source of sockets. This could be a `socketpool`
3334
in CircuitPython or the `socket` module in CPython.
35+
:param str root_path: Root directory to serve files from
3436
"""
3537
self._buffer = bytearray(1024)
3638
self._timeout = 1
3739
self.routes = _HTTPRoutes()
3840
self._socket_source = socket_source
3941
self._sock = None
40-
self.root_path = "/"
42+
self.root_path = root_path
4143

4244
def route(self, path: str, method: HTTPMethod = HTTPMethod.GET) -> Callable:
4345
"""
@@ -63,32 +65,28 @@ def route_decorator(func: Callable) -> Callable:
6365

6466
return route_decorator
6567

66-
def serve_forever(self, host: str, port: int = 80, root_path: str = "") -> None:
68+
def serve_forever(self, host: str, port: int = 80) -> None:
6769
"""Wait for HTTP requests at the given host and port. Does not return.
6870
6971
:param str host: host name or IP address
7072
:param int port: port
71-
:param str root_path: root directory to serve files from
7273
"""
73-
self.start(host, port, root_path)
74+
self.start(host, port)
7475

7576
while True:
7677
try:
7778
self.poll()
7879
except OSError:
7980
continue
8081

81-
def start(self, host: str, port: int = 80, root_path: str = "") -> None:
82+
def start(self, host: str, port: int = 80) -> None:
8283
"""
8384
Start the HTTP server at the given host and port. Requires calling
8485
poll() in a while loop to handle incoming requests.
8586
8687
:param str host: host name or IP address
8788
:param int port: port
88-
:param str root_path: root directory to serve files from
8989
"""
90-
self.root_path = root_path
91-
9290
self._sock = self._socket_source.socket(
9391
self._socket_source.AF_INET, self._socket_source.SOCK_STREAM
9492
)
@@ -158,38 +156,50 @@ def poll(self):
158156
conn, received_body_bytes, content_length
159157
)
160158

159+
# Find a handler for the route
161160
handler = self.routes.find_handler(
162161
_HTTPRoute(request.path, request.method)
163162
)
164163

165-
# If a handler for route exists and is callable, call it.
166-
if handler is not None and callable(handler):
167-
handler(request)
168-
169-
# If no handler exists and request method is GET, try to serve a file.
170-
elif handler is None and request.method in (
171-
HTTPMethod.GET,
172-
HTTPMethod.HEAD,
173-
):
174-
filename = "index.html" if request.path == "/" else request.path
175-
HTTPResponse(request).send_file(
176-
filename=filename,
177-
root_path=self.root_path,
178-
buffer_size=self.request_buffer_size,
179-
head_only=(request.method == HTTPMethod.HEAD),
164+
try:
165+
# If a handler for route exists and is callable, call it.
166+
if handler is not None and callable(handler):
167+
handler(request)
168+
169+
# If no handler exists and request method is GET or HEAD, try to serve a file.
170+
elif handler is None and request.method in (
171+
HTTPMethod.GET,
172+
HTTPMethod.HEAD,
173+
):
174+
filename = "index.html" if request.path == "/" else request.path
175+
HTTPResponse(request).send_file(
176+
filename=filename,
177+
root_path=self.root_path,
178+
buffer_size=self.request_buffer_size,
179+
head_only=(request.method == HTTPMethod.HEAD),
180+
)
181+
else:
182+
HTTPResponse(
183+
request, status=CommonHTTPStatus.BAD_REQUEST_400
184+
).send()
185+
186+
except InvalidPathError as error:
187+
HTTPResponse(request, status=CommonHTTPStatus.FORBIDDEN_403).send(
188+
str(error)
180189
)
181-
else:
182-
HTTPResponse(
183-
request, status=CommonHTTPStatus.BAD_REQUEST_400
184-
).send()
185-
186-
except OSError as ex:
187-
# handle EAGAIN and ECONNRESET
188-
if ex.errno == EAGAIN:
189-
# there is no data available right now, try again later.
190+
191+
except FileNotExistsError as error:
192+
HTTPResponse(request, status=CommonHTTPStatus.NOT_FOUND_404).send(
193+
str(error)
194+
)
195+
196+
except OSError as error:
197+
# Handle EAGAIN and ECONNRESET
198+
if error.errno == EAGAIN:
199+
# There is no data available right now, try again later.
190200
return
191-
if ex.errno == ECONNRESET:
192-
# connection reset by peer, try again later.
201+
if error.errno == ECONNRESET:
202+
# Connection reset by peer, try again later.
193203
return
194204
raise
195205

@@ -204,7 +214,7 @@ def request_buffer_size(self) -> int:
204214
205215
Example::
206216
207-
server = HTTPServer(pool)
217+
server = HTTPServer(pool, "/static")
208218
server.request_buffer_size = 2048
209219
210220
server.serve_forever(str(wifi.radio.ipv4_address))
@@ -226,7 +236,7 @@ def socket_timeout(self) -> int:
226236
227237
Example::
228238
229-
server = HTTPServer(pool)
239+
server = HTTPServer(pool, "/static")
230240
server.socket_timeout = 3
231241
232242
server.serve_forever(str(wifi.radio.ipv4_address))

adafruit_httpserver/status.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,9 @@ class CommonHTTPStatus(HTTPStatus): # pylint: disable=too-few-public-methods
3939
BAD_REQUEST_400 = HTTPStatus(400, "Bad Request")
4040
"""400 Bad Request"""
4141

42+
FORBIDDEN_403 = HTTPStatus(403, "Forbidden")
43+
"""403 Forbidden"""
44+
4245
NOT_FOUND_404 = HTTPStatus(404, "Not Found")
4346
"""404 Not Found"""
4447

docs/api.rst

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,3 +27,6 @@
2727

2828
.. automodule:: adafruit_httpserver.mime_type
2929
:members:
30+
31+
.. automodule:: adafruit_httpserver.exceptions
32+
:members:

0 commit comments

Comments
 (0)