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

View file

@ -6,6 +6,7 @@ from py_vapid.utils import b64urlencode
from lnbits.db import dict_to_model
from lnbits.settings import (
EditableSettings,
UpdateSettings,
readonly_variables,
settings,
)
@ -37,8 +38,12 @@ async def check_webpush_settings():
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):
editable_settings = dict_to_model(sets_dict, EditableSettings)
editable_settings = dict_to_settings(sets_dict)
for key in sets_dict.keys():
if key in readonly_variables:
continue

View file

@ -30,6 +30,7 @@ from lnbits.core.services.notifications import (
process_next_notification,
)
from lnbits.db import Filters
from lnbits.helpers import check_callback_url
from lnbits.settings import settings
from lnbits.tasks import create_unique_task, send_push_notification
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:
data = payment.dict()
try:
check_callback_url(payment.webhook)
r = await client.post(payment.webhook, json=data, timeout=40)
r.raise_for_status()
await mark_webhook_sent(payment.payment_hash, r.status_code)

View file

@ -318,6 +318,42 @@
</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>
</q-card-section>

View file

@ -16,6 +16,7 @@ from lnbits.core.services import (
get_balance_delta,
update_cached_settings,
)
from lnbits.core.services.settings import dict_to_settings
from lnbits.decorators import check_admin, check_super_user
from lnbits.server import server_restart
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"}
@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(
"/api/v1/settings/default",
status_code=HTTPStatus.OK,

View file

@ -29,6 +29,7 @@ from lnbits.decorators import (
require_admin_key,
require_invoice_key,
)
from lnbits.helpers import check_callback_url
from lnbits.lnurl import decode as lnurl_decode
from lnbits.settings import settings
from lnbits.utils.exchange_rates import (
@ -128,6 +129,7 @@ async def api_lnurlscan(
else:
headers = {"User-Agent": settings.user_agent}
async with httpx.AsyncClient(headers=headers, follow_redirects=True) as client:
check_callback_url(url)
r = await client.get(url, timeout=5)
r.raise_for_status()
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.extensions import get_valid_extensions
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.wallets import get_funding_source
@ -443,6 +443,7 @@ async def lnurlwallet(request: Request, lightning: str = ""):
lnurl = lnurl_decode(lightning)
async with httpx.AsyncClient() as client:
check_callback_url(lnurl)
res1 = await client.get(lnurl, timeout=2)
res1.raise_for_status()
data1 = res1.json()

View file

@ -46,7 +46,11 @@ from lnbits.decorators import (
require_admin_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.settings import settings
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}
async with httpx.AsyncClient(headers=headers) as client:
try:
check_callback_url(data.lnurl_callback)
r = await client.get(
data.lnurl_callback,
params={"pr": payment.bolt11},
@ -337,6 +342,7 @@ async def api_payments_pay_lnurl(
amount_msat = ceil(amount_msat // 1000) * 1000
else:
amount_msat = data.amount
check_callback_url(data.callback)
r = await client.get(
data.callback,
params={"amount": amount_msat, "comment": data.comment},
@ -461,6 +467,7 @@ async def api_payment_pay_with_nfc(
headers = {"User-Agent": settings.user_agent}
async with httpx.AsyncClient(headers=headers, follow_redirects=True) as client:
try:
check_callback_url(url)
lnurl_req = await client.get(url, timeout=10)
if lnurl_req.is_error:
return JSONResponse(

View file

@ -5,6 +5,7 @@ from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any, Optional, Type
from urllib import request
from urllib.parse import urlparse
import jinja2
import jwt
@ -272,6 +273,15 @@ def is_lnbits_version_ok(
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):
with request.urlopen(url, timeout=60) as dl_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_allowed_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_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)',
enter_ip: 'Enter IP and hit enter',
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_config: 'Wallet Config',
wallet_charts: 'Wallet Charts',

View file

@ -56,6 +56,7 @@ window.AdminPageLogic = {
formAddExtensionsManifest: '',
nostrNotificationIdentifier: '',
formAllowedIPs: '',
formCallbackUrlRule: '',
formBlockedIPs: '',
nostrAcceptedUrl: '',
formAddIncludePath: '',
@ -331,6 +332,28 @@ window.AdminPageLogic = {
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() {
const url = this.nostrAcceptedUrl.trim()
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.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"]