Received: from mail.netlandish.com (mail.netlandish.com [174.136.98.166])
	by code.netlandish.com (Postfix) with ESMTP id 2454B352
	for <~netlandish/links-dev@lists.code.netlandish.com>; Sat, 28 Feb 2026 13:54:22 +0000 (UTC)
Received-SPF: Pass (mailfrom) identity=mailfrom; client-ip=74.125.224.41; helo=mail-yx1-f41.google.com; envelope-from=peter@netlandish.com; receiver=<UNKNOWN> 
Authentication-Results: mail.netlandish.com;
	dkim=pass (1024-bit key; unprotected) header.d=netlandish.com header.i=@netlandish.com header.b=T4SuKFPh
Received: from mail-yx1-f41.google.com (mail-yx1-f41.google.com [74.125.224.41])
	by mail.netlandish.com (Postfix) with ESMTP id A6C2D1D815C
	for <~netlandish/links-dev@lists.code.netlandish.com>; Sat, 28 Feb 2026 13:54:19 +0000 (UTC)
Received: by mail-yx1-f41.google.com with SMTP id 956f58d0204a3-64ad79df972so3058441d50.1
        for <~netlandish/links-dev@lists.code.netlandish.com>; Sat, 28 Feb 2026 05:54:19 -0800 (PST)
DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed;
        d=netlandish.com; s=google; t=1772286859; x=1772891659; darn=lists.code.netlandish.com;
        h=content-transfer-encoding:mime-version:message-id:date:subject:cc
         :to:from:from:to:cc:subject:date:message-id:reply-to;
        bh=6UmetgeJEzfMXfXtrTALTkAKl/GDeF+yTTvL7xWiItg=;
        b=T4SuKFPh5MU4yAOytOsfUFoOJWtvB1lLxOtzyOUwv3j/Tnq/Lx1rCwc/ksLb4gGn/F
         VJ8fK0kXTw4tkm3AelNdTrFmNE4DUjtzmNZImfmGXZ+nyU+23KF+GWLoHEIFp36YF5wa
         Zk/Mf8PnVMCkJr8vgfw/8Sb0xlWAEUbt7JSQw=
X-Google-DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed;
        d=1e100.net; s=20230601; t=1772286859; x=1772891659;
        h=content-transfer-encoding:mime-version:message-id:date:subject:cc
         :to:from:x-gm-gg:x-gm-message-state:from:to:cc:subject:date
         :message-id:reply-to;
        bh=6UmetgeJEzfMXfXtrTALTkAKl/GDeF+yTTvL7xWiItg=;
        b=enlo1EiLL1oH5OqgpDpTUn3AIgAOCWVpF50ao8K2Z7b/Y5Jig2Zag/rKKAyuYVJJ0E
         KeJpwKvRtTJc/1rZBxHLG88I4IP77oUiaS/HV61A4QwSehulAXvEKpRp766zasLfRkZx
         cEXjiZNTx8E9uODbL1w7aMUUtrtpcp1WIEJFjIW7RhS/e3kBdoswsuYF8P0qveqj6p4z
         qFmZrXg9M7rb7vth9D5Q/bf/cB8PIgDY86KtSKjSUNWy1b7S9wstLUOxLXH63Wemrxj5
         P2gYLFhCJ2lHfAtImcE96qS7bf/P0Aawoozc9QwEmdkiQfCsId2nDqwnpKjFVPE8oNDn
         Gs9g==
X-Gm-Message-State: AOJu0Yw596ssncg9ZZZIU5hiDx2HE7kAuBNRoK4ploSmF7p1oI2b/UGu
	pm+KLxM2kwQiWkHdMonWaMfZKizkzI0zkKawncUJyv9P4toF0O5nCiwxyOKhwOdUBzJpWc4rde6
	Ga3f2w7M=
