[fix] callback url validation (#2959)

This commit is contained in:
Vlad Stan 2025-02-13 15:11:46 +02:00 committed by GitHub
parent b76d8b5458
commit bfa23568e3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 139 additions and 5 deletions

View file

@ -13,7 +13,7 @@ from lnbits.decorators import (
WalletTypeInfo, WalletTypeInfo,
require_admin_key, require_admin_key,
) )
from lnbits.helpers import url_for from lnbits.helpers import check_callback_url, url_for
from lnbits.lnurl import LnurlErrorResponse from lnbits.lnurl import LnurlErrorResponse
from lnbits.lnurl import decode as decode_lnurl from lnbits.lnurl import decode as decode_lnurl
from lnbits.settings import settings from lnbits.settings import settings
@ -37,6 +37,7 @@ async def redeem_lnurl_withdraw(
headers = {"User-Agent": settings.user_agent} headers = {"User-Agent": settings.user_agent}
async with httpx.AsyncClient(headers=headers) as client: async with httpx.AsyncClient(headers=headers) as client:
lnurl = decode_lnurl(lnurl_request) lnurl = decode_lnurl(lnurl_request)
check_callback_url(str(lnurl))
r = await client.get(str(lnurl)) r = await client.get(str(lnurl))
res = r.json() res = r.json()
@ -72,6 +73,7 @@ async def redeem_lnurl_withdraw(
headers = {"User-Agent": settings.user_agent} headers = {"User-Agent": settings.user_agent}
async with httpx.AsyncClient(headers=headers) as client: async with httpx.AsyncClient(headers=headers) as client:
try: try:
check_callback_url(res["callback"])
await client.get(res["callback"], params=params) await client.get(res["callback"], params=params)
except Exception: except Exception:
pass pass
@ -135,6 +137,7 @@ async def perform_lnurlauth(
headers = {"User-Agent": settings.user_agent} headers = {"User-Agent": settings.user_agent}
async with httpx.AsyncClient(headers=headers) as client: async with httpx.AsyncClient(headers=headers) as client:
assert key.verifying_key, "LNURLauth verifying_key does not exist" assert key.verifying_key, "LNURLauth verifying_key does not exist"
check_callback_url(callback)
r = await client.get( r = await client.get(
callback, callback,
params={ params={

View file

@ -6,6 +6,7 @@ from py_vapid.utils import b64urlencode
from lnbits.db import dict_to_model from lnbits.db import dict_to_model
from lnbits.settings import ( from lnbits.settings import (
EditableSettings, EditableSettings,
UpdateSettings,
readonly_variables, readonly_variables,
settings, settings,
) )
@ -37,8 +38,12 @@ async def check_webpush_settings():
logger.info(f"Pubkey: {settings.lnbits_webpush_pubkey}") logger.info(f"Pubkey: {settings.lnbits_webpush_pubkey}")
def dict_to_settings(sets_dict: dict) -> UpdateSettings:
return dict_to_model(sets_dict, UpdateSettings)
def update_cached_settings(sets_dict: dict): def update_cached_settings(sets_dict: dict):
editable_settings = dict_to_model(sets_dict, EditableSettings) editable_settings = dict_to_settings(sets_dict)
for key in sets_dict.keys(): for key in sets_dict.keys():
if key in readonly_variables: if key in readonly_variables:
continue continue

View file

@ -30,6 +30,7 @@ from lnbits.core.services.notifications import (
process_next_notification, process_next_notification,
) )
from lnbits.db import Filters from lnbits.db import Filters
from lnbits.helpers import check_callback_url
from lnbits.settings import settings from lnbits.settings import settings
from lnbits.tasks import create_unique_task, send_push_notification from lnbits.tasks import create_unique_task, send_push_notification
from lnbits.utils.exchange_rates import btc_rates from lnbits.utils.exchange_rates import btc_rates
@ -117,6 +118,7 @@ async def dispatch_webhook(payment: Payment):
async with httpx.AsyncClient(headers=headers) as client: async with httpx.AsyncClient(headers=headers) as client:
data = payment.dict() data = payment.dict()
try: try:
check_callback_url(payment.webhook)
r = await client.post(payment.webhook, json=data, timeout=40) r = await client.post(payment.webhook, json=data, timeout=40)
r.raise_for_status() r.raise_for_status()
await mark_webhook_sent(payment.payment_hash, r.status_code) await mark_webhook_sent(payment.payment_hash, r.status_code)

View file

@ -318,6 +318,42 @@
</div> </div>
</div> </div>
</div> </div>
<div class="col-12 col-md-12">
<p v-text="$t('callback_url_rules')"></p>
<div class="row q-col-gutter-md">
<div class="col-12">
<q-input
filled
v-model="formCallbackUrlRule"
@keydown.enter="addCallbackUrlRule"
type="text"
:label="$t('enter_callback_url_rule')"
:hint="$t('callback_url_rule_hint')"
>
<q-btn
@click="addCallbackUrlRule"
dense
flat
icon="add"
></q-btn>
</q-input>
<div>
<q-chip
v-for="rule in formData.lnbits_callback_url_rules"
:key="rule"
removable
@remove="removeCallbackUrlRule(rule)"
color="primary"
text-color="white"
:label="rule"
class="ellipsis"
></q-chip>
</div>
<br />
</div>
</div>
</div>
</div> </div>
</div> </div>
</q-card-section> </q-card-section>

View file

@ -16,6 +16,7 @@ from lnbits.core.services import (
get_balance_delta, get_balance_delta,
update_cached_settings, update_cached_settings,
) )
from lnbits.core.services.settings import dict_to_settings
from lnbits.decorators import check_admin, check_super_user from lnbits.decorators import check_admin, check_super_user
from lnbits.server import server_restart from lnbits.server import server_restart
from lnbits.settings import AdminSettings, Settings, UpdateSettings, settings from lnbits.settings import AdminSettings, Settings, UpdateSettings, settings
@ -71,6 +72,15 @@ async def api_update_settings(data: UpdateSettings, user: User = Depends(check_a
return {"status": "Success"} return {"status": "Success"}
@admin_router.patch(
"/api/v1/settings",
status_code=HTTPStatus.OK,
)
async def api_update_settings_partial(data: dict, user: User = Depends(check_admin)):
updatable_settings = dict_to_settings({**settings.dict(), **data})
return await api_update_settings(updatable_settings, user)
@admin_router.get( @admin_router.get(
"/api/v1/settings/default", "/api/v1/settings/default",
status_code=HTTPStatus.OK, status_code=HTTPStatus.OK,

View file

@ -29,6 +29,7 @@ from lnbits.decorators import (
require_admin_key, require_admin_key,
require_invoice_key, require_invoice_key,
) )
from lnbits.helpers import check_callback_url
from lnbits.lnurl import decode as lnurl_decode from lnbits.lnurl import decode as lnurl_decode
from lnbits.settings import settings from lnbits.settings import settings
from lnbits.utils.exchange_rates import ( from lnbits.utils.exchange_rates import (
@ -128,6 +129,7 @@ async def api_lnurlscan(
else: else:
headers = {"User-Agent": settings.user_agent} headers = {"User-Agent": settings.user_agent}
async with httpx.AsyncClient(headers=headers, follow_redirects=True) as client: async with httpx.AsyncClient(headers=headers, follow_redirects=True) as client:
check_callback_url(url)
r = await client.get(url, timeout=5) r = await client.get(url, timeout=5)
r.raise_for_status() r.raise_for_status()
if r.is_error: if r.is_error:

View file

@ -17,7 +17,7 @@ from lnbits.core.models.extensions import ExtensionMeta, InstallableExtension
from lnbits.core.services import create_invoice, create_user_account from lnbits.core.services import create_invoice, create_user_account
from lnbits.core.services.extensions import get_valid_extensions from lnbits.core.services.extensions import get_valid_extensions
from lnbits.decorators import check_admin, check_user_exists from lnbits.decorators import check_admin, check_user_exists
from lnbits.helpers import template_renderer from lnbits.helpers import check_callback_url, template_renderer
from lnbits.settings import settings from lnbits.settings import settings
from lnbits.wallets import get_funding_source from lnbits.wallets import get_funding_source
@ -443,6 +443,7 @@ async def lnurlwallet(request: Request, lightning: str = ""):
lnurl = lnurl_decode(lightning) lnurl = lnurl_decode(lightning)
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
check_callback_url(lnurl)
res1 = await client.get(lnurl, timeout=2) res1 = await client.get(lnurl, timeout=2)
res1.raise_for_status() res1.raise_for_status()
data1 = res1.json() data1 = res1.json()

View file

@ -46,7 +46,11 @@ from lnbits.decorators import (
require_admin_key, require_admin_key,
require_invoice_key, require_invoice_key,
) )
from lnbits.helpers import filter_dict_keys, generate_filter_params_openapi from lnbits.helpers import (
check_callback_url,
filter_dict_keys,
generate_filter_params_openapi,
)
from lnbits.lnurl import decode as lnurl_decode from lnbits.lnurl import decode as lnurl_decode
from lnbits.settings import settings from lnbits.settings import settings
from lnbits.utils.exchange_rates import fiat_amount_as_satoshis from lnbits.utils.exchange_rates import fiat_amount_as_satoshis
@ -225,6 +229,7 @@ async def _api_payments_create_invoice(data: CreateInvoice, wallet: Wallet):
headers = {"User-Agent": settings.user_agent} headers = {"User-Agent": settings.user_agent}
async with httpx.AsyncClient(headers=headers) as client: async with httpx.AsyncClient(headers=headers) as client:
try: try:
check_callback_url(data.lnurl_callback)
r = await client.get( r = await client.get(
data.lnurl_callback, data.lnurl_callback,
params={"pr": payment.bolt11}, params={"pr": payment.bolt11},
@ -337,6 +342,7 @@ async def api_payments_pay_lnurl(
amount_msat = ceil(amount_msat // 1000) * 1000 amount_msat = ceil(amount_msat // 1000) * 1000
else: else:
amount_msat = data.amount amount_msat = data.amount
check_callback_url(data.callback)
r = await client.get( r = await client.get(
data.callback, data.callback,
params={"amount": amount_msat, "comment": data.comment}, params={"amount": amount_msat, "comment": data.comment},
@ -461,6 +467,7 @@ async def api_payment_pay_with_nfc(
headers = {"User-Agent": settings.user_agent} headers = {"User-Agent": settings.user_agent}
async with httpx.AsyncClient(headers=headers, follow_redirects=True) as client: async with httpx.AsyncClient(headers=headers, follow_redirects=True) as client:
try: try:
check_callback_url(url)
lnurl_req = await client.get(url, timeout=10) lnurl_req = await client.get(url, timeout=10)
if lnurl_req.is_error: if lnurl_req.is_error:
return JSONResponse( return JSONResponse(

View file

@ -5,6 +5,7 @@ from datetime import datetime, timedelta, timezone
from pathlib import Path from pathlib import Path
from typing import Any, Optional, Type from typing import Any, Optional, Type
from urllib import request from urllib import request
from urllib.parse import urlparse
import jinja2 import jinja2
import jwt import jwt
@ -272,6 +273,15 @@ def is_lnbits_version_ok(
return True return True
def check_callback_url(url: str):
netloc = urlparse(url).netloc
for rule in settings.lnbits_callback_url_rules:
if re.match(rule, netloc) is None:
raise ValueError(
f"Callback not allowed. URL: {url}. Netloc: {netloc}. Rule: {rule}"
)
def download_url(url, save_path): def download_url(url, save_path):
with request.urlopen(url, timeout=60) as dl_file: with request.urlopen(url, timeout=60) as dl_file:
with open(save_path, "wb") as out_file: with open(save_path, "wb") as out_file:

View file

@ -366,6 +366,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_callback_url_rules: list[str] = Field(
default=["^(?!\\d+\\.\\d+\\.\\d+\\.\\d+$)(?:[a-zA-Z0-9-]+\\.)+[a-zA-Z]{2,}$"]
)
lnbits_wallet_limit_max_balance: int = Field(default=0) lnbits_wallet_limit_max_balance: int = Field(default=0)
lnbits_wallet_limit_daily_max_withdraw: int = Field(default=0) lnbits_wallet_limit_daily_max_withdraw: int = Field(default=0)

File diff suppressed because one or more lines are too long

View file

@ -248,6 +248,10 @@ window.localisation.en = {
allow_access_hint: 'Allow access by IP (will override blocked IPs)', allow_access_hint: 'Allow access by IP (will override blocked IPs)',
enter_ip: 'Enter IP and hit enter', enter_ip: 'Enter IP and hit enter',
rate_limiter: 'Rate Limiter', rate_limiter: 'Rate Limiter',
callback_url_rules: 'Callback URL Rules',
enter_callback_url_rule: 'Enter URL rule as regex and hit enter',
callback_url_rule_hint:
'Callback URLs (like LNURL one) will be validated against all of these rules. No rule means all URLs are allowed.',
wallet_limiter: 'Wallet Limiter', wallet_limiter: 'Wallet Limiter',
wallet_config: 'Wallet Config', wallet_config: 'Wallet Config',
wallet_charts: 'Wallet Charts', wallet_charts: 'Wallet Charts',

View file

@ -56,6 +56,7 @@ window.AdminPageLogic = {
formAddExtensionsManifest: '', formAddExtensionsManifest: '',
nostrNotificationIdentifier: '', nostrNotificationIdentifier: '',
formAllowedIPs: '', formAllowedIPs: '',
formCallbackUrlRule: '',
formBlockedIPs: '', formBlockedIPs: '',
nostrAcceptedUrl: '', nostrAcceptedUrl: '',
formAddIncludePath: '', formAddIncludePath: '',
@ -331,6 +332,28 @@ window.AdminPageLogic = {
b => b !== blocked_ip b => b !== blocked_ip
) )
}, },
addCallbackUrlRule() {
const allowedCallback = this.formCallbackUrlRule.trim()
const allowedCallbacks = this.formData.lnbits_callback_url_rules
if (
allowedCallback &&
allowedCallback.length &&
!allowedCallbacks.includes(allowedCallback)
) {
this.formData.lnbits_callback_url_rules = [
...allowedCallbacks,
allowedCallback
]
this.formCallbackUrlRule = ''
}
},
removeCallbackUrlRule(allowedCallback) {
const allowedCallbacks = this.formData.lnbits_callback_url_rules
this.formData.lnbits_callback_url_rules = allowedCallbacks.filter(
a => a !== allowedCallback
)
},
addNostrUrl() { addNostrUrl() {
const url = this.nostrAcceptedUrl.trim() const url = this.nostrAcceptedUrl.trim()
this.removeNostrUrl(url) this.removeNostrUrl(url)

View file

@ -683,3 +683,31 @@ async def test_api_payment_pay_with_nfc(
assert response.status_code == HTTPStatus.OK assert response.status_code == HTTPStatus.OK
assert response.json() == expected_response assert response.json() == expected_response
@pytest.mark.anyio
async def test_api_payments_pay_lnurl(client, adminkey_headers_from):
valid_lnurl_data = {
"description_hash": "randomhash",
"callback": "https://example.com/callback",
"amount": 1000,
"unit": "sat",
"comment": "test comment",
"description": "test description",
}
invalid_lnurl_data = {**valid_lnurl_data, "callback": "invalid_url"}
# Test with valid callback URL
response = await client.post(
"/api/v1/payments/lnurl", json=valid_lnurl_data, headers=adminkey_headers_from
)
assert response.status_code == 400
assert response.json()["detail"] == "Failed to connect to example.com."
# Test with invalid callback URL
response = await client.post(
"/api/v1/payments/lnurl", json=invalid_lnurl_data, headers=adminkey_headers_from
)
assert response.status_code == 400
assert "Callback not allowed." in response.json()["detail"]