Compare commits

...
Author SHA1 Message Date
Arc 962a09bb30 fundle 2026-01-29 18:21:05 +00:00
Arc 92c86eb837 make 2026-01-29 18:17:07 +00:00
Arc dadff18a9c feat: route whitelist/blacklist
security feature middleware to block/allow routes
2026-01-29 18:11:28 +00:00
Arc 87c1684a63 init 2026-01-29 17:45:44 +00:00
9 changed files with 224 additions and 5 deletions
+2
View File
@@ -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)
+84
View File
@@ -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,53 @@ 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"}
_PUBLIC_ASSET_PATHS = {
"/favicon.ico",
"/service-worker.js",
}
def _normalize_match_path(path: str) -> str:
if not path:
return "/"
if path != "/" and path.endswith("/"):
return path.rstrip("/")
return path
def _route_pattern_matches(pattern: str, path: str) -> bool:
if not pattern:
return False
if not pattern.startswith("/"):
pattern = f"/{pattern}"
pattern = _normalize_match_path(pattern)
path = _normalize_match_path(path)
if pattern.endswith("*"):
prefix = pattern.rstrip("*")
return path.startswith(prefix)
if pattern == path:
return True
escaped = re.escape(pattern)
escaped = re.sub(r"\\\{[^/]+\\\}", r"[^/]+", escaped)
return re.fullmatch(escaped, path) is not None
def _response_by_accepted_type(request: Request, msg: str, status_code: HTTPStatus):
accept_header = request.headers.get("accept", "")
if "text/html" in accept_header.split(","):
return HTMLResponse(
status_code=status_code,
content=template_renderer()
.TemplateResponse(
request,
"error.html",
{"err": msg, "status_code": status_code, "message": msg},
)
.body,
)
return JSONResponse(status_code=status_code, content={"detail": msg})
class InstalledExtensionMiddleware:
# This middleware class intercepts calls made to the extensions API and:
@@ -235,6 +283,42 @@ 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 "/"
if "/static/" in path or path in _PUBLIC_ASSET_PATHS:
return await call_next(request)
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 _response_by_accepted_type(
request, f"Route not whitelisted: {path}", HTTPStatus.FORBIDDEN
)
if blacklist and any(
_route_pattern_matches(route, path) for route in blacklist
):
return _response_by_accepted_type(
request, f"Route is blacklisted: {path}", HTTPStatus.FORBIDDEN
)
return await call_next(request)
def add_first_install_middleware(app: FastAPI):
@app.middleware("http")
async def first_install_middleware(request: Request, call_next):
+3
View File
@@ -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+)?"]
)
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+12
View File
@@ -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',
@@ -8,11 +8,58 @@ 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
}
},
addRouteOption(value, done) {
const route = value.trim()
if (!route) {
done()
return
}
if (!this.routeOptions.includes(route)) {
this.routeOptions.push(route)
this.routeOptions.sort()
}
this.routeOptionsFiltered = this.routeOptions
done(route)
},
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
+22 -1
View File
@@ -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,25 @@ 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',
@@ -296,6 +296,56 @@
</div>
</div>
<div class="col-12 col-md-12">
<p v-text="$t('route_access_control')"></p>
<div class="row q-col-gutter-md">
<div class="col-12">
<q-toggle
v-model="formData.lnbits_route_access_control_enabled"
:label="$t('route_access_control_enable')"
></q-toggle>
<div
class="text-caption text-grey-6 q-mt-xs"
v-text="$t('route_access_control_hint')"
></div>
</div>
<div class="col-12 col-md-6">
<q-select
filled
multiple
use-chips
use-input
input-debounce="0"
new-value-mode="add-unique"
@new-value="addRouteOption"
:options="routeOptionsFiltered"
:loading="routeOptionsLoading"
@filter="filterRouteOptions"
v-model="formData.lnbits_route_access_whitelist"
:label="$t('route_access_whitelist_label')"
:hint="$t('route_access_whitelist_hint')"
></q-select>
</div>
<div class="col-12 col-md-6">
<q-select
filled
multiple
use-chips
use-input
input-debounce="0"
new-value-mode="add-unique"
@new-value="addRouteOption"
:options="routeOptionsFiltered"
:loading="routeOptionsLoading"
@filter="filterRouteOptions"
v-model="formData.lnbits_route_access_blacklist"
:label="$t('route_access_blacklist_label')"
:hint="$t('route_access_blacklist_hint')"
></q-select>
</div>
</div>
</div>
<div class="col-12 col-md-12">
<p v-text="$t('rate_limiter')"></p>
<div class="row q-col-gutter-md">