Guides image + draft gating, audit retention, ADR-015 gate, backup credential redaction
This commit is contained in:
@@ -27,12 +27,29 @@ function escapeHtml (text) {
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
|
||||
// The text was HTML-escaped before any pattern ran, so a captured URL arrives
|
||||
// carrying entities: a real query string like ?a=1&b=2 reaches here as
|
||||
// ?a=1&b=2. Escaping that again for the attribute produced & and a
|
||||
// link that opened the wrong record - the ServiceNow and SharePoint links that
|
||||
// carry several parameters were all broken this way. Decode first, then let the
|
||||
// single escape on output put back exactly one level.
|
||||
function decodeEntities (text) {
|
||||
return String(text)
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/&/g, '&') // last, so &lt; does not become <
|
||||
}
|
||||
|
||||
// Only http(s) and mailto. A javascript: or data: URL in a link is the one way
|
||||
// an author could otherwise smuggle script past the escaping above.
|
||||
function safeHref (url) {
|
||||
const trimmed = String(url || '').trim()
|
||||
if (/^(https?:|mailto:)/i.test(trimmed)) return trimmed
|
||||
if (trimmed.startsWith('/')) return trimmed // in-app link
|
||||
// In-app link. NOT '//host', which looks like a path and is read by the
|
||||
// browser as protocol-relative - an off-site link wearing a local costume.
|
||||
if (trimmed.startsWith('/') && !trimmed.startsWith('//')) return trimmed
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -50,13 +67,18 @@ export function renderInline (text) {
|
||||
// [label](url). The URL pattern allows ONE level of balanced parentheses so
|
||||
// real links survive - Wikipedia-style /wiki/Foo_(bar) is the common case,
|
||||
// and a naive [^)]+ silently truncates it into a broken link.
|
||||
// Finished links are stashed like code spans, so the emphasis passes below
|
||||
// cannot reach inside an href. Without that, a URL containing *characters*
|
||||
// has <em> injected into the middle of the attribute and the link breaks.
|
||||
const links = []
|
||||
html = html.replace(/\[([^\]]+)\]\(((?:[^()\s]|\([^()\s]*\))+)\)/g, (match, label, url) => {
|
||||
const href = safeHref(url)
|
||||
const href = safeHref(decodeEntities(url))
|
||||
if (!href) return label
|
||||
const external = /^https?:/i.test(href)
|
||||
return '<a href="' + escapeHtml(href) + '"' +
|
||||
links.push('<a href="' + escapeHtml(href) + '"' +
|
||||
(external ? ' target="_blank" rel="noopener noreferrer"' : '') +
|
||||
'>' + label + '</a>'
|
||||
'>' + label + '</a>')
|
||||
return '\u0000LINK' + (links.length - 1) + '\u0000'
|
||||
})
|
||||
|
||||
// **bold** then *italic*, longest marker first so **x** is not read as
|
||||
@@ -66,6 +88,8 @@ export function renderInline (text) {
|
||||
|
||||
html = html.replace(/\n/g, '<br>')
|
||||
|
||||
html = html.replace(/\u0000LINK(\d+)\u0000/g, (match, index) => links[Number(index)])
|
||||
|
||||
html = html.replace(/\u0000CODE(\d+)\u0000/g, (match, index) =>
|
||||
'<code>' + codeSpans[Number(index)] + '</code>')
|
||||
|
||||
|
||||
Reference in New Issue
Block a user