This commit is contained in:
Arc
2026-01-29 17:45:44 +00:00
parent 91354f8be4
commit 87c1684a63
7 changed files with 172 additions and 3 deletions
+2
View File
@@ -66,6 +66,7 @@ from .middleware import (
add_first_install_middleware, add_first_install_middleware,
add_ip_block_middleware, add_ip_block_middleware,
add_ratelimit_middleware, add_ratelimit_middleware,
add_route_access_middleware,
) )
from .tasks import internal_invoice_listener, invoice_listener, run_interval from .tasks import internal_invoice_listener, invoice_listener, run_interval
@@ -192,6 +193,7 @@ def create_app() -> FastAPI:
# adds security middleware # adds security middleware
add_ip_block_middleware(app) add_ip_block_middleware(app)
add_route_access_middleware(app)
add_ratelimit_middleware(app) add_ratelimit_middleware(app)
register_exception_handlers(app) register_exception_handlers(app)
+49
View File
@@ -1,5 +1,6 @@
import asyncio import asyncio
import json import json
import re
from datetime import datetime, timezone from datetime import datetime, timezone
from http import HTTPStatus from http import HTTPStatus
from typing import Any from typing import Any
@@ -18,6 +19,20 @@ from lnbits.core.models import AuditEntry
from lnbits.helpers import normalize_path, template_renderer from lnbits.helpers import normalize_path, template_renderer
from lnbits.settings import settings 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: class InstalledExtensionMiddleware:
# This middleware class intercepts calls made to the extensions API and: # 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) 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): def add_first_install_middleware(app: FastAPI):
@app.middleware("http") @app.middleware("http")
async def first_install_middleware(request: Request, call_next): 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_rate_limit_unit: str = Field(default="minute")
lnbits_allowed_ips: list[str] = Field(default=[]) lnbits_allowed_ips: list[str] = Field(default=[])
lnbits_blocked_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( lnbits_callback_url_rules: list[str] = Field(
default=["https?://([a-zA-Z0-9.-]+\\.[a-zA-Z]{2,})(:\\d+)?"] default=["https?://([a-zA-Z0-9.-]+\\.[a-zA-Z]{2,})(:\\d+)?"]
) )
+12
View File
@@ -363,6 +363,18 @@ window.localisation.en = {
watchdog: 'Watchdog', watchdog: 'Watchdog',
server_logs: 'Server Logs', server_logs: 'Server Logs',
ip_blocker: 'IP Blocker', 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: 'Security',
security_tools: 'Security tools', security_tools: 'Security tools',
block_access_hint: 'Block access by IP', block_access_hint: 'Block access by IP',
@@ -8,11 +8,45 @@ window.app.component('lnbits-admin-security', {
serverlogEnabled: false, serverlogEnabled: false,
nostrAcceptedUrl: '', nostrAcceptedUrl: '',
formAllowedIPs: '', formAllowedIPs: '',
formCallbackUrlRule: '' formCallbackUrlRule: '',
routeOptions: [],
routeOptionsFiltered: [],
routeOptionsLoading: false
} }
}, },
created() {}, created() {
this.loadOpenApiRoutes()
},
methods: { 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() { addAllowedIPs() {
const allowedIPs = this.formAllowedIPs.trim() const allowedIPs = this.formAllowedIPs.trim()
const allowed_ips = this.formData.lnbits_allowed_ips const allowed_ips = this.formData.lnbits_allowed_ips
+24 -1
View File
@@ -8,7 +8,9 @@ window.PageAdmin = {
lnbits_exchange_rate_providers: [], lnbits_exchange_rate_providers: [],
lnbits_audit_exclude_paths: [], lnbits_audit_exclude_paths: [],
lnbits_audit_include_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, isSuperUser: false,
needsRestart: false needsRestart: false
@@ -69,6 +71,27 @@ window.PageAdmin = {
.catch(LNbits.utils.notifyApiError) .catch(LNbits.utils.notifyApiError)
}, },
updateSettings() { 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, [ const data = _.omit(this.formData, [
'is_super_user', 'is_super_user',
'lnbits_allowed_funding_sources', 'lnbits_allowed_funding_sources',
@@ -296,6 +296,52 @@
</div> </div>
</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"
: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"
: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"> <div class="col-12 col-md-12">
<p v-text="$t('rate_limiter')"></p> <p v-text="$t('rate_limiter')"></p>
<div class="row q-col-gutter-md"> <div class="row q-col-gutter-md">