[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,
|
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={
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
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)',
|
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',
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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"]
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue