mirror of
https://github.com/lnbits/lnbits-legend.git
synced 2025-03-13 19:37:42 +01:00
[fix] callback url validation (#2959)
This commit is contained in:
parent
b76d8b5458
commit
bfa23568e3
14 changed files with 139 additions and 5 deletions
|
@ -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={
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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)
|
||||
|
|
2
lnbits/static/bundle.min.js
vendored
2
lnbits/static/bundle.min.js
vendored
File diff suppressed because one or more lines are too long
|
@ -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',
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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"]
|
||||
|
|
Loading…
Add table
Reference in a new issue