fix: external iframe access

This commit is contained in:
Vlad Stan
2026-07-09 16:30:18 +03:00
parent a175807188
commit 285c0bc92f
2 changed files with 80 additions and 10 deletions
+67 -8
View File
@@ -5,15 +5,19 @@ import re
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from uuid import uuid4
from fastapi import Depends, FastAPI, HTTPException, Request
from fastapi.responses import FileResponse
from fastapi.responses import FileResponse, PlainTextResponse
from fastapi.staticfiles import StaticFiles
from loguru import logger
from lnbits.decorators import check_user_exists, check_user_extension_access
from lnbits.helpers import template_renderer
from lnbits.settings import settings
from lnbits.utils.cache import cache
WASM_FRAME_TOKEN_EXPIRY_SECONDS = 60
@dataclass(frozen=True)
@@ -120,7 +124,7 @@ def _register_wasm_extension_ui_routes(app: FastAPI, extension: WasmExtension) -
frame_path = f"/ext-frame/{extension.id}/{route_index}"
auth = _wasm_extension_route_auth(extension, route_config.get("auth"))
path_params = route_config.get("path_params") or {}
_add_wasm_extension_frame_route(app, extension, frame_path, entrypoint, auth)
_add_wasm_extension_frame_route(app, extension, frame_path, entrypoint)
_add_wasm_extension_wrapper_route(
app,
extension,
@@ -313,12 +317,13 @@ def _add_wasm_extension_frame_route(
extension: WasmExtension,
frame_path: str,
entrypoint: Path,
auth: str,
) -> None:
if _has_route(app, frame_path, "GET"):
return
async def serve_wasm_extension_frame() -> FileResponse:
async def serve_wasm_extension_frame(request: Request) -> Any:
if not _consume_wasm_extension_frame_token(request, extension, frame_path):
return PlainTextResponse("Not found", status_code=404)
response = FileResponse(entrypoint)
response.headers["Content-Security-Policy"] = (
"sandbox allow-scripts allow-forms; "
@@ -332,10 +337,10 @@ def _add_wasm_extension_frame_route(
response.headers["Cache-Control"] = "no-store"
response.headers["Cross-Origin-Opener-Policy"] = "same-origin"
response.headers["Cross-Origin-Resource-Policy"] = "same-origin"
# for access will use the bridge API, but we don't want to allow any other access
# Extension access goes through the parent bridge.
response.headers["Permissions-Policy"] = (
"camera=(), microphone=(), geolocation=(), payment=(), "
"clipboard-read=(), usb=()"
"clipboard-read=(), clipboard-write=(), usb=()"
)
response.headers["Referrer-Policy"] = "no-referrer"
response.headers["X-Content-Type-Options"] = "nosniff"
@@ -346,7 +351,6 @@ def _add_wasm_extension_frame_route(
serve_wasm_extension_frame,
methods=["GET"],
name=f"{extension.id}:frame:{frame_path}",
dependencies=_wasm_extension_dependencies(extension, auth),
include_in_schema=False,
)
@@ -365,7 +369,7 @@ def _wasm_extension_wrapper_response(
"wasm_extension.html",
{
"extension": extension,
"frame_url": frame_path,
"frame_url": _wasm_extension_frame_url(extension, frame_path),
"bridge": {
"extensionId": extension.id,
"public": public,
@@ -379,6 +383,61 @@ def _wasm_extension_wrapper_response(
)
def _wasm_extension_frame_url(extension: WasmExtension, frame_path: str) -> str:
token = _create_wasm_extension_frame_token(extension, frame_path)
return f"{frame_path}?frame_token={token}"
def _create_wasm_extension_frame_token(
extension: WasmExtension,
frame_path: str,
) -> str:
token = uuid4().hex
cache.set(
_wasm_extension_frame_token_cache_key(token),
{
"extension_id": extension.id,
"frame_path": frame_path,
},
expiry=WASM_FRAME_TOKEN_EXPIRY_SECONDS,
)
return token
def _consume_wasm_extension_frame_token(
request: Request,
extension: WasmExtension,
frame_path: str,
) -> bool:
token = request.query_params.get("frame_token")
if not token:
return _reject_wasm_extension_frame(extension, frame_path, "missing")
token_data = cache.pop(_wasm_extension_frame_token_cache_key(token))
if (
not isinstance(token_data, dict)
or token_data.get("extension_id") != extension.id
or token_data.get("frame_path") != frame_path
):
return _reject_wasm_extension_frame(extension, frame_path, "unknown")
return True
def _wasm_extension_frame_token_cache_key(token: str) -> str:
return f"wasm-frame-token:{token}"
def _reject_wasm_extension_frame(
extension: WasmExtension,
frame_path: str,
reason: str,
) -> bool:
logger.warning(
f"WASM frame token {reason} for extension '{extension.id}' at '{frame_path}'."
)
return False
def _wasm_extension_bridge_api_routes(
extension: WasmExtension,
public: bool,
+13 -2
View File
@@ -14,7 +14,7 @@
<iframe
id="lnbits-wasm-extension-frame"
class="wasm-extension-frame"
src="{{ frame_url }}"
data-frame-url="{{ frame_url }}"
title="{{ extension.name }}"
sandbox="allow-scripts allow-forms"
referrerpolicy="no-referrer"
@@ -27,8 +27,18 @@
const bridge = {{ bridge | tojson | safe }}
function extensionFrameWindow() {
return extensionFrame()?.contentWindow
}
function extensionFrame() {
return document.getElementById('lnbits-wasm-extension-frame')
?.contentWindow
}
function loadExtensionFrame() {
const frame = extensionFrame()
if (!frame || frame.dataset.loaded === 'true') return
frame.dataset.loaded = 'true'
frame.src = frame.dataset.frameUrl
}
function sendResponse(targetWindow, id, payload) {
@@ -153,6 +163,7 @@
})
}
})
window.addEventListener('load', loadExtensionFrame)
})()
</script>
{% endblock %}