X-Gm-Gg: ATEYQzwVx2hSTrbISJvtY8HAm1WEEyf5u4t9QyEXQ/OBOZxhOfnxTYXv6LHY1uwieiw
	gG6hCR5+d4xbPyXkE89ibyWmUio8c1eXsbtpCV7DLwNqZZx6YxSe5PMoyjx9PvDU2H2b/iZRCfq
	zw09rgLFoM3A5Xvex1b74BPvD2UAGoRaMvdSSYIoVH6FCB2K8t5AL45FUhYU0TBllGLVdQ8m/Y3
	rMDVxzJMJs7SpN40KWWHSwRBRqaS6GHor0Dt8pqsItpPZLQip7gyrKuB6eZ4QcoOkJtlDajHvtd
	iRYWHY2qw+QRcr2fvL68qCp8Hrnu+nV0L2JH6sjVLPcgpoE8pGz7ig3pX6ntJPyYhe8OJXdjaAN
	2M1UwtCtjQcyA83M1bF0diMT3bV+Hm+/srbzCZ2JTHbZ24s4My+SfZ7vmU4xC4f8nErfY/wiJlv
	G++o/Xv+k07QCJ2g4c15XEP1Vd
X-Received: by 2002:a05:690e:4298:10b0:64c:20f7:b01d with SMTP id 956f58d0204a3-64cc2250707mr4439127d50.55.1772286858768;
        Sat, 28 Feb 2026 05:54:18 -0800 (PST)
Received: from localhost ([2803:2d60:1107:87f:6ab5:5dce:75b0:ccfd])
        by smtp.gmail.com with ESMTPSA id 956f58d0204a3-64cb75ae57esm3442935d50.9.2026.02.28.05.54.17
        (version=TLS1_3 cipher=TLS_AES_256_GCM_SHA384 bits=256/256);
        Sat, 28 Feb 2026 05:54:18 -0800 (PST)
From: Peter Sanchez <peter@netlandish.com>
To: ~netlandish/links-dev@lists.code.netlandish.com
Cc: Peter Sanchez <peter@netlandish.com>
Subject: [PATCH links] backend: adding bluemonday for proper HTML sanitizing.
Date: Sat, 28 Feb 2026 07:54:11 -0600
Message-ID: <20260228135416.23321-1-peter@netlandish.com>
X-Mailer: git-send-email 2.52.0
MIME-Version: 1.0
Content-Transfer-Encoding: 8bit

Changelog-fixed: Issue when sanitizing user input that has valid
 characters that would be stripped as html tags.
---
 api/graph/schema.resolvers.go |  8 ++++----
 go.mod                        |  3 +++
 go.sum                        |  5 +++++
 helpers.go                    | 13 +++++++------
 4 files changed, 19 insertions(+), 10 deletions(-)

diff --git a/api/graph/schema.resolvers.go b/api/graph/schema.resolvers.go
index bf295fd..120051b 100644
--- a/api/graph/schema.resolvers.go
+++ b/api/graph/schema.resolvers.go
@@ -42,7 +42,7 @@ import (
 	auditlog "netlandish.com/x/gobwebs-auditlog"
 	oauth2 "netlandish.com/x/gobwebs-oauth2"
 	gaccounts "netlandish.com/x/gobwebs/accounts"
-	gcore "netlandish.com/x/gobwebs/core"
+	"github.com/microcosm-cc/bluemonday"
 	"netlandish.com/x/gobwebs/crypto"
 	"netlandish.com/x/gobwebs/database"
 	"netlandish.com/x/gobwebs/email"
@@ -569,7 +569,7 @@ func (r *mutationResolver) AddLink(ctx context.Context, input *model.LinkInput)
 		Hash:       ksuid.New().String(),
 	}
 	if input.Description != nil {
-		OrgLink.Description = gcore.StripHtmlTags(*input.Description)
+		OrgLink.Description = bluemonday.StrictPolicy().Sanitize(*input.Description)
 	}
 
 	err = OrgLink.Store(ctx)
