An assistant that retrieves the right knowledge base article and then cannot give the reader a link to it has lost most of its value. This is a common and slightly embarrassing bug, and it comes from doing exactly the right thing about security in the wrong order.
What it looks like
A customer's report, near enough verbatim:
I wasn't able to get the bot to include an active link to the knowledge base entry in its response. At first, it created the link in Markdown, but after I disabled that option in the settings, it only created it as plain text.
Both halves are the same bug. The reply is being escaped wholesale, so [Title](https://example.com/kb/1) arrives as those literal characters. Turning an editor Markdown option on or off changes nothing, because the content never contained markup to begin with.
The cause
Model output is untrusted text, so it gets escaped before being posted — correctly:
$escaped = htmlspecialchars( $para, ENT_QUOTES | ENT_DISALLOWED, 'UTF-8', false ); $out .= '<p>' . nl2br( $escaped ) . '</p>';
Invision Community post content is HTML. Escaped text is therefore displayed text, and there is no step that ever turns a URL into an anchor.
The fix, and the order that matters
Convert links after escaping, never before. The input to the converter is already-safe HTML, and it only ever inserts a tag it built itself from a URL it has checked:
/* Markdown links: the brackets survive htmlspecialchars untouched. */
$html = preg_replace_callback(
'#\[([^\]\n]{1,200})\]\((https?://[^\s()<>"]{1,2000})\)#i',
fn( $m ) => static::anchor( $m[2], $m[1] ),
$html
);
/* Then bare URLs, but not ones already inside an href. */
$html = preg_replace_callback(
'#(?<!href=")(?<!">)\bhttps?://[^\s<>"\']{1,2000}#i',
function( $m ) {
/* trailing punctuation belongs to the sentence, not the URL */
$url = rtrim( $m[0], '.,;:!?)' );
$tail = substr( $m[0], strlen( $url ) );
return static::anchor( $url, $url ) . $tail;
},
$html
);
protected static function anchor( string $url, string $label ): string
{
/* http and https only. A member can talk a model into writing
javascript: or data:, and this output is posted under an
official-looking account. */
if ( !preg_match( '#^https?://#i', html_entity_decode( $url, ENT_QUOTES, 'UTF-8' ) ) )
{
return $label;
}
return '<a href="' . $url . '" rel="noopener">' . $label . '</a>';
}
Doing it the other way round — linkifying raw model output and escaping afterwards — either destroys the anchors you just built or, far worse, lets the model emit its own markup into a post.
Test the cases that matter
The scheme check is the one to prove, not assume:
'See [the guide](https://example.com/kb/1).' -> a real link 'It is at https://example.com/kb/1 and works' -> a real link 'Read https://example.com/kb/1.' -> link, full stop outside it 'Click [here](javascript:alert(1)) now.' -> NOT a link, left as text 'Try <script>alert(1)</script> and [x](https://a.co)' -> script still escaped '[Search](https://a.co/x?a=1&b=2)' -> href keeps &
The & case is worth checking explicitly: because escaping ran first, the ampersand in the query string is already &, which is exactly what an HTML attribute should contain.
Related application: AI Assistant — AI Assistant turns the links in an answer into real links, so a reply that cites a knowledge base article gives you something to click.
Recommended Comments