fix: sanitize details_link (#4108)

Co-authored-by: alan <alan@lnbits.com>
This commit is contained in:
Vlad Stan 2026-07-28 15:11:05 +03:00 committed by GitHub
parent 0a7da6dc73
commit 8b0413fa16
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 116 additions and 4 deletions

File diff suppressed because one or more lines are too long

View file

@ -26,6 +26,7 @@ window.PageExtensions = {
selectedExtension: null,
selectedImage: null,
selectedExtensionDetails: null,
selectedExtensionDetailsDescription: '',
selectedExtensionRepos: null,
selectedRelease: null,
permissionGrant: {
@ -656,6 +657,7 @@ window.PageExtensions = {
this.selectedExtension =
this.extensions.find(ext => ext.id === extId) || this.selectedExtension
this.selectedExtensionDetails = null
this.selectedExtensionDetailsDescription = ''
this.showExtensionDetailsDialog = true
this.slide = 0
this.fullscreen = false
@ -667,12 +669,89 @@ window.PageExtensions = {
)
this.selectedExtensionDetails = data
this.selectedExtensionDetails.description_md =
LNbits.utils.convertMarkdown(data.description_md)
this.selectedExtensionDetailsDescription =
this.extensionDescriptionDocument(data.description_md)
} catch (error) {
console.warn(error)
}
},
extensionDescriptionDocument(markdown) {
const source = typeof markdown === 'string' ? markdown : ''
const rendered = LNbits.utils.convertMarkdown(source)
const parsed = new DOMParser().parseFromString(rendered, 'text/html')
parsed.body
.querySelectorAll(
'applet, base, embed, form, frame, iframe, link, meta, object, portal, script'
)
.forEach(element => element.remove())
parsed.body.querySelectorAll('*').forEach(element => {
for (const attribute of [...element.attributes]) {
const attributeName = attribute.name.toLowerCase()
if (
attributeName.startsWith('on') ||
attributeName === 'srcdoc' ||
attributeName === 'xlink:href'
) {
element.removeAttribute(attribute.name)
}
}
})
parsed.body.querySelectorAll('a[href], area[href]').forEach(link => {
try {
const url = new URL(link.getAttribute('href'), window.location.origin)
if (
!['http:', 'https:'].includes(url.protocol) ||
url.username ||
url.password
) {
link.removeAttribute('href')
return
}
link.setAttribute('href', url.href)
link.setAttribute('target', '_blank')
link.setAttribute('rel', 'noopener noreferrer')
} catch (_error) {
link.removeAttribute('href')
}
})
const csp = [
"default-src 'none'",
'img-src https: data:',
"style-src 'unsafe-inline'",
"script-src 'none'",
"script-src-attr 'none'",
"base-uri 'none'",
"form-action 'none'",
"frame-src 'none'",
"object-src 'none'"
].join('; ')
const styles = `
:root { color-scheme: light dark; font-family: Roboto, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
body { margin: 0; color: CanvasText; background: Canvas; line-height: 1.5; overflow-wrap: anywhere; }
img { max-width: 100%; height: auto; }
pre { overflow: auto; padding: 0.75rem; background: color-mix(in srgb, CanvasText 8%, Canvas); }
code { font-family: ui-monospace, SFMono-Regular, Consolas, monospace; }
table { display: block; max-width: 100%; overflow-x: auto; border-collapse: collapse; }
th, td { padding: 0.35rem 0.6rem; border: 1px solid color-mix(in srgb, CanvasText 20%, Canvas); }
a[href] { color: LinkText; cursor: pointer; }
`
return `<!doctype html>
<html>
<head>
<meta charset="utf-8">
<meta http-equiv="Content-Security-Policy" content="${csp}">
<meta name="referrer" content="no-referrer">
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>${styles}</style>
</head>
<body>
${parsed.body.innerHTML}
</body>
</html>`
},
async payAndInstall(release) {
try {
if ((await this.resolveExtensionPermissionGrant(release)) === null) {

View file

@ -1282,7 +1282,15 @@
<div class="row">
<div class="col-sm-12 col-md-8 q-pr-sm">
<div v-html="selectedExtensionDetails.description_md"></div>
<iframe
ref="extensionDescriptionFrame"
:srcdoc="selectedExtensionDetailsDescription"
title="Extension description"
sandbox="allow-popups allow-popups-to-escape-sandbox"
referrerpolicy="no-referrer"
loading="lazy"
style="width: 100%; min-height: 480px; border: 0"
></iframe>
</div>
<div class="col-sm-12 col-md-4 q-pl-sm">
<lnbits-extension-rating

View file

@ -104,6 +104,31 @@ def test_extension_install_ui_warns_only_for_python_releases():
assert '<q-icon name="info"' in extensions_template
def test_extension_details_render_in_isolated_frame():
extensions_page = (ROOT / "lnbits/static/js/pages/extensions.js").read_text(
encoding="utf-8"
)
extensions_template = (ROOT / "lnbits/templates/pages/extensions.vue").read_text(
encoding="utf-8"
)
assert 'v-html="selectedExtensionDetails.description_md"' not in extensions_template
assert ':srcdoc="selectedExtensionDetailsDescription"' in extensions_template
assert (
'sandbox="allow-popups allow-popups-to-escape-sandbox"' in extensions_template
)
assert 'referrerpolicy="no-referrer"' in extensions_template
assert "\"default-src 'none'\"" in extensions_page
assert "\"script-src 'none'\"" in extensions_page
assert "\"script-src-attr 'none'\"" in extensions_page
assert "attributeName === 'xlink:href'" in extensions_page
assert "['http:', 'https:'].includes(url.protocol)" in extensions_page
assert "link.setAttribute('target', '_blank')" in extensions_page
assert "link.setAttribute('rel', 'noopener noreferrer')" in extensions_page
assert "title: 'Open external link?'" not in extensions_page
def test_wasm_admin_frontend_calls_runtime_limit_and_invocation_endpoints():
runtime = (
ROOT / "lnbits/static/js/components/admin/lnbits-admin-wasm-runtime.js"