blog.git

blog

~ Personal research notes. blog.sqzer.com
git clone https://github.com/debuman/blog.git
commit d8520215044afa06771ee574411264247170a757
parent 0442f99e782c241881da99fa5235f307379d33d7
Author: sqzer <110329244+sqzer-x@users.noreply.github.com>
Date: Fri, 25 Sep 2026 03:54:29 +0900

fix: escape feed descriptions as HTML text

Feed readers XML-decode <description> and render it as HTML, but the
description is a plain-text excerpt. A first paragraph quoting
<img src=x onerror=...> in a code span would come out of a reader as a
live element. & < > are now escaped before @astrojs/rss escapes the XML,
so readers show the text as written. No current description contains
those characters, so today's feed is unchanged.

Diffstat:
Msrc/pages/index.xml.ts | 14++++++++++----
1 file changed, 10 insertion(+), 4 deletion(-)

diff --git a/src/pages/index.xml.ts b/src/pages/index.xml.ts
@@ -19,10 +19,16 @@ import { excerpt } from '../lib/prose';
  * the page's own <meta name="description"> uses, which means a post that gains a `deck`
  * improves both at once.
  *
- * Escaping is @astrojs/rss's, not ours. The corpus is full of the characters that break
- * hand-rolled XML: ampersands in shell pipelines, angle brackets in log output, quotes
- * and em-dashes in Korean prose.
+ * XML escaping is @astrojs/rss's, not ours. The corpus is full of the characters that
+ * break hand-rolled XML: ampersands in shell pipelines, angle brackets in log output,
+ * quotes and em-dashes in Korean prose.
+ *
+ * HTML escaping is ours. A reader XML-decodes <description> and then renders it as HTML,
+ * but the excerpt is plain text: a first paragraph quoting `<img src=x onerror=…>` in a
+ * code span would come out of the reader as a live element. Escaping & < > before
+ * @astrojs/rss escapes again makes the reader show it as the text it is.
  */
+const asHtmlText = (s: string): string => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
 export async function GET(context: APIContext) {
   const posts = (await getCollection('writing'))
     .filter((p) => !p.data.draft)
@@ -38,7 +44,7 @@ export async function GET(context: APIContext) {
       // Dates are stored YYYY-MM-DD and read as UTC everywhere on this site; parsing them
       // in local time moves half the corpus back a day.
       pubDate: new Date(`${p.data.date}T00:00:00Z`),
-      description: p.data.deck ?? excerpt(p.body ?? ''),
+      description: asHtmlText(p.data.deck ?? excerpt(p.body ?? '')),
       link: postUrl(p),
     })),
     customData: '<language>en</language>',