From 7e1f43933d32d337f15eae6a31b3da52812af972 Mon Sep 17 00:00:00 2001 From: Arc <33088785+arcbtc@users.noreply.github.com> Date: Tue, 20 Jun 2023 10:26:33 +0100 Subject: [PATCH] Adds security tools, such as a rate limiter, IP block/allow, server logs (#1606) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * added ratelimiter * Adds server logs to admin ui * Added IP allow/ban list * fixed remove ips * Split rate limit number and unit * security tab and background tasks for killswitch * fix test for auditor api --------- Co-authored-by: dni ⚡ --- .env.example | 6 + lnbits/app.py | 69 ++++- lnbits/core/crud.py | 5 + lnbits/core/models.py | 1 + lnbits/core/services.py | 20 +- lnbits/core/tasks.py | 86 +++++- lnbits/core/templates/admin/_tab_funding.html | 13 +- .../core/templates/admin/_tab_security.html | 264 ++++++++++++++++++ .../admin/_tab_security_notifications.html | 70 +++++ lnbits/core/templates/admin/index.html | 135 ++++++++- lnbits/core/views/admin_api.py | 30 +- lnbits/core/views/api.py | 23 +- lnbits/middleware.py | 32 +++ lnbits/settings.py | 17 ++ lnbits/static/i18n/en.js | 49 +++- lnbits/static/js/base.js | 9 + lnbits/templates/base.html | 1 + package-lock.json | 2 +- poetry.lock | 250 +++++++++++------ pyproject.toml | 1 + tests/core/views/test_api.py | 3 +- 21 files changed, 963 insertions(+), 123 deletions(-) create mode 100644 lnbits/core/templates/admin/_tab_security.html create mode 100644 lnbits/core/templates/admin/_tab_security_notifications.html diff --git a/.env.example b/.env.example index 1dd64035..7b6e852e 100644 --- a/.env.example +++ b/.env.example @@ -8,6 +8,12 @@ PORT=5000 DEBUG=false +# Server security, rate limiting ips, blocked ips, allowed ips +LNBITS_RATE_LIMIT_NO="200" +LNBITS_RATE_LIMIT_UNIT="minute" +LNBITS_ALLOWED_IPS="" +LNBITS_BLOCKED_IPS="" + # Allow users and admins by user IDs (comma separated list) LNBITS_ALLOWED_USERS="" LNBITS_ADMIN_USERS="" diff --git a/lnbits/app.py b/lnbits/app.py index d3dfa440..30431229 100644 --- a/lnbits/app.py +++ b/lnbits/app.py @@ -7,20 +7,28 @@ import shutil import signal import sys import traceback +from hashlib import sha256 from http import HTTPStatus from typing import Callable, List -from fastapi import FastAPI, Request -from fastapi.exceptions import HTTPException, RequestValidationError +from fastapi import FastAPI, HTTPException, Request +from fastapi.exceptions import RequestValidationError from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.gzip import GZipMiddleware -from fastapi.responses import JSONResponse from fastapi.staticfiles import StaticFiles from loguru import logger +from slowapi import Limiter +from slowapi.util import get_remote_address +from starlette.responses import JSONResponse from lnbits.core.crud import get_installed_extensions from lnbits.core.helpers import migrate_extension_database -from lnbits.core.tasks import register_task_listeners +from lnbits.core.services import websocketUpdater +from lnbits.core.tasks import ( # register_watchdog,; unregister_watchdog, + register_killswitch, + register_task_listeners, + unregister_killswitch, +) from lnbits.settings import get_wallet_class, set_wallet_class, settings from .commands import db_versions, load_disabled_extension_list, migrate_databases @@ -34,7 +42,12 @@ from .core.services import check_admin_settings from .core.views.generic import core_html_routes from .extension_manager import Extension, InstallableExtension, get_valid_extensions from .helpers import template_renderer -from .middleware import ExtensionsRedirectMiddleware, InstalledExtensionMiddleware +from .middleware import ( + ExtensionsRedirectMiddleware, + InstalledExtensionMiddleware, + add_ip_block_middleware, + add_ratelimit_middleware, +) from .requestvars import g from .tasks import ( catch_everything_and_restart, @@ -47,7 +60,6 @@ from .tasks import ( def create_app() -> FastAPI: configure_logger() - app = FastAPI( title="LNbits API", description="API for LNbits, the free and open source bitcoin wallet and accounts system with plugins.", @@ -85,6 +97,7 @@ def create_app() -> FastAPI: # Allow registering new extensions routes without direct access to the `app` object setattr(core_app_extra, "register_new_ext_routes", register_new_ext_routes(app)) + setattr(core_app_extra, "register_new_ratelimiter", register_new_ratelimiter(app)) return app @@ -245,6 +258,19 @@ def register_new_ext_routes(app: FastAPI) -> Callable: return register_new_ext_routes_fn +def register_new_ratelimiter(app: FastAPI) -> Callable: + def register_new_ratelimiter_fn(): + limiter = Limiter( + key_func=get_remote_address, + default_limits=[ + f"{settings.lnbits_rate_limit_no}/{settings.lnbits_rate_limit_unit}" + ], + ) + app.state.limiter = limiter + + return register_new_ratelimiter_fn + + def register_ext_routes(app: FastAPI, ext: Extension) -> None: """Register FastAPI routes for extension.""" ext_module = importlib.import_module(ext.module_name) @@ -287,6 +313,10 @@ def register_startup(app: FastAPI): log_server_info() + # adds security middleware + add_ratelimit_middleware(app) + add_ip_block_middleware(app) + # initialize WALLET set_wallet_class() @@ -296,6 +326,9 @@ def register_startup(app: FastAPI): # check extensions after restart await check_installed_extensions(app) + if settings.lnbits_admin_ui: + initialize_server_logger() + except Exception as e: logger.error(str(e)) raise ImportError("Failed to run 'startup' event.") @@ -308,6 +341,25 @@ def register_shutdown(app: FastAPI): await WALLET.cleanup() +def initialize_server_logger(): + + super_user_hash = sha256(settings.super_user.encode("utf-8")).hexdigest() + + serverlog_queue = asyncio.Queue() + + async def update_websocket_serverlog(): + while True: + msg = await serverlog_queue.get() + await websocketUpdater(super_user_hash, msg) + + asyncio.create_task(update_websocket_serverlog()) + + logger.add( + lambda msg: serverlog_queue.put_nowait(msg), + format=Formatter().format, + ) + + def log_server_info(): logger.info("Starting LNbits") logger.info(f"Version: {settings.version}") @@ -346,10 +398,14 @@ def register_async_tasks(app): loop.create_task(catch_everything_and_restart(invoice_listener)) loop.create_task(catch_everything_and_restart(internal_invoice_listener)) await register_task_listeners() + # await register_watchdog() + await register_killswitch() # await run_deferred_async() # calle: doesn't do anyting? @app.on_event("shutdown") async def stop_listeners(): + # await unregister_watchdog() + await unregister_killswitch() pass @@ -428,7 +484,6 @@ def configure_logger() -> None: log_level: str = "DEBUG" if settings.debug else "INFO" formatter = Formatter() logger.add(sys.stderr, level=log_level, format=formatter.format) - logging.getLogger("uvicorn").handlers = [InterceptHandler()] logging.getLogger("uvicorn.access").handlers = [InterceptHandler()] diff --git a/lnbits/core/crud.py b/lnbits/core/crud.py index b82fde8b..fd608004 100644 --- a/lnbits/core/crud.py +++ b/lnbits/core/crud.py @@ -305,6 +305,11 @@ async def get_total_balance(conn: Optional[Connection] = None): return 0 if row[0] is None else row[0] +async def get_active_wallet_total_balance(conn: Optional[Connection] = None): + row = await (conn or db).fetchone("SELECT SUM(balance) FROM balances") + return 0 if row[0] is None else row[0] + + # wallet payments # --------------- diff --git a/lnbits/core/models.py b/lnbits/core/models.py index dae4a1e0..569656f3 100644 --- a/lnbits/core/models.py +++ b/lnbits/core/models.py @@ -244,6 +244,7 @@ class BalanceCheck(BaseModel): class CoreAppExtra: register_new_ext_routes: Callable + register_new_ratelimiter: Callable class TinyURL(BaseModel): diff --git a/lnbits/core/services.py b/lnbits/core/services.py index e1ccd152..d444d254 100644 --- a/lnbits/core/services.py +++ b/lnbits/core/services.py @@ -21,6 +21,7 @@ from lnbits.settings import ( get_wallet_class, readonly_variables, send_admin_user_to_saas, + set_wallet_class, settings, ) from lnbits.wallets.base import PaymentResponse, PaymentStatus @@ -37,6 +38,7 @@ from .crud import ( get_account, get_standalone_payment, get_super_settings, + get_total_balance, get_wallet, get_wallet_payment, update_payment_details, @@ -511,7 +513,6 @@ class WebsocketConnectionManager: async def connect(self, websocket: WebSocket): await websocket.accept() - logger.debug(websocket) self.active_connections.append(websocket) def disconnect(self, websocket: WebSocket): @@ -528,3 +529,20 @@ websocketManager = WebsocketConnectionManager() async def websocketUpdater(item_id, data): return await websocketManager.send_data(f"{data}", item_id) + + +async def switch_to_voidwallet() -> None: + WALLET = get_wallet_class() + if WALLET.__class__.__name__ == "VoidWallet": + return + set_wallet_class("VoidWallet") + settings.lnbits_backend_wallet_class = "VoidWallet" + + +async def get_balance_delta() -> Tuple[int, int, int]: + WALLET = get_wallet_class() + total_balance = await get_total_balance() + error_message, node_balance = await WALLET.status() + if error_message: + raise Exception(error_message) + return node_balance - total_balance, node_balance, total_balance diff --git a/lnbits/core/tasks.py b/lnbits/core/tasks.py index 7ff407d9..4b397b10 100644 --- a/lnbits/core/tasks.py +++ b/lnbits/core/tasks.py @@ -1,20 +1,102 @@ import asyncio -from typing import Dict +from typing import Dict, Optional import httpx from loguru import logger +from lnbits.settings import get_wallet_class, settings from lnbits.tasks import SseListenersDict, register_invoice_listener from . import db from .crud import get_balance_notify, get_wallet from .models import Payment -from .services import websocketUpdater +from .services import get_balance_delta, switch_to_voidwallet, websocketUpdater api_invoice_listeners: Dict[str, asyncio.Queue] = SseListenersDict( "api_invoice_listeners" ) +killswitch: Optional[asyncio.Task] = None +watchdog: Optional[asyncio.Task] = None + + +async def register_killswitch(): + """ + Registers a killswitch which will check lnbits-status repository + for a signal from LNbits and will switch to VoidWallet if the killswitch is triggered. + """ + logger.debug("Starting killswitch task") + global killswitch + killswitch = asyncio.create_task(killswitch_task()) + + +async def unregister_killswitch(): + """ + Unregisters a killswitch taskl + """ + global killswitch + if killswitch: + logger.debug("Stopping killswitch task") + killswitch.cancel() + + +async def killswitch_task(): + while True: + WALLET = get_wallet_class() + if settings.lnbits_killswitch and WALLET.__class__.__name__ != "VoidWallet": + with httpx.Client() as client: + try: + r = client.get(settings.lnbits_status_manifest, timeout=4) + r.raise_for_status() + if r.status_code == 200: + ks = r.json().get("killswitch") + if ks and ks == 1: + logger.error( + "Switching to VoidWallet. Killswitch triggered." + ) + await switch_to_voidwallet() + except (httpx.ConnectError, httpx.RequestError): + logger.error( + f"Cannot fetch lnbits status manifest. {settings.lnbits_status_manifest}" + ) + await asyncio.sleep(settings.lnbits_killswitch_interval * 60) + + +async def register_watchdog(): + """ + Registers a watchdog which will check lnbits balance and nodebalance + and will switch to VoidWallet if the watchdog delta is reached. + """ + # TODO: implement watchdog porperly + # logger.debug("Starting watchdog task") + # global watchdog + # watchdog = asyncio.create_task(watchdog_task()) + + +async def unregister_watchdog(): + """ + Unregisters a watchdog task + """ + global watchdog + if watchdog: + logger.debug("Stopping watchdog task") + watchdog.cancel() + + +async def watchdog_task(): + while True: + WALLET = get_wallet_class() + if settings.lnbits_watchdog and WALLET.__class__.__name__ != "VoidWallet": + try: + delta, *_ = await get_balance_delta() + logger.debug(f"Running watchdog task. current delta: {delta}") + if delta + settings.lnbits_watchdog_delta <= 0: + logger.error(f"Switching to VoidWallet. current delta: {delta}") + await switch_to_voidwallet() + except Exception as e: + logger.error("Error in watchdog task", e) + await asyncio.sleep(settings.lnbits_watchdog_interval * 60) + async def register_task_listeners(): """ diff --git a/lnbits/core/templates/admin/_tab_funding.html b/lnbits/core/templates/admin/_tab_funding.html index 9fe3e831..6ceefc82 100644 --- a/lnbits/core/templates/admin/_tab_funding.html +++ b/lnbits/core/templates/admin/_tab_funding.html @@ -9,7 +9,18 @@
diff --git a/lnbits/core/templates/admin/_tab_security.html b/lnbits/core/templates/admin/_tab_security.html new file mode 100644 index 00000000..0b62f1ae --- /dev/null +++ b/lnbits/core/templates/admin/_tab_security.html @@ -0,0 +1,264 @@ + + +
+
+
+
+
+
+
+ + {% raw %}{{ log }}{% endraw %}
+
+
+
+ +
+
+
+
+

+
+
+ + + +
+ {%raw%} + + {{ blocked_ip }} + + {%endraw%} +
+
+
+
+ + + +
+ {%raw%} + + {{ allowed_ip }} + + {%endraw%} +
+
+
+
+
+
+

+
+
+ +
+
+ +
+
+
+
+
+
+
+
+
+ + + + + + + + + +
+

+
+ {% include "admin/_tab_security_notifications.html" %} +
+
+
+

+ +
+
+
+
+

+ + + + + + + + + + + + + + + + + + +
+

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+
+
diff --git a/lnbits/core/templates/admin/_tab_security_notifications.html b/lnbits/core/templates/admin/_tab_security_notifications.html new file mode 100644 index 00000000..8f59133c --- /dev/null +++ b/lnbits/core/templates/admin/_tab_security_notifications.html @@ -0,0 +1,70 @@ +{% raw %} + + + + + + + + + + + + + + + + + + +{% endraw %} diff --git a/lnbits/core/templates/admin/index.html b/lnbits/core/templates/admin/index.html index 1d0e3439..ab6000e0 100644 --- a/lnbits/core/templates/admin/index.html +++ b/lnbits/core/templates/admin/index.html @@ -88,6 +88,12 @@ @update="val => tab = val.name" > + + {% include "admin/_tab_funding.html" %} {% include "admin/_tab_users.html" %} {% include "admin/_tab_server.html" %} {% - include "admin/_tab_theme.html" %} + include "admin/_tab_security.html" %} {% include + "admin/_tab_theme.html" %} @@ -164,6 +171,8 @@ data: function () { return { settings: {}, + logs: [], + serverlogEnabled: false, lnbits_theme_options: [ 'classic', 'bitcoin', @@ -175,10 +184,30 @@ 'monochrome', 'salvador' ], + auditData: {}, + statusData: {}, + statusDataTable: { + columns: [ + { + name: 'date', + align: 'left', + label: this.$t('date'), + field: 'date' + }, + { + name: 'message', + align: 'left', + label: this.$t('memo'), + field: 'message' + } + ] + }, formData: {}, formAddAdmin: '', formAddUser: '', formAddExtensionsManifest: '', + formAllowedIPs: '', + formBlockedIPs: '', isSuperUser: false, wallet: {}, cancel: {}, @@ -349,11 +378,18 @@ }, created: function () { this.getSettings() + this.getAudit() this.balance = +'{{ balance|safe }}' }, computed: { + lnbitsVersion() { + return LNBITS_VERSION + }, checkChanges() { return !_.isEqual(this.settings, this.formData) + }, + updateAvailable() { + return LNBITS_VERSION !== this.statusData.version } }, methods: { @@ -364,7 +400,6 @@ //admin_users = [...admin_users, addUser] this.formData.lnbits_admin_users = [...admin_users, addUser] this.formAddAdmin = '' - //console.log(this.checkChanges) } }, removeAdminUser(user) { @@ -406,6 +441,68 @@ m => m !== manifest ) }, + async toggleServerLog() { + this.serverlogEnabled = !this.serverlogEnabled + if (this.serverlogEnabled) { + const wsProto = location.protocol !== 'http:' ? 'wss://' : 'ws://' + const digestHex = await LNbits.utils.digestMessage(this.g.user.id) + const localUrl = + wsProto + + document.domain + + ':' + + location.port + + '/api/v1/ws/' + + digestHex + this.ws = new WebSocket(localUrl) + this.ws.addEventListener('message', async ({data}) => { + this.logs.push(data.toString()) + const scrollArea = this.$refs.logScroll + if (scrollArea) { + const scrollTarget = scrollArea.getScrollTarget() + const duration = 0 + scrollArea.setScrollPosition(scrollTarget.scrollHeight, duration) + } + }) + } else { + this.ws.close() + } + }, + addAllowedIPs() { + const allowedIPs = this.formAllowedIPs.trim() + const allowed_ips = this.formData.lnbits_allowed_ips + if ( + allowedIPs && + allowedIPs.length && + !allowed_ips.includes(allowedIPs) + ) { + this.formData.lnbits_allowed_ips = [...allowed_ips, allowedIPs] + this.formAllowedIPs = '' + } + }, + removeAllowedIPs(allowed_ip) { + const allowed_ips = this.formData.lnbits_allowed_ips + this.formData.lnbits_allowed_ips = allowed_ips.filter( + a => a !== allowed_ip + ) + }, + addBlockedIPs() { + const blockedIPs = this.formBlockedIPs.trim() + const blocked_ips = this.formData.lnbits_blocked_ips + if ( + blockedIPs && + blockedIPs.length && + !blocked_ips.includes(blockedIPs) + ) { + this.formData.lnbits_blocked_ips = [...blocked_ips, blockedIPs] + this.formBlockedIPs = '' + } + }, + removeBlockedIPs(blocked_ip) { + const blocked_ips = this.formData.lnbits_blocked_ips + this.formData.lnbits_blocked_ips = blocked_ips.filter( + b => b !== blocked_ip + ) + }, restartServer() { LNbits.api .request('GET', '/admin/api/v1/restart/?usr=' + this.g.user.id) @@ -449,12 +546,43 @@ this.settings.lnbits_allowed_funding_sources.map(f => { let opts = this.funding_sources.get(f) if (!opts) return - Object.keys(opts).forEach(e => { opts[e].value = this.settings[e] }) }) }, + formatDate(date) { + return moment(date * 1000).fromNow() + }, + getNotifications() { + if (this.settings.lnbits_notifications) { + axios + .get(this.settings.lnbits_status_manifest) + .then(response => { + this.statusData = response.data + }) + .catch(error => { + this.formData.lnbits_notifications = false + error.response.data = {} + error.response.data.message = 'Could not fetch status manifest.' + LNbits.utils.notifyApiError(error) + }) + } + }, + getAudit() { + LNbits.api + .request( + 'GET', + '/admin/api/v1/audit/?usr=' + this.g.user.id, + this.g.user.wallets[0].adminkey + ) + .then(response => { + this.auditData = response.data + }) + .catch(function (error) { + LNbits.utils.notifyApiError(error) + }) + }, getSettings() { LNbits.api .request( @@ -467,6 +595,7 @@ this.settings = response.data this.formData = _.clone(this.settings) this.updateFundingData() + this.getNotifications() }) .catch(function (error) { LNbits.utils.notifyApiError(error) diff --git a/lnbits/core/views/admin_api.py b/lnbits/core/views/admin_api.py index b73c549c..0f63800a 100644 --- a/lnbits/core/views/admin_api.py +++ b/lnbits/core/views/admin_api.py @@ -12,15 +12,35 @@ from starlette.exceptions import HTTPException from lnbits.core.crud import get_wallet from lnbits.core.models import User -from lnbits.core.services import update_cached_settings, update_wallet_balance +from lnbits.core.services import ( + get_balance_delta, + update_cached_settings, + update_wallet_balance, +) from lnbits.decorators import check_admin, check_super_user from lnbits.server import server_restart from lnbits.settings import AdminSettings, EditableSettings, settings -from .. import core_app +from .. import core_app, core_app_extra from ..crud import delete_admin_settings, get_admin_settings, update_admin_settings +@core_app.get("/admin/api/v1/audit", dependencies=[Depends(check_admin)]) +async def api_auditor(): + try: + delta, node_balance, total_balance = await get_balance_delta() + return { + "delta_msats": int(delta), + "node_balance_msats": int(node_balance), + "lnbits_balance_msats": int(total_balance), + } + except: + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + detail="Could not audit balance.", + ) + + @core_app.get("/admin/api/v1/settings/") async def api_get_settings( user: User = Depends(check_admin), @@ -40,6 +60,7 @@ async def api_update_settings( admin_settings = await get_admin_settings(user.super_user) assert admin_settings, "Updated admin settings not found." update_cached_settings(admin_settings.dict()) + core_app_extra.register_new_ratelimiter() return {"status": "Success"} @@ -78,6 +99,11 @@ async def api_topup_balance( status_code=HTTPStatus.FORBIDDEN, detail="wallet does not exist." ) + if settings.lnbits_backend_wallet_class == "VoidWallet": + raise HTTPException( + status_code=HTTPStatus.FORBIDDEN, detail="VoidWallet active" + ) + await update_wallet_balance(wallet_id=id, amount=int(amount)) return {"status": "Success"} diff --git a/lnbits/core/views/api.py b/lnbits/core/views/api.py index 5b1989b4..c6bea824 100644 --- a/lnbits/core/views/api.py +++ b/lnbits/core/views/api.py @@ -1,7 +1,6 @@ import asyncio import hashlib import json -import time import uuid from http import HTTPStatus from io import BytesIO @@ -52,7 +51,7 @@ from lnbits.extension_manager import ( get_valid_extensions, ) from lnbits.helpers import generate_filter_params_openapi, url_for -from lnbits.settings import get_wallet_class, settings +from lnbits.settings import settings from lnbits.utils.exchange_rates import ( currencies, fiat_amount_as_satoshis, @@ -73,7 +72,6 @@ from ..crud import ( get_standalone_payment, get_tinyurl, get_tinyurl_by_url, - get_total_balance, get_wallet_for_key, save_balance_check, update_wallet, @@ -726,25 +724,6 @@ async def img(request: Request, data): ) -@core_app.get("/api/v1/audit", dependencies=[Depends(check_admin)]) -async def api_auditor(): - WALLET = get_wallet_class() - total_balance = await get_total_balance() - error_message, node_balance = await WALLET.status() - - if not error_message: - delta = node_balance - total_balance - else: - node_balance, delta = 0, 0 - - return { - "node_balance_msats": int(node_balance), - "lnbits_balance_msats": int(total_balance), - "delta_msats": int(delta), - "timestamp": int(time.time()), - } - - # UNIVERSAL WEBSOCKET MANAGER diff --git a/lnbits/middleware.py b/lnbits/middleware.py index 2815ddde..5911adb7 100644 --- a/lnbits/middleware.py +++ b/lnbits/middleware.py @@ -2,9 +2,14 @@ from http import HTTPStatus from typing import Any, List, Tuple, Union from urllib.parse import parse_qs +from fastapi import FastAPI, Request from fastapi.responses import HTMLResponse, JSONResponse +from slowapi import _rate_limit_exceeded_handler +from slowapi.errors import RateLimitExceeded +from slowapi.middleware import SlowAPIMiddleware from starlette.types import ASGIApp, Receive, Scope, Send +from lnbits.core import core_app_extra from lnbits.helpers import template_renderer from lnbits.settings import settings @@ -189,3 +194,30 @@ class ExtensionsRedirectMiddleware: ] return "/" + "/".join(elements) + + +def add_ratelimit_middleware(app: FastAPI): + core_app_extra.register_new_ratelimiter() + app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler) + app.add_middleware(SlowAPIMiddleware) + + +def add_ip_block_middleware(app: FastAPI): + @app.middleware("http") + async def block_allow_ip_middleware(request: Request, call_next): + response = await call_next(request) + if not request.client: + return JSONResponse( + status_code=429, + content={"detail": "No request client"}, + ) + if request.client.host in settings.lnbits_allowed_ips: + return response + if request.client.host in settings.lnbits_blocked_ips: + return JSONResponse( + status_code=429, + content={"detail": "IP is blocked"}, + ) + return response + + app.middleware("http")(block_allow_ip_middleware) diff --git a/lnbits/settings.py b/lnbits/settings.py index adec5951..be09b6f9 100644 --- a/lnbits/settings.py +++ b/lnbits/settings.py @@ -101,6 +101,22 @@ class OpsSettings(LNbitsSettings): lnbits_denomination: str = Field(default="sats") +class SecuritySettings(LNbitsSettings): + lnbits_rate_limit_no: str = Field(default="200") + lnbits_rate_limit_unit: str = Field(default="minute") + lnbits_allowed_ips: List[str] = Field(default=[]) + lnbits_blocked_ips: List[str] = Field(default=[]) + lnbits_notifications: bool = Field(default=False) + lnbits_killswitch: bool = Field(default=False) + lnbits_killswitch_interval: int = Field(default=60) + lnbits_watchdog: bool = Field(default=False) + lnbits_watchdog_interval: int = Field(default=60) + lnbits_watchdog_delta: int = Field(default=1_000_000) + lnbits_status_manifest: str = Field( + default="https://raw.githubusercontent.com/lnbits/lnbits-status/main/manifest.json" + ) + + class FakeWalletFundingSource(LNbitsSettings): fake_wallet_secret: str = Field(default="ToTheMoon1") @@ -207,6 +223,7 @@ class EditableSettings( ExtensionsSettings, ThemesSettings, OpsSettings, + SecuritySettings, FundingSourcesSettings, BoltzExtensionSettings, LightningSettings, diff --git a/lnbits/static/i18n/en.js b/lnbits/static/i18n/en.js index ed53831f..93b639dd 100644 --- a/lnbits/static/i18n/en.js +++ b/lnbits/static/i18n/en.js @@ -122,5 +122,52 @@ window.localisation.en = { description: 'Description', expiry: 'Expiry', webhook: 'Webhook', - payment_proof: 'Payment Proof' + payment_proof: 'Payment Proof', + update_available: 'Update %{version} available!', + latest_update: 'You are on the latest version %{version}.', + notifications: 'Notifications', + no_notifications: 'No notifications', + notifications_disabled: 'LNbits status notifications are disabled.', + enable_notifications: 'Enable Notifications', + enable_notifications_desc: + 'If enabled it will fetch the latest LNbits Status updates, like security incidents and updates.', + enable_killswitch: 'Enable Killswitch', + enable_killswitch_desc: + 'If enabled it will change your funding source to VoidWallet automatically if LNbits sends out a killswitch signal. You will need to enable manually after an update.', + killswitch_interval: 'Killswitch Interval', + killswitch_interval_desc: + 'How often the background task should check for the LNBits killswitch signal from the status source (in minutes).', + enable_watchdog: 'Enable Watchdog', + enable_watchdog_desc: + 'If enabled it will change your funding source to VoidWallet automatically if your balance is lower than the LNbits balance. You will need to enable manually after an update.', + watchdog_interval: 'Watchdog Interval', + watchdog_interval_desc: + 'How often the background task should check for a killswitch signal in the watchdog delta [node_balance - lnbits_balance] (in minutes).', + watchdog_delta: 'Watchdog Delta', + watchdog_delta_desc: + 'Limit before killswitch changes funding source to VoidWallet [lnbits_balance - node_balance > delta]', + status: 'Status', + notification_source: 'Notification Source', + notification_source_label: + 'Source URL (only use the official LNbits status source, and sources you can trust)', + more: 'more', + releases: 'Releases', + killswitch: 'Killswitch', + watchdog: 'Watchdog', + server_logs: 'Server Logs', + ip_blocker: 'IP Blocker', + security: 'Security', + security_tools: 'Security tools', + block_access_hint: 'Block access by IP', + allow_access_hint: 'Allow access by IP (will override blocked IPs)', + enter_ip: 'Enter IP and hit enter', + rate_limiter: 'Rate Limiter', + number_of_requests: 'Number of requests', + time_unit: 'Time unit', + minute: 'minute', + second: 'second', + hour: 'hour', + disable_server_log: 'Disable Server Log', + enable_server_log: 'Enable Server Log', + coming_soon: 'Feature coming soon' } diff --git a/lnbits/static/js/base.js b/lnbits/static/js/base.js index f5ff3350..e8b500ac 100644 --- a/lnbits/static/js/base.js +++ b/lnbits/static/js/base.js @@ -241,6 +241,15 @@ window.LNbits = { } }) }, + digestMessage: async function (message) { + const msgUint8 = new TextEncoder().encode(message) + const hashBuffer = await crypto.subtle.digest('SHA-256', msgUint8) + const hashArray = Array.from(new Uint8Array(hashBuffer)) + const hashHex = hashArray + .map(b => b.toString(16).padStart(2, '0')) + .join('') + return hashHex + }, formatCurrency: function (value, currency) { return new Intl.NumberFormat(window.LOCALE, { style: 'currency', diff --git a/lnbits/templates/base.html b/lnbits/templates/base.html index 4874dc3f..e5dc93d8 100644 --- a/lnbits/templates/base.html +++ b/lnbits/templates/base.html @@ -295,6 +295,7 @@