@@ -765,7 +765,7 @@ func (r *mutationResolver) UpdateLink(ctx context.Context, input *model.UpdateLi
 		orgLink.Title = *input.Title
 	}
 	if input.Description != nil {
-		orgLink.Description = gcore.StripHtmlTags(*input.Description)
+		orgLink.Description = bluemonday.StrictPolicy().Sanitize(*input.Description)
 	}
 
 	if input.Unread != nil {
@@ -1074,7 +1074,7 @@ func (r *mutationResolver) AddNote(ctx context.Context, input *model.NoteInput)
 	OrgLinkNote := &models.OrgLink{
 		Title:       input.Title,
 		OrgID:       org.ID,
-		Description: gcore.StripHtmlTags(input.Description),
+		Description: bluemonday.StrictPolicy().Sanitize(input.Description),
 		BaseURLID:   BaseURL.ID,
 		Visibility:  string(input.Visibility),
 		Starred:     input.Starred,
diff --git a/go.mod b/go.mod
index 1bea701..d74e868 100644
--- a/go.mod
+++ b/go.mod
@@ -17,6 +17,7 @@ require (
 	github.com/lib/pq v1.10.9
 	github.com/mattermost/mattermost-plugin-apps v1.1.0
 	github.com/mattermost/mattermost-server/v6 v6.6.0
+	github.com/microcosm-cc/bluemonday v1.0.27
 	github.com/oschwald/geoip2-golang v1.9.0
 	github.com/segmentio/ksuid v1.0.4
 	github.com/shopspring/decimal v1.2.0
@@ -54,6 +55,7 @@ require (
 	github.com/alecthomas/chroma/v2 v2.14.0 // indirect
 	github.com/alexedwards/argon2id v1.0.0 // indirect
 	github.com/aws/aws-sdk-go v1.54.18 // indirect
+	github.com/aymerick/douceur v0.2.0 // indirect
 	github.com/beorn7/perks v1.0.1 // indirect
 	github.com/bgentry/go-netrc v0.0.0-20140422174119-9fd32a8b3d3d // indirect
 	github.com/blang/semver v3.5.1+incompatible // indirect
@@ -89,6 +91,7 @@ require (
 	github.com/google/go-cmp v0.6.0 // indirect
 	github.com/google/uuid v1.6.0 // indirect
 	github.com/googleapis/gax-go/v2 v2.1.1 // indirect
+	github.com/gorilla/css v1.0.1 // indirect
 	github.com/gorilla/mux v1.8.0 // indirect
 	github.com/gorilla/websocket v1.5.0 // indirect
 	github.com/graph-gophers/graphql-go v1.3.0 // indirect
diff --git a/go.sum b/go.sum
index d2091e0..548627e 100644
--- a/go.sum
+++ b/go.sum
@@ -232,6 +232,7 @@ github.com/aws/aws-sdk-go-v2/service/sts v1.6.1/go.mod h1:hLZ/AnkIKHLuPGjEiyghNE
 github.com/aws/aws-sdk-go-v2/service/sts v1.7.2/go.mod h1:8EzeIqfWt2wWT4rJVu3f21TfrhJ8AEMzVybRNSb/b4g=
 github.com/aws/smithy-go v1.7.0/go.mod h1:SObp3lf9smib00L/v3U2eAKG8FyQ7iLrJnQiAmR5n+E=
 github.com/aws/smithy-go v1.8.0/go.mod h1:SObp3lf9smib00L/v3U2eAKG8FyQ7iLrJnQiAmR5n+E=
+github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
 github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
 github.com/aymerick/raymond v2.0.3-0.20180322193309-b565731e1464+incompatible/go.mod h1:osfaiScAUVup+UC9Nfq76eWqDhXlp+4UYaA8uhTBO6g=
 github.com/beevik/etree v1.1.0/go.mod h1:r8Aw8JqVegEf0w2fDnATrX9VpkMcyFeM0FhwO62wh+A=
@@ -775,6 +776,8 @@ github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORR
 github.com/gopherjs/gopherjs v0.0.0-20211111143520-d0d5ecc1a356/go.mod h1:cz9oNYuRUWGdHmLF2IodMLkAhcPtXeULvcBNagUrxTI=
 github.com/gopherjs/gopherjs v0.0.0-20220221023154-0b2280d3ff96/go.mod h1:pRRIvn/QzFLrKfvEz3qUuEhtE/zLCWfreZ6J5gM2i+k=
 github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c=
+github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
+github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
 github.com/gorilla/handlers v0.0.0-20150720190736-60c7bfde3e33/go.mod h1:Qkdc/uu4tH4g6mTK6auzZ766c4CA0Ng8+o/OAirnOIQ=
 github.com/gorilla/handlers v1.4.2/go.mod h1:Qkdc/uu4tH4g6mTK6auzZ766c4CA0Ng8+o/OAirnOIQ=
 github.com/gorilla/handlers v1.5.1/go.mod h1:t8XrUpc4KVXb7HGyJ4/cEnwQiaxrX/hz1Zv/4g96P1Q=
@@ -1136,6 +1139,8 @@ github.com/mholt/archiver/v3 v3.5.1/go.mod h1:e3dqJ7H78uzsRSEACH1joayhuSyhnonssn
 github.com/microcosm-cc/bluemonday v1.0.1/go.mod h1:hsXNsILzKxV+sX77C5b8FSuKF00vh2OMYv+xgHpAMF4=
 github.com/microcosm-cc/bluemonday v1.0.2/go.mod h1:iVP4YcDBq+n/5fb23BhYFvIMq/leAFZyRl6bYmGDlGc=
 github.com/microcosm-cc/bluemonday v1.0.18/go.mod h1:Z0r70sCuXHig8YpBzCc5eGHAap2K7e/u082ZUpDRRqM=
+github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
+github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
 github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg=
 github.com/miekg/dns v1.1.26/go.mod h1:bPDLeHnStXmXAq1m/Ch/hvfNHr14JKNPMBo3VZKjuso=
 github.com/miekg/dns v1.1.41/go.mod h1:p6aan82bvRIyn+zDIv9xYNUpwa73JcSh9BKwknJysuI=
diff --git a/helpers.go b/helpers.go
index 09524fe..eb77b00 100644
--- a/helpers.go
+++ b/helpers.go
@@ -26,6 +26,7 @@ import (
 	"unicode/utf8"
 
 	"git.sr.ht/~emersion/gqlclient"
+	"github.com/microcosm-cc/bluemonday"
 	"github.com/99designs/gqlgen/graphql"
 	sq "github.com/Masterminds/squirrel"
 	"github.com/labstack/echo/v4"
@@ -346,33 +347,33 @@ func extract(resp io.Reader) *models.HTMLMeta {
 			if t.Data == "meta" {
 				desc, ok := extractMetaProperty(t, "description")
 				if ok {
-					hm.Description = SanitizeUTF8(core.StripHtmlTags(desc))
+					hm.Description = SanitizeUTF8(bluemonday.StrictPolicy().Sanitize(desc))
 				}
 
 				ogTitle, ok := extractMetaProperty(t, "og:title")
 				if ok {
-					hm.Title = SanitizeUTF8(core.StripHtmlTags(ogTitle))
+					hm.Title = SanitizeUTF8(bluemonday.StrictPolicy().Sanitize(ogTitle))
 				}
 
 				ogDesc, ok := extractMetaProperty(t, "og:description")
 				if ok {
-					hm.Description = SanitizeUTF8(core.StripHtmlTags(ogDesc))
+					hm.Description = SanitizeUTF8(bluemonday.StrictPolicy().Sanitize(ogDesc))
 				}
 
 				ogImage, ok := extractMetaProperty(t, "og:image")
 				if ok {
-					hm.Image = SanitizeUTF8(core.StripHtmlTags(ogImage))
+					hm.Image = SanitizeUTF8(bluemonday.StrictPolicy().Sanitize(ogImage))
 				}
 
 				ogSiteName, ok := extractMetaProperty(t, "og:site_name")
 				if ok {
-					hm.SiteName = SanitizeUTF8(core.StripHtmlTags(ogSiteName))
+					hm.SiteName = SanitizeUTF8(bluemonday.StrictPolicy().Sanitize(ogSiteName))
 				}
 			}
 		case html.TextToken:
 			if titleFound {
 				t := z.Token()
-				hm.Title = SanitizeUTF8(core.StripHtmlTags(t.Data))
+				hm.Title = SanitizeUTF8(bluemonday.StrictPolicy().Sanitize(t.Data))
 				titleFound = false
 			}
 		}
-- 
2.52.0

