From 0a74ca3972749f8048ab944d3e153a9ee1237983 Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Wed, 5 Apr 2023 17:39:22 +0300 Subject: [PATCH 1/6] fix: use extension `code` instead on `name` --- lnbits/core/templates/admin/_tab_server.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lnbits/core/templates/admin/_tab_server.html b/lnbits/core/templates/admin/_tab_server.html index 814a490f4..f4d61bbf6 100644 --- a/lnbits/core/templates/admin/_tab_server.html +++ b/lnbits/core/templates/admin/_tab_server.html @@ -63,7 +63,7 @@ multiple hint="Extensions only user with admin privileges can use" label="Admin extensions" - :options="g.extensions.map(e => e.name)" + :options="g.extensions.map(e => e.code)" >
From af212c820c15fbb30efcdc2a88df964888e0f606 Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Wed, 5 Apr 2023 17:40:08 +0300 Subject: [PATCH 2/6] fix: block access admin extensions to normal users --- lnbits/middleware.py | 32 +++++++++++++++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/lnbits/middleware.py b/lnbits/middleware.py index daac03bf4..17da5ecf6 100644 --- a/lnbits/middleware.py +++ b/lnbits/middleware.py @@ -1,9 +1,11 @@ from http import HTTPStatus from typing import List, Tuple +from urllib.parse import parse_qs -from fastapi.responses import JSONResponse +from fastapi.responses import HTMLResponse, JSONResponse from starlette.types import ASGIApp, Receive, Scope, Send +from lnbits.helpers import template_renderer from lnbits.settings import settings @@ -36,6 +38,18 @@ class InstalledExtensionMiddleware: await response(scope, receive, send) return + if not self._user_allowed_to_extension(path_name, scope): + response = HTMLResponse( + status_code=HTTPStatus.FORBIDDEN, + content=template_renderer() + .TemplateResponse( + "error.html", {"request": {}, "err": "User not authorized."} + ) + .body, + ) + await response(scope, receive, send) + return + # re-route API trafic if the extension has been upgraded if path_type == "api": upgraded_extensions = list( @@ -51,6 +65,22 @@ class InstalledExtensionMiddleware: await self.app(scope, receive, send) + def _user_allowed_to_extension(self, ext_name: str, scope) -> bool: + if ext_name not in settings.lnbits_admin_extensions: + return True + if "query_string" not in scope: + return True + + q = parse_qs(scope["query_string"].decode("UTF-8")) + user = q.get("usr", [None])[0] + if not user: + return True + + if user == settings.super_user or user in settings.lnbits_admin_users: + return True + + return False + class ExtensionsRedirectMiddleware: # Extensions are allowed to specify redirect paths. From 1d0fcaa5790ccc86934c423150f86e39cb112375 Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Wed, 5 Apr 2023 18:54:54 +0300 Subject: [PATCH 3/6] feat: show content by accepted type --- lnbits/middleware.py | 43 ++++++++++++++++++++++++++++++++----------- 1 file changed, 32 insertions(+), 11 deletions(-) diff --git a/lnbits/middleware.py b/lnbits/middleware.py index 17da5ecf6..dc4a8875e 100644 --- a/lnbits/middleware.py +++ b/lnbits/middleware.py @@ -1,5 +1,5 @@ from http import HTTPStatus -from typing import List, Tuple +from typing import Any, List, Tuple, Union from urllib.parse import parse_qs from fastapi.responses import HTMLResponse, JSONResponse @@ -29,23 +29,19 @@ class InstalledExtensionMiddleware: _, path_name = path_elements path_type = None + headers = scope.get("headers", []) + # block path for all users if the extension is disabled if path_name in settings.lnbits_deactivated_extensions: - response = JSONResponse( - status_code=HTTPStatus.NOT_FOUND, - content={"detail": f"Extension '{path_name}' disabled"}, + response = self._response_by_accepted_type( + headers, f"Extension '{path_name}' disabled", HTTPStatus.NOT_FOUND ) await response(scope, receive, send) return if not self._user_allowed_to_extension(path_name, scope): - response = HTMLResponse( - status_code=HTTPStatus.FORBIDDEN, - content=template_renderer() - .TemplateResponse( - "error.html", {"request": {}, "err": "User not authorized."} - ) - .body, + response = self._response_by_accepted_type( + headers, "User not authorized.", HTTPStatus.FORBIDDEN ) await response(scope, receive, send) return @@ -81,6 +77,31 @@ class InstalledExtensionMiddleware: return False + def _response_by_accepted_type( + self, headers: List[Any], msg: str, status_code: HTTPStatus + ) -> Union[HTMLResponse, JSONResponse]: + accept_header: str = next( + ( + h[1].decode("UTF-8") + for h in headers + if len(h) >= 2 and h[0].decode("UTF-8") == "accept" + ), + "", + ) + + if "text/html" in [a for a in accept_header.split(",")]: + return HTMLResponse( + status_code=status_code, + content=template_renderer() + .TemplateResponse("error.html", {"request": {}, "err": msg}) + .body, + ) + + return JSONResponse( + status_code=status_code, + content={"detail": msg}, + ) + class ExtensionsRedirectMiddleware: # Extensions are allowed to specify redirect paths. From d7e7d89e9aa54e7a6e0c2410d396cd083c95e6ea Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Wed, 5 Apr 2023 19:03:14 +0300 Subject: [PATCH 4/6] fix: mypy --- lnbits/middleware.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lnbits/middleware.py b/lnbits/middleware.py index dc4a8875e..99f637e61 100644 --- a/lnbits/middleware.py +++ b/lnbits/middleware.py @@ -68,7 +68,7 @@ class InstalledExtensionMiddleware: return True q = parse_qs(scope["query_string"].decode("UTF-8")) - user = q.get("usr", [None])[0] + user = q.get("usr", [""])[0] if not user: return True From 0d14d2b56ecfbb301606cc9c161264bd0f6a5e9b Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Wed, 5 Apr 2023 19:40:16 +0300 Subject: [PATCH 5/6] fix: remove admin extensions for non admin users --- lnbits/core/crud.py | 4 +++- lnbits/core/models.py | 12 +++++++++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/lnbits/core/crud.py b/lnbits/core/crud.py index 1b807b788..5dfc73d00 100644 --- a/lnbits/core/crud.py +++ b/lnbits/core/crud.py @@ -62,7 +62,9 @@ async def get_user(user_id: str, conn: Optional[Connection] = None) -> Optional[ return User( id=user["id"], email=user["email"], - extensions=[e[0] for e in extensions], + extensions=[ + e[0] for e in extensions if User.is_extension_for_user(e[0], user["id"]) + ], wallets=[Wallet(**w) for w in wallets], admin=user["id"] == settings.super_user or user["id"] in settings.lnbits_admin_users, diff --git a/lnbits/core/models.py b/lnbits/core/models.py index c3ff6fd9c..4bcdd3311 100644 --- a/lnbits/core/models.py +++ b/lnbits/core/models.py @@ -13,7 +13,7 @@ from pydantic import BaseModel from lnbits.db import Connection from lnbits.helpers import url_for -from lnbits.settings import get_wallet_class +from lnbits.settings import get_wallet_class, settings from lnbits.wallets.base import PaymentStatus @@ -75,6 +75,16 @@ class User(BaseModel): w = [wallet for wallet in self.wallets if wallet.id == wallet_id] return w[0] if w else None + @classmethod + def is_extension_for_user(cls, ext: str, user: str) -> bool: + if ext not in settings.lnbits_admin_extensions: + return True + if user == settings.super_user: + return True + if user in settings.lnbits_admin_users: + return True + return False + class Payment(BaseModel): checking_id: str From 554ad88cbd4b28aaef86cf72aca282e80e9fada2 Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Thu, 6 Apr 2023 12:32:23 +0300 Subject: [PATCH 6/6] doc: add comments --- lnbits/middleware.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/lnbits/middleware.py b/lnbits/middleware.py index 99f637e61..b59225df4 100644 --- a/lnbits/middleware.py +++ b/lnbits/middleware.py @@ -67,6 +67,7 @@ class InstalledExtensionMiddleware: if "query_string" not in scope: return True + # parse the URL query string into a `dict` q = parse_qs(scope["query_string"].decode("UTF-8")) user = q.get("usr", [""])[0] if not user: @@ -80,6 +81,11 @@ class InstalledExtensionMiddleware: def _response_by_accepted_type( self, headers: List[Any], msg: str, status_code: HTTPStatus ) -> Union[HTMLResponse, JSONResponse]: + """ + Build an HTTP response containing the `msg` as HTTP body and the `status_code` as HTTP code. + If the `accept` HTTP header is present int the request and contains the value of `text/html` + then return an `HTMLResponse`, otherwise return an `JSONResponse`. + """ accept_header: str = next( ( h[1].decode("UTF-8")