Skip to content
Projects
Groups
Snippets
Help
Loading...
Help
Submit feedback
Contribute to GitLab
Sign in
Toggle navigation
C
canifa_note
Project
Project
Details
Activity
Releases
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
Vũ Hoàng Anh
canifa_note
Commits
7e21b728
Commit
7e21b728
authored
Apr 08, 2026
by
boojack
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
fix: harden memo content iframe and HTML sanitization
parent
2d682ae1
Changes
5
Show whitespace changes
Inline
Side-by-side
Showing
5 changed files
with
116 additions
and
46 deletions
+116
-46
TrustedIframe.ts
web/src/components/MemoContent/TrustedIframe.ts
+10
-0
constants.ts
web/src/components/MemoContent/constants.ts
+36
-45
index.tsx
web/src/components/MemoContent/index.tsx
+2
-0
index.css
web/src/index.css
+1
-1
memo-content-security.test.mjs
web/tests/memo-content-security.test.mjs
+67
-0
No files found.
web/src/components/MemoContent/TrustedIframe.ts
0 → 100644
View file @
7e21b728
import
{
createElement
}
from
"react"
;
import
{
isTrustedIframeSrc
}
from
"./constants"
;
export
const
TrustedIframe
=
(
props
:
React
.
ComponentProps
<
"iframe"
>
)
=>
{
if
(
typeof
props
.
src
!==
"string"
||
!
isTrustedIframeSrc
(
props
.
src
))
{
return
null
;
}
return
createElement
(
"iframe"
,
props
);
};
web/src/components/MemoContent/constants.ts
View file @
7e21b728
...
...
@@ -17,13 +17,30 @@ export const COMPACT_STATES: Record<"ALL" | "SNIPPET", { textKey: string; next:
SNIPPET
:
{
textKey
:
"memo.show-less"
,
next
:
"ALL"
},
};
const
TRUSTED_IFRAME_SRC_PATTERNS
=
[
/^https:
\/\/
www
\.
youtube
\.
com
\/
embed
\/[^
?#
]
+
(?:\?
.*
)?
$/i
,
/^https:
\/\/
www
\.
youtube-nocookie
\.
com
\/
embed
\/[^
?#
]
+
(?:\?
.*
)?
$/i
,
/^https:
\/\/
player
\.
vimeo
\.
com
\/
video
\/[^
?#
]
+
(?:\?
.*
)?
$/i
,
/^https:
\/\/
open
\.
spotify
\.
com
\/
embed
\/[^
?#
]
+
(?:\?
.*
)?
$/i
,
/^https:
\/\/
w
\.
soundcloud
\.
com
\/
player
\/?(?:\?
.*
)?
$/i
,
/^https:
\/\/
www
\.
loom
\.
com
\/
embed
\/[^
?#
]
+
(?:\?
.*
)?
$/i
,
/^https:
\/\/
www
\.
google
\.
com
\/
maps
\/
embed
(?:\/[^
?#
]
*
)?(?:\?
.*
)?
$/i
,
/^https:
\/\/(?:
app
\.)?
diagrams
\.
net
\/(?:[^
?#
]
+
)?(?:\?
.*
)?
$/i
,
/^https:
\/\/(?:
www
\.)?
draw
\.
io
\/(?:[^
?#
]
+
)?(?:\?
.*
)?
$/i
,
];
const
KATEX_INLINE_CLASS_NAMES
=
[
"language-math"
,
"math-inline"
]
as
const
;
const
KATEX_BLOCK_CLASS_NAMES
=
[
"language-math"
,
"math-display"
]
as
const
;
const
SPAN_CLASS_NAMES
=
[
"mention"
,
"tag"
]
as
const
;
export
const
isTrustedIframeSrc
=
(
src
:
string
):
boolean
=>
TRUSTED_IFRAME_SRC_PATTERNS
.
some
((
pattern
)
=>
pattern
.
test
(
src
));
/**
* Sanitization schema for markdown HTML content.
* Extends the default schema to allow:
* - KaTeX math rendering elements (MathML tags)
* - KaTeX-specific attributes (className, style, aria-*, data-*)
* - Safe HTML elements for rich content
* - iframe embeds for trusted video providers (YouTube, Vimeo, etc.)
* - KaTeX marker classes used before trusted KaTeX rendering runs
* - Mention/tag metadata generated by trusted remark plugins
* - iframe embeds only from trusted video providers
*
* This prevents XSS attacks while preserving math rendering functionality.
*/
...
...
@@ -31,50 +48,24 @@ export const SANITIZE_SCHEMA = {
...
defaultSchema
,
attributes
:
{
...
defaultSchema
.
attributes
,
div
:
[...(
defaultSchema
.
attributes
?.
div
||
[]),
"className"
],
img
:
[...(
defaultSchema
.
attributes
?.
img
||
[]),
"height"
,
"width"
],
span
:
[...(
defaultSchema
.
attributes
?.
span
||
[]),
"className"
,
"style"
,
[
"aria*"
],
[
"data*"
]],
// iframe attributes for video embeds
iframe
:
[
"src"
,
"width"
,
"height"
,
"frameborder"
,
"allowfullscreen"
,
"allow"
,
"title"
,
"referrerpolicy"
,
"loading"
],
// MathML attributes for KaTeX rendering
annotation
:
[
"encoding"
],
math
:
[
"xmlns"
],
mi
:
[],
mn
:
[],
mo
:
[],
mrow
:
[],
mspace
:
[],
mstyle
:
[],
msup
:
[],
msub
:
[],
msubsup
:
[],
mfrac
:
[],
mtext
:
[],
semantics
:
[],
},
tagNames
:
[
...(
defaultSchema
.
tagNames
||
[]),
// iframe for video embeds
"iframe"
,
// MathML elements for KaTeX math rendering
"math"
,
"annotation"
,
"semantics"
,
"mi"
,
"mn"
,
"mo"
,
"mrow"
,
"mspace"
,
"mstyle"
,
"msup"
,
"msub"
,
"msubsup"
,
"mfrac"
,
"mtext"
,
code
:
[...(
defaultSchema
.
attributes
?.
code
||
[]),
[
"className"
,
...
KATEX_INLINE_CLASS_NAMES
,
...
KATEX_BLOCK_CLASS_NAMES
]],
span
:
[...(
defaultSchema
.
attributes
?.
span
||
[]),
[
"className"
,
...
SPAN_CLASS_NAMES
],
[
"aria*"
],
[
"data*"
]],
iframe
:
[
[
"src"
,
...
TRUSTED_IFRAME_SRC_PATTERNS
],
"width"
,
"height"
,
"frameborder"
,
"allowfullscreen"
,
"allow"
,
"title"
,
"referrerpolicy"
,
"loading"
,
],
},
tagNames
:
[...(
defaultSchema
.
tagNames
||
[]),
"iframe"
],
protocols
:
{
...
defaultSchema
.
protocols
,
// Allow HTTPS iframe embeds only for security
iframe
:
{
src
:
[
"https"
]
},
src
:
[
"https"
],
},
};
web/src/components/MemoContent/index.tsx
View file @
7e21b728
...
...
@@ -25,6 +25,7 @@ import { Blockquote, Heading, HorizontalRule, Image, InlineCode, Link, List, Lis
import
{
Table
,
TableBody
,
TableCell
,
TableHead
,
TableHeaderCell
,
TableRow
}
from
"./Table"
;
import
{
Tag
}
from
"./Tag"
;
import
{
TaskListItem
}
from
"./TaskListItem"
;
import
{
TrustedIframe
}
from
"./TrustedIframe"
;
import
type
{
MemoContentProps
}
from
"./types"
;
function
getMentionUsername
(
node
:
Element
,
children
?:
React
.
ReactNode
):
string
{
...
...
@@ -148,6 +149,7 @@ const MemoContent = (props: MemoContentProps) => {
// Inline elements
a
:
({
children
,
...
props
})
=>
<
Link
{
...
props
}
>
{
children
}
</
Link
>,
code
:
({
children
,
...
props
})
=>
<
InlineCode
{
...
props
}
>
{
children
}
</
InlineCode
>,
iframe
:
TrustedIframe
as
React
.
ComponentType
<
React
.
ComponentProps
<
"iframe"
>>
,
img
:
({
...
props
})
=>
<
Image
{
...
props
}
/>,
// Code blocks
pre
:
CodeBlock
,
...
...
web/src/index.css
View file @
7e21b728
...
...
@@ -18,7 +18,7 @@
* Embedded Content
* ======================================== */
/* iframes
(e.g., YouTube embeds, maps)
*/
/* iframes
from trusted embed providers
*/
iframe
{
max-width
:
100%
;
border-radius
:
0.5rem
;
...
...
web/tests/memo-content-security.test.mjs
0 → 100644
View file @
7e21b728
import
assert
from
"node:assert/strict"
;
import
test
from
"node:test"
;
import
React
from
"react"
;
import
{
renderToStaticMarkup
}
from
"react-dom/server"
;
import
ReactMarkdown
from
"react-markdown"
;
import
rehypeKatex
from
"rehype-katex"
;
import
rehypeRaw
from
"rehype-raw"
;
import
rehypeSanitize
from
"rehype-sanitize"
;
import
remarkMath
from
"remark-math"
;
import
{
SANITIZE_SCHEMA
,
isTrustedIframeSrc
}
from
"../src/components/MemoContent/constants.ts"
;
const
TrustedIframe
=
(
props
)
=>
{
if
(
typeof
props
.
src
!==
"string"
||
!
isTrustedIframeSrc
(
props
.
src
))
{
return
null
;
}
return
React
.
createElement
(
"iframe"
,
props
);
};
const
renderMemoContent
=
(
content
)
=>
renderToStaticMarkup
(
React
.
createElement
(
ReactMarkdown
,
{
children
:
content
,
remarkPlugins
:
[
remarkMath
],
rehypePlugins
:
[
rehypeRaw
,
[
rehypeSanitize
,
SANITIZE_SCHEMA
],
[
rehypeKatex
,
{
throwOnError
:
false
,
strict
:
false
}]],
components
:
{
iframe
:
TrustedIframe
,
},
}),
);
test
(
"strips user-controlled inline styles from raw HTML spans"
,
()
=>
{
const
html
=
renderMemoContent
(
'<span style="position:fixed;inset:0;z-index:99999">overlay</span>'
);
assert
.
match
(
html
,
/<span>overlay<
\/
span>/
);
assert
.
doesNotMatch
(
html
,
/style=/
);
assert
.
doesNotMatch
(
html
,
/position:fixed/
);
});
test
(
"still renders KaTeX output after sanitizing math marker classes"
,
()
=>
{
const
html
=
renderMemoContent
(
"$L$"
);
assert
.
match
(
html
,
/class="katex"/
);
assert
.
match
(
html
,
/class="katex-html"/
);
});
test
(
"allows trusted iframe providers only"
,
()
=>
{
assert
.
equal
(
isTrustedIframeSrc
(
"https://www.youtube.com/embed/abc123"
),
true
);
assert
.
equal
(
isTrustedIframeSrc
(
"https://www.youtube-nocookie.com/embed/abc123?si=test"
),
true
);
assert
.
equal
(
isTrustedIframeSrc
(
"https://player.vimeo.com/video/123456"
),
true
);
assert
.
equal
(
isTrustedIframeSrc
(
"https://open.spotify.com/embed/track/123456"
),
true
);
assert
.
equal
(
isTrustedIframeSrc
(
"https://w.soundcloud.com/player/?url=https%3A//api.soundcloud.com/tracks/123456"
),
true
);
assert
.
equal
(
isTrustedIframeSrc
(
"https://www.loom.com/embed/123456"
),
true
);
assert
.
equal
(
isTrustedIframeSrc
(
"https://www.google.com/maps/embed?pb=test"
),
true
);
assert
.
equal
(
isTrustedIframeSrc
(
"https://app.diagrams.net/?embed=1"
),
true
);
assert
.
equal
(
isTrustedIframeSrc
(
"https://www.draw.io/?embed=1"
),
true
);
assert
.
equal
(
isTrustedIframeSrc
(
"https://evil.example/embed/abc123"
),
false
);
});
test
(
"drops untrusted iframe embeds during rendering"
,
()
=>
{
const
trusted
=
renderMemoContent
(
'<iframe src="https://www.youtube.com/embed/abc123" title="demo"></iframe>'
);
const
untrusted
=
renderMemoContent
(
'<iframe src="https://evil.example/embed/abc123" title="demo"></iframe>'
);
assert
.
match
(
trusted
,
/<iframe/
);
assert
.
match
(
trusted
,
/youtube
\.
com
\/
embed
\/
abc123/
);
assert
.
doesNotMatch
(
untrusted
,
/<iframe/
);
});
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment