Skip to content

Commit

Permalink
Merge branch 'master' of https://github.com/Arksine/moonraker
Browse files Browse the repository at this point in the history
  • Loading branch information
actions-user committed Jan 23, 2024
2 parents 0125bf1 + f44fc4b commit 623a585
Show file tree
Hide file tree
Showing 5 changed files with 118 additions and 84 deletions.
1 change: 1 addition & 0 deletions docs/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ The format is based on [Keep a Changelog].
- **klippy_connection**: Fixed a race condition that can result in
skipped subscription updates.
- **configheler**: Fixed inline comment parsing.
- **authorization**: Fixed blocking call to `socket.getfqdn()`

### Changed

Expand Down
12 changes: 10 additions & 2 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -469,8 +469,8 @@ trusted_clients:
# must be expressed in CIDR notation (see http://ip.sb/cidr for more info).
# For example, an entry of 192.168.1.0/24 will authorize IPs in the range of
# 192.168.1.1 - 192.168.1.254. Note that when specifying IPv4 ranges the
# last segment of the ip address must be 0. The default is no clients are
# trusted.
# last segment of the ip address must be 0. The default is no IPs or
# domains are trusted.
cors_domains:
http://klipper-printer.local
http://second-printer.local:7125
Expand Down Expand Up @@ -498,6 +498,14 @@ default_source: moonraker
# "moonraker" The default is "moonraker".
```

!!! Tip
When configuring the `trusted_clients` option it is generally recommended
to stick with IP ranges and avoid including domain names. When attempting to
authenticate a request against a domain name Moonraker must perform a DNS
lookup. If the DNS service is not available then authentication will fail
and an error will be returned. In addition, DNS lookups will introduce delay
in the response.

### `[ldap]`

The `ldap` module may be used by `[authorization]` to perform user
Expand Down
80 changes: 53 additions & 27 deletions moonraker/components/application.py
Original file line number Diff line number Diff line change
Expand Up @@ -458,27 +458,50 @@ async def load_template(self, asset_name: str) -> JinjaTemplate:
self.template_cache[asset_name] = asset_tmpl
return asset_tmpl

def _set_cors_headers(req_hdlr: tornado.web.RequestHandler) -> None:
request = req_hdlr.request
origin: Optional[str] = request.headers.get("Origin")
if origin is None:
return
req_hdlr.set_header("Access-Control-Allow-Origin", origin)
if req_hdlr.request.method == "OPTIONS":
req_hdlr.set_header(
"Access-Control-Allow-Methods",
"GET, POST, PUT, DELETE, OPTIONS"
)
req_hdlr.set_header(
"Access-Control-Allow-Headers",
"Origin, Accept, Content-Type, X-Requested-With, "
"X-CRSF-Token, Authorization, X-Access-Token, "
"X-Api-Key"
)
req_pvt_header = req_hdlr.request.headers.get(
"Access-Control-Request-Private-Network", None
)
if req_pvt_header == "true":
req_hdlr.set_header("Access-Control-Allow-Private-Network", "true")


class AuthorizedRequestHandler(tornado.web.RequestHandler):
def initialize(self) -> None:
self.server: Server = self.settings['server']
self.auth_required: bool = True
self.cors_enabled = False

def set_default_headers(self) -> None:
origin: Optional[str] = self.request.headers.get("Origin")
# it is necessary to look up the parent app here,
# as initialize() may not yet be called
server: Server = self.settings['server']
auth: AuthComp = server.lookup_component('authorization', None)
self.cors_enabled = False
if auth is not None:
self.cors_enabled = auth.check_cors(origin, self)
if getattr(self, "cors_enabled", False):
_set_cors_headers(self)

def prepare(self) -> None:
async def prepare(self) -> None:
auth: AuthComp = self.server.lookup_component('authorization', None)
if auth is not None:
self.current_user = auth.authenticate_request(
self.current_user = await auth.authenticate_request(
self.request, self.auth_required
)
origin: Optional[str] = self.request.headers.get("Origin")
self.cors_enabled = await auth.check_cors(origin)
if self.cors_enabled:
_set_cors_headers(self)

def options(self, *args, **kwargs) -> None:
# Enable CORS if configured
Expand Down Expand Up @@ -520,23 +543,22 @@ def initialize(self,
) -> None:
super(AuthorizedFileHandler, self).initialize(path, default_filename)
self.server: Server = self.settings['server']
self.cors_enabled = False

def set_default_headers(self) -> None:
origin: Optional[str] = self.request.headers.get("Origin")
# it is necessary to look up the parent app here,
# as initialize() may not yet be called
server: Server = self.settings['server']
auth: AuthComp = server.lookup_component('authorization', None)
self.cors_enabled = False
if auth is not None:
self.cors_enabled = auth.check_cors(origin, self)
if getattr(self, "cors_enabled", False):
_set_cors_headers(self)

def prepare(self) -> None:
async def prepare(self) -> None:
auth: AuthComp = self.server.lookup_component('authorization', None)
if auth is not None:
self.current_user = auth.authenticate_request(
self.current_user = await auth.authenticate_request(
self.request, self._check_need_auth()
)
origin: Optional[str] = self.request.headers.get("Origin")
self.cors_enabled = await auth.check_cors(origin)
if self.cors_enabled:
_set_cors_headers(self)

def options(self, *args, **kwargs) -> None:
# Enable CORS if configured
Expand Down Expand Up @@ -915,8 +937,10 @@ def initialize(self,
self.parse_lock = Lock()
self.parse_failed: bool = False

def prepare(self) -> None:
super(FileUploadHandler, self).prepare()
async def prepare(self) -> None:
ret = super(FileUploadHandler, self).prepare()
if ret is not None:
await ret
content_type: str = self.request.headers.get("Content-Type", "")
logging.info(
f"Upload Request Received from {self.request.remote_ip}\n"
Expand Down Expand Up @@ -1017,8 +1041,10 @@ async def post(self) -> None:

# Default Handler for unregistered endpoints
class AuthorizedErrorHandler(AuthorizedRequestHandler):
def prepare(self) -> None:
super(AuthorizedRequestHandler, self).prepare()
async def prepare(self) -> None:
ret = super(AuthorizedRequestHandler, self).prepare()
if ret is not None:
await ret
self.set_status(404)
raise tornado.web.HTTPError(404)

Expand All @@ -1038,7 +1064,7 @@ def initialize(self) -> None:
super().initialize()
self.auth_required = False

def get(self, *args, **kwargs) -> None:
async def get(self, *args, **kwargs) -> None:
url: Optional[str] = self.get_argument('url', None)
if url is None:
try:
Expand All @@ -1052,7 +1078,7 @@ def get(self, *args, **kwargs) -> None:
assert url is not None
# validate the url origin
auth: AuthComp = self.server.lookup_component('authorization', None)
if auth is None or not auth.check_cors(url.rstrip("/")):
if auth is None or not await auth.check_cors(url.rstrip("/")):
raise tornado.web.HTTPError(
400, f"Unauthorized URL redirect: {url}")
self.redirect(url)
Expand All @@ -1066,7 +1092,7 @@ async def get(self) -> None:
auth: AuthComp = self.server.lookup_component("authorization", None)
if auth is not None:
try:
auth.authenticate_request(self.request)
await auth.authenticate_request(self.request)
except tornado.web.HTTPError:
authorized = False
else:
Expand Down
81 changes: 37 additions & 44 deletions moonraker/components/authorization.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,6 @@
from ..common import WebRequest
from .websockets import WebsocketManager
from tornado.httputil import HTTPServerRequest
from tornado.web import RequestHandler
from .database import MoonrakerDatabase as DBComp
from .ldap import MoonrakerLDAP
IPAddr = Union[ipaddress.IPv4Address, ipaddress.IPv6Address]
Expand All @@ -58,6 +57,7 @@ def base64url_decode(data: str) -> bytes:

ONESHOT_TIMEOUT = 5
TRUSTED_CONNECTION_TIMEOUT = 3600
FQDN_CACHE_TIMEOUT = 84000
PRUNE_CHECK_TIME = 300.

AUTH_SOURCES = ["moonraker", "ldap"]
Expand All @@ -80,6 +80,7 @@ def __init__(self, config: ConfigHelper) -> None:
self.enable_api_key = config.getboolean('enable_api_key', True)
self.max_logins = config.getint("max_login_attempts", None, above=0)
self.failed_logins: Dict[IPAddr, int] = {}
self.fqdn_cache: Dict[IPAddr, Dict[str, Any]] = {}
if self.default_source not in AUTH_SOURCES:
raise config.error(
"[authorization]: option 'default_source' - Invalid "
Expand Down Expand Up @@ -669,8 +670,13 @@ def _prune_conn_handler(self, eventtime: float) -> float:
exp_time: float = user_info['expires_at']
if cur_time >= exp_time:
self.trusted_users.pop(ip, None)
logging.info(
f"Trusted Connection Expired, IP: {ip}")
logging.info(f"Trusted Connection Expired, IP: {ip}")
for ip, fqdn_info in list(self.fqdn_cache.items()):
exp_time = fqdn_info["expires_at"]
if cur_time >= exp_time:
domain: str = fqdn_info["domain"]
self.fqdn_cache.pop(ip, None)
logging.info(f"Cached FQDN Expired, IP: {ip}, domain: {domain}")
return eventtime + PRUNE_CHECK_TIME

def _oneshot_token_expire_handler(self, token):
Expand Down Expand Up @@ -709,27 +715,42 @@ def _check_json_web_token(
raise HTTPError(401, "JWT Decode Error")
return None

def _check_authorized_ip(self, ip: IPAddr) -> bool:
async def _check_authorized_ip(self, ip: IPAddr) -> bool:
if ip in self.trusted_ips:
return True
for rng in self.trusted_ranges:
if ip in rng:
return True
fqdn = socket.getfqdn(str(ip)).lower()
if fqdn in self.trusted_domains:
return True
if self.trusted_domains:
if ip in self.fqdn_cache:
fqdn: str = self.fqdn_cache[ip]["domain"]
else:
eventloop = self.server.get_event_loop()
try:
fut = eventloop.run_in_thread(socket.getfqdn, str(ip))
fqdn = await asyncio.wait_for(fut, 5.0)
except asyncio.TimeoutError:
logging.info("Call to socket.getfqdn() timed out")
return False
else:
fqdn = fqdn.lower()
self.fqdn_cache[ip] = {
"expires_at": time.time() + FQDN_CACHE_TIMEOUT,
"domain": fqdn
}
return fqdn in self.trusted_domains
return False

def _check_trusted_connection(self,
ip: Optional[IPAddr]
) -> Optional[Dict[str, Any]]:
async def _check_trusted_connection(
self, ip: Optional[IPAddr]
) -> Optional[Dict[str, Any]]:
if ip is not None:
curtime = time.time()
exp_time = curtime + TRUSTED_CONNECTION_TIMEOUT
if ip in self.trusted_users:
self.trusted_users[ip]['expires_at'] = exp_time
return self.trusted_users[ip]
elif self._check_authorized_ip(ip):
elif await self._check_authorized_ip(ip):
logging.info(
f"Trusted Connection Detected, IP: {ip}")
self.trusted_users[ip] = {
Expand Down Expand Up @@ -761,7 +782,7 @@ def check_logins_maxed(self, ip_addr: IPAddr) -> bool:
return False
return self.failed_logins.get(ip_addr, 0) >= self.max_logins

def authenticate_request(
async def authenticate_request(
self, request: HTTPServerRequest, auth_required: bool = True
) -> Optional[Dict[str, Any]]:
if request.method == "OPTIONS":
Expand Down Expand Up @@ -801,16 +822,13 @@ def authenticate_request(

# Check if IP is trusted. If this endpoint doesn't require authentication
# then it is acceptable to return None
trusted_user = self._check_trusted_connection(ip)
trusted_user = await self._check_trusted_connection(ip)
if trusted_user is not None or not auth_required:
return trusted_user

raise HTTPError(401, "Unauthorized")

def check_cors(self,
origin: Optional[str],
req_hdlr: Optional[RequestHandler] = None
) -> bool:
async def check_cors(self, origin: Optional[str]) -> bool:
if origin is None or not self.cors_domains:
return False
for regex in self.cors_domains:
Expand All @@ -819,7 +837,6 @@ def check_cors(self,
if match.group() == origin:
logging.debug(f"CORS Pattern Matched, origin: {origin} "
f" | pattern: {regex}")
self._set_cors_headers(origin, req_hdlr)
return True
else:
logging.debug(f"Partial Cors Match: {match.group()}")
Expand All @@ -834,37 +851,13 @@ def check_cors(self,
except ValueError:
pass
else:
if self._check_authorized_ip(ipaddr):
logging.debug(
f"Cors request matched trusted IP: {ip}")
self._set_cors_headers(origin, req_hdlr)
if await self._check_authorized_ip(ipaddr):
logging.debug(f"Cors request matched trusted IP: {ip}")
return True
logging.debug(f"No CORS match for origin: {origin}\n"
f"Patterns: {self.cors_domains}")
return False

def _set_cors_headers(self,
origin: str,
req_hdlr: Optional[RequestHandler]
) -> None:
if req_hdlr is None:
return
req_hdlr.set_header("Access-Control-Allow-Origin", origin)
if req_hdlr.request.method == "OPTIONS":
req_hdlr.set_header(
"Access-Control-Allow-Methods",
"GET, POST, PUT, DELETE, OPTIONS")
req_hdlr.set_header(
"Access-Control-Allow-Headers",
"Origin, Accept, Content-Type, X-Requested-With, "
"X-CRSF-Token, Authorization, X-Access-Token, "
"X-Api-Key")
if req_hdlr.request.headers.get(
"Access-Control-Request-Private-Network", None) == "true":
req_hdlr.set_header(
"Access-Control-Allow-Private-Network",
"true")

def cors_enabled(self) -> bool:
return self.cors_domains is not None

Expand Down
Loading

0 comments on commit 623a585

Please sign in to comment.