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_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)
+84
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,53 @@ 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"}
_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: 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 +283,42 @@ 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 "/"
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): 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+)?"]
) )
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', 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,58 @@ 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
}
},
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() { 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
+22 -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,25 @@ 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,56 @@
</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"
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"> <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">