From 87c1684a632fba4142b713da69dc93ca6cf3ef10 Mon Sep 17 00:00:00 2001 From: Arc Date: Thu, 29 Jan 2026 17:45:44 +0000 Subject: [PATCH] init --- lnbits/app.py | 2 + lnbits/middleware.py | 49 +++++++++++++++++++ lnbits/settings.py | 3 ++ lnbits/static/i18n/en.js | 12 +++++ .../components/admin/lnbits-admin-security.js | 38 +++++++++++++- lnbits/static/js/pages/admin.js | 25 +++++++++- .../templates/components/admin/security.vue | 46 +++++++++++++++++ 7 files changed, 172 insertions(+), 3 deletions(-) diff --git a/lnbits/app.py b/lnbits/app.py index a00d71577..c721b7943 100644 --- a/lnbits/app.py +++ b/lnbits/app.py @@ -66,6 +66,7 @@ from .middleware import ( add_first_install_middleware, add_ip_block_middleware, add_ratelimit_middleware, + add_route_access_middleware, ) from .tasks import internal_invoice_listener, invoice_listener, run_interval @@ -192,6 +193,7 @@ def create_app() -> FastAPI: # adds security middleware add_ip_block_middleware(app) + add_route_access_middleware(app) add_ratelimit_middleware(app) register_exception_handlers(app) diff --git a/lnbits/middleware.py b/lnbits/middleware.py index 8f6cf0eab..784421b17 100644 --- a/lnbits/middleware.py +++ b/lnbits/middleware.py @@ -1,5 +1,6 @@ import asyncio import json +import re from datetime import datetime, timezone from http import HTTPStatus from typing import Any @@ -18,6 +19,20 @@ from lnbits.core.models import AuditEntry from lnbits.helpers import normalize_path, template_renderer from lnbits.settings import settings +_LOCALHOST_IPS = {"127.0.0.1", "::1"} + + +def _route_pattern_matches(pattern: str, path: str) -> bool: + if not pattern: + return False + if not pattern.startswith("/"): + pattern = f"/{pattern}" + if pattern == path: + return True + escaped = re.escape(pattern) + escaped = re.sub(r"\\\{[^/]+\\\}", r"[^/]+", escaped) + return re.fullmatch(escaped, path) is not None + class InstalledExtensionMiddleware: # This middleware class intercepts calls made to the extensions API and: @@ -235,6 +250,40 @@ def add_ip_block_middleware(app: FastAPI): return await call_next(request) +def add_route_access_middleware(app: FastAPI): + @app.middleware("http") + async def route_access_middleware(request: Request, call_next): + if not settings.lnbits_route_access_control_enabled: + return await call_next(request) + if not request.client: + return JSONResponse( + status_code=HTTPStatus.FORBIDDEN, + content={"detail": "No request client"}, + ) + if request.client.host in _LOCALHOST_IPS: + return await call_next(request) + + path = request.url.path or "/" + whitelist = settings.lnbits_route_access_whitelist + blacklist = settings.lnbits_route_access_blacklist + + if whitelist: + if any(_route_pattern_matches(route, path) for route in whitelist): + return await call_next(request) + return JSONResponse( + status_code=HTTPStatus.FORBIDDEN, + content={"detail": "Route not whitelisted"}, + ) + + if blacklist and any(_route_pattern_matches(route, path) for route in blacklist): + return JSONResponse( + status_code=HTTPStatus.FORBIDDEN, + content={"detail": "Route is blacklisted"}, + ) + + return await call_next(request) + + def add_first_install_middleware(app: FastAPI): @app.middleware("http") async def first_install_middleware(request: Request, call_next): diff --git a/lnbits/settings.py b/lnbits/settings.py index 4e6cb58bb..0a54dd189 100644 --- a/lnbits/settings.py +++ b/lnbits/settings.py @@ -421,6 +421,9 @@ class SecuritySettings(LNbitsSettings): lnbits_rate_limit_unit: str = Field(default="minute") lnbits_allowed_ips: list[str] = Field(default=[]) lnbits_blocked_ips: list[str] = Field(default=[]) + lnbits_route_access_control_enabled: bool = Field(default=False) + lnbits_route_access_whitelist: list[str] = Field(default=[]) + lnbits_route_access_blacklist: list[str] = Field(default=[]) lnbits_callback_url_rules: list[str] = Field( default=["https?://([a-zA-Z0-9.-]+\\.[a-zA-Z]{2,})(:\\d+)?"] ) diff --git a/lnbits/static/i18n/en.js b/lnbits/static/i18n/en.js index 6d1725c35..bef98675c 100644 --- a/lnbits/static/i18n/en.js +++ b/lnbits/static/i18n/en.js @@ -363,6 +363,18 @@ window.localisation.en = { watchdog: 'Watchdog', server_logs: 'Server Logs', ip_blocker: 'IP Blocker', + route_access_control: 'Route Access Control', + route_access_control_enable: 'Enable route access control', + route_access_control_hint: + 'When enabled, non-local requests are restricted. Requests from 127.0.0.1 (and ::1) are always allowed.', + route_access_whitelist_label: 'Route Whitelist', + route_access_whitelist_hint: + 'Only allow non-local access to these routes (leave empty to disable whitelist mode).', + route_access_blacklist_label: 'Route Blacklist', + route_access_blacklist_hint: + 'Block non-local access to these routes (ignored when a whitelist is set).', + route_access_save_confirm: + 'Are you sure you want to save? This can impact access to LNbits if you are not accessing locally.', security: 'Security', security_tools: 'Security tools', block_access_hint: 'Block access by IP', diff --git a/lnbits/static/js/components/admin/lnbits-admin-security.js b/lnbits/static/js/components/admin/lnbits-admin-security.js index 67e598a61..779b09d1f 100644 --- a/lnbits/static/js/components/admin/lnbits-admin-security.js +++ b/lnbits/static/js/components/admin/lnbits-admin-security.js @@ -8,11 +8,45 @@ window.app.component('lnbits-admin-security', { serverlogEnabled: false, nostrAcceptedUrl: '', formAllowedIPs: '', - formCallbackUrlRule: '' + formCallbackUrlRule: '', + routeOptions: [], + routeOptionsFiltered: [], + routeOptionsLoading: false } }, - created() {}, + created() { + this.loadOpenApiRoutes() + }, methods: { + async loadOpenApiRoutes() { + this.routeOptionsLoading = true + try { + const response = await fetch('/openapi.json') + if (!response.ok) { + throw new Error('Failed to load OpenAPI spec') + } + const data = await response.json() + const paths = Object.keys(data.paths || {}).sort() + this.routeOptions = paths + this.routeOptionsFiltered = paths + } catch (error) { + console.warn(error) + } finally { + this.routeOptionsLoading = false + } + }, + filterRouteOptions(val, update) { + update(() => { + if (!val) { + this.routeOptionsFiltered = this.routeOptions + return + } + const needle = val.toLowerCase() + this.routeOptionsFiltered = this.routeOptions.filter(route => + route.toLowerCase().includes(needle) + ) + }) + }, addAllowedIPs() { const allowedIPs = this.formAllowedIPs.trim() const allowed_ips = this.formData.lnbits_allowed_ips diff --git a/lnbits/static/js/pages/admin.js b/lnbits/static/js/pages/admin.js index 233740883..29fa19645 100644 --- a/lnbits/static/js/pages/admin.js +++ b/lnbits/static/js/pages/admin.js @@ -8,7 +8,9 @@ window.PageAdmin = { lnbits_exchange_rate_providers: [], lnbits_audit_exclude_paths: [], lnbits_audit_include_paths: [], - lnbits_audit_http_response_codes: [] + lnbits_audit_http_response_codes: [], + lnbits_route_access_whitelist: [], + lnbits_route_access_blacklist: [] }, isSuperUser: false, needsRestart: false @@ -69,6 +71,27 @@ window.PageAdmin = { .catch(LNbits.utils.notifyApiError) }, updateSettings() { + if (this.shouldConfirmRouteAccess()) { + LNbits.utils + .confirmDialog( + this.$t('route_access_save_confirm') + ) + .onOk(() => this.persistSettings()) + return + } + this.persistSettings() + }, + shouldConfirmRouteAccess() { + const fields = [ + 'lnbits_route_access_control_enabled', + 'lnbits_route_access_whitelist', + 'lnbits_route_access_blacklist' + ] + return fields.some(field => + !_.isEqual(this.settings[field], this.formData[field]) + ) + }, + persistSettings() { const data = _.omit(this.formData, [ 'is_super_user', 'lnbits_allowed_funding_sources', diff --git a/lnbits/templates/components/admin/security.vue b/lnbits/templates/components/admin/security.vue index e738185b9..30fc30595 100644 --- a/lnbits/templates/components/admin/security.vue +++ b/lnbits/templates/components/admin/security.vue @@ -296,6 +296,52 @@ +
+

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