@@ -66,7 +66,6 @@ const createDocumentMutation = graphql`
|
|||||||
|
|
||||||
const documentSchema = z.object({
|
const documentSchema = z.object({
|
||||||
title: z.string().min(1, "Title is required"),
|
title: z.string().min(1, "Title is required"),
|
||||||
approverIds: z.array(z.string()).min(1, "At least one approver is required"),
|
|
||||||
documentType: z.enum(["OTHER", "GOVERNANCE", "POLICY", "PROCEDURE", "PLAN", "REGISTER", "RECORD", "REPORT", "TEMPLATE"]),
|
documentType: z.enum(["OTHER", "GOVERNANCE", "POLICY", "PROCEDURE", "PLAN", "REGISTER", "RECORD", "REPORT", "TEMPLATE"]),
|
||||||
classification: z.enum(["PUBLIC", "INTERNAL", "CONFIDENTIAL", "SECRET"]),
|
classification: z.enum(["PUBLIC", "INTERNAL", "CONFIDENTIAL", "SECRET"]),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -22,6 +22,9 @@ export const LinkExtension = Link.extend({
|
|||||||
view.dom.classList.remove("pointer-on-hovered-link");
|
view.dom.classList.remove("pointer-on-hovered-link");
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
blur: (view) => {
|
||||||
|
view.dom.classList.remove("pointer-on-hovered-link");
|
||||||
|
},
|
||||||
},
|
},
|
||||||
handleClick: (_view, _, event) => {
|
handleClick: (_view, _, event) => {
|
||||||
const { ctrlKey, metaKey } = event; // Check for Ctrl (Windows) or Cmd (Mac)
|
const { ctrlKey, metaKey } = event; // Check for Ctrl (Windows) or Cmd (Mac)
|
||||||
|
|||||||
@@ -70,6 +70,11 @@ export function moveColumn(
|
|||||||
const table = editor.state.doc.nodeAt(tableNodePos);
|
const table = editor.state.doc.nodeAt(tableNodePos);
|
||||||
if (!table) return;
|
if (!table) return;
|
||||||
|
|
||||||
|
const map = TableMap.get(table);
|
||||||
|
if (new Set(map.map).size < map.map.length) return;
|
||||||
|
if (fromCol < 0 || fromCol >= map.width || toCol < 0 || toCol >= map.width)
|
||||||
|
return;
|
||||||
|
|
||||||
const rows: PMNode[] = [];
|
const rows: PMNode[] = [];
|
||||||
table.forEach((row) => {
|
table.forEach((row) => {
|
||||||
const cells: PMNode[] = [];
|
const cells: PMNode[] = [];
|
||||||
|
|||||||
@@ -129,8 +129,12 @@ export function TableColumnMenuContent({
|
|||||||
.chain()
|
.chain()
|
||||||
.focus()
|
.focus()
|
||||||
.command(({ tr }) => {
|
.command(({ tr }) => {
|
||||||
|
const seen = new Set<number>();
|
||||||
for (let row = map.height - 1; row >= 0; row--) {
|
for (let row = map.height - 1; row >= 0; row--) {
|
||||||
const cellOffset = map.map[row * map.width + colIndex];
|
const cellOffset = map.map[row * map.width + colIndex];
|
||||||
|
if (seen.has(cellOffset)) continue;
|
||||||
|
seen.add(cellOffset);
|
||||||
|
|
||||||
const cell = table.nodeAt(cellOffset);
|
const cell = table.nodeAt(cellOffset);
|
||||||
if (!cell) continue;
|
if (!cell) continue;
|
||||||
|
|
||||||
@@ -211,13 +215,17 @@ export function TableColumnMenuContent({
|
|||||||
const lastCellPos
|
const lastCellPos
|
||||||
= map.map[(map.height - 1) * map.width + colIndex] + tableStart + 1;
|
= map.map[(map.height - 1) * map.width + colIndex] + tableStart + 1;
|
||||||
|
|
||||||
const $anchor = editor.state.doc.resolve(firstCellPos);
|
editor
|
||||||
const $head = editor.state.doc.resolve(lastCellPos);
|
.chain()
|
||||||
|
.focus()
|
||||||
editor.view.dispatch(
|
.command(({ tr }) => {
|
||||||
editor.state.tr.setSelection(new CellSelection($anchor, $head)),
|
const $anchor = tr.doc.resolve(firstCellPos);
|
||||||
);
|
const $head = tr.doc.resolve(lastCellPos);
|
||||||
editor.commands.deleteSelection();
|
tr.setSelection(new CellSelection($anchor, $head));
|
||||||
|
return true;
|
||||||
|
})
|
||||||
|
.deleteSelection()
|
||||||
|
.run();
|
||||||
} catch {
|
} catch {
|
||||||
// table may have changed
|
// table may have changed
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,6 +70,11 @@ export function moveRow(
|
|||||||
const table = editor.state.doc.nodeAt(tableNodePos);
|
const table = editor.state.doc.nodeAt(tableNodePos);
|
||||||
if (!table) return;
|
if (!table) return;
|
||||||
|
|
||||||
|
const map = TableMap.get(table);
|
||||||
|
if (new Set(map.map).size < map.map.length) return;
|
||||||
|
if (fromRow < 0 || fromRow >= map.height || toRow < 0 || toRow >= map.height)
|
||||||
|
return;
|
||||||
|
|
||||||
const rows: PMNode[] = [];
|
const rows: PMNode[] = [];
|
||||||
table.forEach(row => rows.push(row));
|
table.forEach(row => rows.push(row));
|
||||||
const [moved] = rows.splice(fromRow, 1);
|
const [moved] = rows.splice(fromRow, 1);
|
||||||
|
|||||||
@@ -209,13 +209,17 @@ export function TableRowMenuContent({
|
|||||||
const lastCellPos
|
const lastCellPos
|
||||||
= map.map[rowIndex * map.width + (map.width - 1)] + tableStart;
|
= map.map[rowIndex * map.width + (map.width - 1)] + tableStart;
|
||||||
|
|
||||||
const $anchor = editor.state.doc.resolve(firstCellPos);
|
editor
|
||||||
const $head = editor.state.doc.resolve(lastCellPos);
|
.chain()
|
||||||
|
.focus()
|
||||||
editor.view.dispatch(
|
.command(({ tr }) => {
|
||||||
editor.state.tr.setSelection(new CellSelection($anchor, $head)),
|
const $anchor = tr.doc.resolve(firstCellPos);
|
||||||
);
|
const $head = tr.doc.resolve(lastCellPos);
|
||||||
editor.commands.deleteSelection();
|
tr.setSelection(new CellSelection($anchor, $head));
|
||||||
|
return true;
|
||||||
|
})
|
||||||
|
.deleteSelection()
|
||||||
|
.run();
|
||||||
} catch {
|
} catch {
|
||||||
// table may have changed
|
// table may have changed
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -153,8 +153,6 @@ func (udvr *UpdateDocumentVersionRequest) Validate() error {
|
|||||||
v.Check(
|
v.Check(
|
||||||
udvr.Content,
|
udvr.Content,
|
||||||
"content",
|
"content",
|
||||||
validator.Required(),
|
|
||||||
validator.NotEmpty(),
|
|
||||||
validator.MaxLen(documentMaxLength),
|
validator.MaxLen(documentMaxLength),
|
||||||
validator.ProseMirrorDocumentContent(),
|
validator.ProseMirrorDocumentContent(),
|
||||||
)
|
)
|
||||||
@@ -783,17 +781,15 @@ func (s *DocumentService) UpdateVersion(
|
|||||||
return &ErrDocumentVersionNotDraft{}
|
return &ErrDocumentVersionNotDraft{}
|
||||||
}
|
}
|
||||||
|
|
||||||
var content string
|
|
||||||
if req.Content != nil {
|
if req.Content != nil {
|
||||||
var err error
|
content, err := prosemirror.SanitizeDocumentJSON(*req.Content)
|
||||||
content, err = prosemirror.SanitizeDocumentJSON(*req.Content)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("cannot sanitize document content: %w", err)
|
return fmt.Errorf("cannot sanitize document content: %w", err)
|
||||||
}
|
}
|
||||||
|
documentVersion.Content = content
|
||||||
}
|
}
|
||||||
|
|
||||||
documentVersion.Title = document.Title
|
documentVersion.Title = document.Title
|
||||||
documentVersion.Content = content
|
|
||||||
if req.Classification != nil {
|
if req.Classification != nil {
|
||||||
documentVersion.Classification = *req.Classification
|
documentVersion.Classification = *req.Classification
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ func renderNode(buf *bytes.Buffer, n Node) error {
|
|||||||
return fmt.Errorf("cannot render image node: %w", err)
|
return fmt.Errorf("cannot render image node: %w", err)
|
||||||
}
|
}
|
||||||
buf.WriteString("<img")
|
buf.WriteString("<img")
|
||||||
writeAttr(buf, "src", attrs.Src)
|
writeAttr(buf, "src", safeImageSrc(attrs.Src))
|
||||||
if attrs.Alt != nil {
|
if attrs.Alt != nil {
|
||||||
writeAttr(buf, "alt", *attrs.Alt)
|
writeAttr(buf, "alt", *attrs.Alt)
|
||||||
}
|
}
|
||||||
@@ -274,21 +274,36 @@ const linkRelBlankTargetDefault = "noopener noreferrer"
|
|||||||
|
|
||||||
// linkRelToEmit returns the rel attribute value for a link mark, or empty when
|
// linkRelToEmit returns the rel attribute value for a link mark, or empty when
|
||||||
// the attribute should be omitted. When target opens a new browsing context
|
// the attribute should be omitted. When target opens a new browsing context
|
||||||
// (_blank) and the document provides no rel, browsers would grant the opened
|
// (_blank), noopener is always injected to prevent the opened page from
|
||||||
// page access to window.opener unless noopener is set.
|
// accessing window.opener, even when the document supplies a custom rel.
|
||||||
func linkRelToEmit(attrs LinkAttrs) string {
|
func linkRelToEmit(attrs LinkAttrs) string {
|
||||||
|
blanksTarget := attrs.Target != nil &&
|
||||||
|
strings.EqualFold(strings.TrimSpace(*attrs.Target), "_blank")
|
||||||
|
|
||||||
if attrs.Rel != nil {
|
if attrs.Rel != nil {
|
||||||
if s := strings.TrimSpace(*attrs.Rel); s != "" {
|
if s := strings.TrimSpace(*attrs.Rel); s != "" {
|
||||||
|
if blanksTarget {
|
||||||
|
return ensureNoopener(s)
|
||||||
|
}
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if attrs.Target == nil {
|
|
||||||
return ""
|
if blanksTarget {
|
||||||
|
return linkRelBlankTargetDefault
|
||||||
}
|
}
|
||||||
if !strings.EqualFold(strings.TrimSpace(*attrs.Target), "_blank") {
|
return ""
|
||||||
return ""
|
}
|
||||||
|
|
||||||
|
// ensureNoopener returns rel unchanged when it already contains the noopener
|
||||||
|
// token (case-insensitive check). Otherwise it appends " noopener".
|
||||||
|
func ensureNoopener(rel string) string {
|
||||||
|
for tok := range strings.FieldsSeq(rel) {
|
||||||
|
if strings.EqualFold(tok, "noopener") {
|
||||||
|
return rel
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return linkRelBlankTargetDefault
|
return rel + " noopener"
|
||||||
}
|
}
|
||||||
|
|
||||||
func writeAttr(buf *bytes.Buffer, name, value string) {
|
func writeAttr(buf *bytes.Buffer, name, value string) {
|
||||||
@@ -334,3 +349,36 @@ func safeLinkHref(href string) string {
|
|||||||
}
|
}
|
||||||
return href
|
return href
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// safeImageSrc returns a value safe to use in an HTML img src attribute.
|
||||||
|
// Only http, https, and data schemes are permitted; everything else
|
||||||
|
// (javascript:, vbscript:, etc.) is replaced with an empty string so the
|
||||||
|
// image simply does not render.
|
||||||
|
func safeImageSrc(src string) string {
|
||||||
|
src = strings.TrimSpace(src)
|
||||||
|
if src == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(src, "/") {
|
||||||
|
if len(src) > 1 && (src[1] == '/' || src[1] == '\\') {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return src
|
||||||
|
}
|
||||||
|
u, err := url.Parse(src)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if u.Scheme != "" {
|
||||||
|
switch strings.ToLower(u.Scheme) {
|
||||||
|
case "http", "https", "data":
|
||||||
|
return src
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if u.Host != "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return src
|
||||||
|
}
|
||||||
|
|||||||
@@ -222,6 +222,26 @@ func TestRenderHTML_LinkBlankTargetDefaultRel(t *testing.T) {
|
|||||||
raw: `{"type":"text","marks":[{"type":"link","attrs":{"href":"https://example.com","target":" _blank ","rel":" "}}],"text":"hi"}`,
|
raw: `{"type":"text","marks":[{"type":"link","attrs":{"href":"https://example.com","target":" _blank ","rel":" "}}],"text":"hi"}`,
|
||||||
want: `<a href="https://example.com" target=" _blank " rel="noopener noreferrer">hi</a>`,
|
want: `<a href="https://example.com" target=" _blank " rel="noopener noreferrer">hi</a>`,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "custom rel without noopener gets noopener appended",
|
||||||
|
raw: `{"type":"text","marks":[{"type":"link","attrs":{"href":"https://example.com","target":"_blank","rel":"nofollow"}}],"text":"hi"}`,
|
||||||
|
want: `<a href="https://example.com" target="_blank" rel="nofollow noopener">hi</a>`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "custom rel already has noopener",
|
||||||
|
raw: `{"type":"text","marks":[{"type":"link","attrs":{"href":"https://example.com","target":"_blank","rel":"noopener nofollow"}}],"text":"hi"}`,
|
||||||
|
want: `<a href="https://example.com" target="_blank" rel="noopener nofollow">hi</a>`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "custom rel with noopener case insensitive",
|
||||||
|
raw: `{"type":"text","marks":[{"type":"link","attrs":{"href":"https://example.com","target":"_blank","rel":"NoOpener"}}],"text":"hi"}`,
|
||||||
|
want: `<a href="https://example.com" target="_blank" rel="NoOpener">hi</a>`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "custom rel without blank target unchanged",
|
||||||
|
raw: `{"type":"text","marks":[{"type":"link","attrs":{"href":"https://example.com","target":"_self","rel":"nofollow"}}],"text":"hi"}`,
|
||||||
|
want: `<a href="https://example.com" target="_self" rel="nofollow">hi</a>`,
|
||||||
|
},
|
||||||
} {
|
} {
|
||||||
t.Run(
|
t.Run(
|
||||||
tc.name,
|
tc.name,
|
||||||
@@ -293,6 +313,47 @@ func TestRenderHTML_Image(t *testing.T) {
|
|||||||
assert.Equal(t, `<img src="https://example.com/img.png" alt="A photo" title="My image">`, got)
|
assert.Equal(t, `<img src="https://example.com/img.png" alt="A photo" title="My image">`, got)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRenderHTML_ImageSanitizesDangerousSrc(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
src string
|
||||||
|
wantSrc string
|
||||||
|
}{
|
||||||
|
{name: "javascript scheme", src: `javascript:alert(1)`, wantSrc: ``},
|
||||||
|
{name: "javascript case insensitive", src: `javaScript:alert(1)`, wantSrc: ``},
|
||||||
|
{name: "vbscript scheme", src: `vbscript:MsgBox("xss")`, wantSrc: ``},
|
||||||
|
{name: "protocol-relative", src: `//evil.example/img.png`, wantSrc: ``},
|
||||||
|
{name: "empty src", src: ``, wantSrc: ``},
|
||||||
|
{name: "https preserved", src: `https://example.com/img.png`, wantSrc: `https://example.com/img.png`},
|
||||||
|
{name: "http preserved", src: `http://example.com/img.png`, wantSrc: `http://example.com/img.png`},
|
||||||
|
{name: "data URI preserved", src: `data:image/png;base64,iVBOR`, wantSrc: `data:image/png;base64,iVBOR`},
|
||||||
|
{name: "absolute path", src: `/images/photo.png`, wantSrc: `/images/photo.png`},
|
||||||
|
{name: "relative path", src: `images/photo.png`, wantSrc: `images/photo.png`},
|
||||||
|
} {
|
||||||
|
t.Run(
|
||||||
|
tc.name,
|
||||||
|
func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
srcJSON, err := json.Marshal(tc.src)
|
||||||
|
require.NoError(t, err)
|
||||||
|
raw := fmt.Sprintf(
|
||||||
|
`{"type":"image","attrs":{"src":%s}}`,
|
||||||
|
string(srcJSON),
|
||||||
|
)
|
||||||
|
var n Node
|
||||||
|
require.NoError(t, json.Unmarshal([]byte(raw), &n))
|
||||||
|
|
||||||
|
got, err := RenderHTML(n)
|
||||||
|
require.NoError(t, err)
|
||||||
|
want := fmt.Sprintf(`<img src="%s">`, html.EscapeString(tc.wantSrc))
|
||||||
|
assert.Equal(t, want, got)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestRenderHTML_MultipleMarks(t *testing.T) {
|
func TestRenderHTML_MultipleMarks(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
@@ -330,15 +330,7 @@ func (c *converter) convertImage(n *ast.Image) ([]Node, error) {
|
|||||||
imgAttrs.Title = &t
|
imgAttrs.Title = &t
|
||||||
}
|
}
|
||||||
|
|
||||||
// Collect alt text from child text nodes.
|
if alt := c.extractText(n); alt != "" {
|
||||||
var altBuf bytes.Buffer
|
|
||||||
for child := n.FirstChild(); child != nil; child = child.NextSibling() {
|
|
||||||
if child.Kind() == ast.KindText {
|
|
||||||
altBuf.Write(child.(*ast.Text).Segment.Value(c.source))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if altBuf.Len() > 0 {
|
|
||||||
alt := altBuf.String()
|
|
||||||
imgAttrs.Alt = &alt
|
imgAttrs.Alt = &alt
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -524,6 +516,22 @@ func (c *converter) convertStrikethrough(n ast.Node) ([]Node, error) {
|
|||||||
return children, nil
|
return children, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// extractText recursively collects the text content of all descendant nodes.
|
||||||
|
func (c *converter) extractText(n ast.Node) string {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
for child := n.FirstChild(); child != nil; child = child.NextSibling() {
|
||||||
|
switch child.Kind() {
|
||||||
|
case ast.KindText:
|
||||||
|
buf.Write(child.(*ast.Text).Segment.Value(c.source))
|
||||||
|
case ast.KindString:
|
||||||
|
buf.Write(child.(*ast.String).Value)
|
||||||
|
default:
|
||||||
|
buf.WriteString(c.extractText(child))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return buf.String()
|
||||||
|
}
|
||||||
|
|
||||||
func copyMarks(marks []Mark) []Mark {
|
func copyMarks(marks []Mark) []Mark {
|
||||||
if len(marks) == 0 {
|
if len(marks) == 0 {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -255,6 +255,25 @@ func TestParseMarkdown_Image(t *testing.T) {
|
|||||||
assert.Equal(t, "title", *attrs.Title)
|
assert.Equal(t, "title", *attrs.Title)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestParseMarkdown_ImageFormattedAltText(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
doc, err := ParseMarkdown("")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, doc.Content, 1)
|
||||||
|
|
||||||
|
p := doc.Content[0]
|
||||||
|
require.Len(t, p.Content, 1)
|
||||||
|
|
||||||
|
img := p.Content[0]
|
||||||
|
assert.Equal(t, NodeImage, img.Type)
|
||||||
|
|
||||||
|
attrs, err := img.ImageAttrs()
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, attrs.Alt)
|
||||||
|
assert.Equal(t, "bold and italic", *attrs.Alt)
|
||||||
|
}
|
||||||
|
|
||||||
func TestParseMarkdown_BulletList(t *testing.T) {
|
func TestParseMarkdown_BulletList(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
@@ -66,6 +66,9 @@ func SanitizeDocumentJSON(s string) (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func sanitizeNode(n *Node) {
|
func sanitizeNode(n *Node) {
|
||||||
|
if n.Type == NodeImage {
|
||||||
|
sanitizeImageNode(n)
|
||||||
|
}
|
||||||
for i := range n.Marks {
|
for i := range n.Marks {
|
||||||
sanitizeLinkMark(&n.Marks[i])
|
sanitizeLinkMark(&n.Marks[i])
|
||||||
}
|
}
|
||||||
@@ -74,6 +77,23 @@ func sanitizeNode(n *Node) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func sanitizeImageNode(n *Node) {
|
||||||
|
attrs, err := n.ImageAttrs()
|
||||||
|
if err != nil {
|
||||||
|
n.Attrs = []byte(`{"src":""}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
attrs.Src = safeImageSrc(attrs.Src)
|
||||||
|
raw, err := json.Marshal(attrs)
|
||||||
|
if err != nil {
|
||||||
|
n.Attrs = []byte(`{"src":""}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
n.Attrs = raw
|
||||||
|
}
|
||||||
|
|
||||||
func sanitizeLinkMark(m *Mark) {
|
func sanitizeLinkMark(m *Mark) {
|
||||||
if m.Type != MarkLink {
|
if m.Type != MarkLink {
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -82,3 +82,37 @@ func TestSanitizeDocumentJSON_PreservesSafeHref(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, "https://example.com", attrs.Href)
|
assert.Equal(t, "https://example.com", attrs.Href)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSanitizeDocumentJSON_ImageSrc(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
raw := `{"type":"doc","content":[{"type":"image","attrs":{"src":"javascript:alert(1)","alt":"xss"}}]}`
|
||||||
|
|
||||||
|
out, err := SanitizeDocumentJSON(raw)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
var doc Node
|
||||||
|
require.NoError(t, json.Unmarshal([]byte(out), &doc))
|
||||||
|
img := doc.Content[0]
|
||||||
|
attrs, err := img.ImageAttrs()
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "", attrs.Src)
|
||||||
|
require.NotNil(t, attrs.Alt)
|
||||||
|
assert.Equal(t, "xss", *attrs.Alt)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSanitizeDocumentJSON_PreservesSafeImageSrc(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
raw := `{"type":"doc","content":[{"type":"image","attrs":{"src":"https://example.com/img.png","alt":"ok"}}]}`
|
||||||
|
|
||||||
|
out, err := SanitizeDocumentJSON(raw)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
var doc Node
|
||||||
|
require.NoError(t, json.Unmarshal([]byte(out), &doc))
|
||||||
|
img := doc.Content[0]
|
||||||
|
attrs, err := img.ImageAttrs()
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "https://example.com/img.png", attrs.Src)
|
||||||
|
}
|
||||||
|
|||||||
@@ -25,7 +25,11 @@ import (
|
|||||||
// strings are allowed.
|
// strings are allowed.
|
||||||
func ProseMirrorDocumentContent() ValidatorFunc {
|
func ProseMirrorDocumentContent() ValidatorFunc {
|
||||||
return func(value any) *ValidationError {
|
return func(value any) *ValidationError {
|
||||||
s, ok := value.(string)
|
actualValue, isNil := dereferenceValue(value)
|
||||||
|
if isNil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
s, ok := actualValue.(string)
|
||||||
if !ok {
|
if !ok {
|
||||||
return newValidationError(ErrorCodeInvalidFormat, "value must be a string")
|
return newValidationError(ErrorCodeInvalidFormat, "value must be a string")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ func TestProseMirrorDocumentContent(t *testing.T) {
|
|||||||
{"valid doc", validDoc, false},
|
{"valid doc", validDoc, false},
|
||||||
{"plain text", "not json", true},
|
{"plain text", "not json", true},
|
||||||
{"non-doc root", `{"type":"paragraph","content":[]}`, true},
|
{"non-doc root", `{"type":"paragraph","content":[]}`, true},
|
||||||
|
{"nil value", nil, false},
|
||||||
|
{"nil *string", (*string)(nil), false},
|
||||||
{"non-string", 1, true},
|
{"non-string", 1, true},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user