Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
962a09bb30 | ||
|
|
92c86eb837 | ||
|
|
dadff18a9c | ||
|
|
87c1684a63 |
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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+)?"]
|
||||
)
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+1
-1
File diff suppressed because one or more lines are too long
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user