Sanitize upon saving

Signed-off-by: Émile Ré <emile@getprobo.com>
This commit is contained in:
Émile Ré
2026-03-30 17:52:25 +04:00
parent 62e68dfb6f
commit f3982f23f9
23 changed files with 471 additions and 356 deletions

View File

@@ -55,9 +55,11 @@ export const documentDescriptionPageQuery = graphql`
`;
const updateContentMutation = graphql`
mutation DocumentDescriptionPage_updateContentMutation($input: UpdateDocumentVersionContentInput!) {
updateDocumentVersionContent(input: $input) {
content
mutation DocumentDescriptionPage_updateContentMutation($input: UpdateDocumentVersionInput!) {
updateDocumentVersion(input: $input) {
documentVersion {
content
}
}
}
`;
@@ -86,7 +88,7 @@ export function DocumentDescriptionPage(props: { queryRef: PreloadedQuery<Docume
updateContent({
variables: {
input: {
id: currentVersion.id,
documentVersionId: currentVersion.id,
content,
},
},

View File

@@ -41,7 +41,7 @@ func TestDocument_Create(t *testing.T) {
name: "with full details",
input: map[string]any{
"title": "Security Policy",
"content": "This is the security policy content.",
"content": testutil.ProseMirrorTextDoc("This is the security policy content."),
"documentType": "POLICY",
"classification": "INTERNAL",
},
@@ -52,7 +52,7 @@ func TestDocument_Create(t *testing.T) {
name: "with POLICY type",
input: map[string]any{
"title": "Policy Document",
"content": "Policy content",
"content": testutil.ProseMirrorTextDoc("Policy content"),
"documentType": "POLICY",
"classification": "INTERNAL",
},
@@ -63,7 +63,7 @@ func TestDocument_Create(t *testing.T) {
name: "with PROCEDURE type",
input: map[string]any{
"title": "Procedure Document",
"content": "Procedure content",
"content": testutil.ProseMirrorTextDoc("Procedure content"),
"documentType": "PROCEDURE",
"classification": "INTERNAL",
},
@@ -74,7 +74,7 @@ func TestDocument_Create(t *testing.T) {
name: "with GOVERNANCE type",
input: map[string]any{
"title": "Governance Document",
"content": "Governance content",
"content": testutil.ProseMirrorTextDoc("Governance content"),
"documentType": "GOVERNANCE",
"classification": "INTERNAL",
},
@@ -85,7 +85,7 @@ func TestDocument_Create(t *testing.T) {
name: "with OTHER type",
input: map[string]any{
"title": "Other Document",
"content": "Other content",
"content": testutil.ProseMirrorTextDoc("Other content"),
"documentType": "OTHER",
"classification": "INTERNAL",
},
@@ -157,7 +157,7 @@ func TestDocument_Create_Validation(t *testing.T) {
name: "missing organizationId",
input: map[string]any{
"title": "Test Document",
"content": "Test content",
"content": testutil.ProseMirrorTextDoc("Test content"),
"documentType": "POLICY",
"classification": "INTERNAL",
},
@@ -168,7 +168,7 @@ func TestDocument_Create_Validation(t *testing.T) {
name: "title with HTML tags",
input: map[string]any{
"title": "<script>alert('xss')</script>",
"content": "Test content",
"content": testutil.ProseMirrorTextDoc("Test content"),
"documentType": "POLICY",
"classification": "INTERNAL",
},
@@ -178,7 +178,7 @@ func TestDocument_Create_Validation(t *testing.T) {
name: "title with newline",
input: map[string]any{
"title": "Test\nDocument",
"content": "Test content",
"content": testutil.ProseMirrorTextDoc("Test content"),
"documentType": "POLICY",
"classification": "INTERNAL",
},
@@ -188,7 +188,7 @@ func TestDocument_Create_Validation(t *testing.T) {
name: "title with carriage return",
input: map[string]any{
"title": "Test\rDocument",
"content": "Test content",
"content": testutil.ProseMirrorTextDoc("Test content"),
"documentType": "POLICY",
"classification": "INTERNAL",
},
@@ -198,7 +198,7 @@ func TestDocument_Create_Validation(t *testing.T) {
name: "title with null byte",
input: map[string]any{
"title": "Test\x00Document",
"content": "Test content",
"content": testutil.ProseMirrorTextDoc("Test content"),
"documentType": "POLICY",
"classification": "INTERNAL",
},
@@ -208,7 +208,7 @@ func TestDocument_Create_Validation(t *testing.T) {
name: "title with tab character",
input: map[string]any{
"title": "Test\tDocument",
"content": "Test content",
"content": testutil.ProseMirrorTextDoc("Test content"),
"documentType": "POLICY",
"classification": "INTERNAL",
},
@@ -218,7 +218,7 @@ func TestDocument_Create_Validation(t *testing.T) {
name: "title with zero-width space",
input: map[string]any{
"title": "Test\u200BDocument",
"content": "Test content",
"content": testutil.ProseMirrorTextDoc("Test content"),
"documentType": "POLICY",
"classification": "INTERNAL",
},
@@ -228,7 +228,7 @@ func TestDocument_Create_Validation(t *testing.T) {
name: "title with zero-width joiner",
input: map[string]any{
"title": "Test\u200DDocument",
"content": "Test content",
"content": testutil.ProseMirrorTextDoc("Test content"),
"documentType": "POLICY",
"classification": "INTERNAL",
},
@@ -238,7 +238,7 @@ func TestDocument_Create_Validation(t *testing.T) {
name: "title with right-to-left override",
input: map[string]any{
"title": "Test\u202EDocument",
"content": "Test content",
"content": testutil.ProseMirrorTextDoc("Test content"),
"documentType": "POLICY",
"classification": "INTERNAL",
},
@@ -611,7 +611,7 @@ func TestDocument_Timestamps(t *testing.T) {
"input": map[string]any{
"organizationId": owner.GetOrganizationID().String(),
"title": "Timestamp Test Document",
"content": "Test content",
"content": testutil.ProseMirrorTextDoc("Test content"),
"documentType": "POLICY",
"classification": "INTERNAL",
},
@@ -740,7 +740,7 @@ func TestDocument_RBAC(t *testing.T) {
"input": map[string]any{
"organizationId": owner.GetOrganizationID().String(),
"title": "RBAC Test Document",
"content": "Test content",
"content": testutil.ProseMirrorTextDoc("Test content"),
"documentType": "POLICY",
"classification": "INTERNAL",
},
@@ -762,7 +762,7 @@ func TestDocument_RBAC(t *testing.T) {
"input": map[string]any{
"organizationId": admin.GetOrganizationID().String(),
"title": "RBAC Test Document",
"content": "Test content",
"content": testutil.ProseMirrorTextDoc("Test content"),
"documentType": "POLICY",
"classification": "INTERNAL",
},
@@ -784,7 +784,7 @@ func TestDocument_RBAC(t *testing.T) {
"input": map[string]any{
"organizationId": viewer.GetOrganizationID().String(),
"title": "RBAC Test Document",
"content": "Test content",
"content": testutil.ProseMirrorTextDoc("Test content"),
"documentType": "POLICY",
"classification": "INTERNAL",
},
@@ -1007,7 +1007,7 @@ func TestDocument_MaxLength_Validation(t *testing.T) {
"input": map[string]any{
"organizationId": owner.GetOrganizationID().String(),
"title": longTitle,
"content": "Test content",
"content": testutil.ProseMirrorTextDoc("Test content"),
"documentType": "POLICY",
"classification": "INTERNAL",
},

View File

@@ -73,7 +73,7 @@ func createTestDocument(t *testing.T, owner *testutil.Client) (docID string, doc
"input": map[string]any{
"organizationId": owner.GetOrganizationID().String(),
"title": "Test Document",
"content": "Initial content",
"content": testutil.ProseMirrorTextDoc("Initial content"),
"documentType": "POLICY",
"classification": "INTERNAL",
},
@@ -282,15 +282,17 @@ func TestDocumentVersion_UpdateContent(t *testing.T) {
} `json:"updateDocumentVersionContent"`
}
wantContent := testutil.ProseMirrorTextDoc("Updated content for the document version")
err := owner.Execute(query, map[string]any{
"input": map[string]any{
"id": draftVersionID,
"content": "Updated content for the document version",
"content": wantContent,
},
}, &result)
require.NoError(t, err)
assert.Equal(t, "Updated content for the document version", result.UpdateDocumentVersionContent.Content)
assert.JSONEq(t, wantContent, result.UpdateDocumentVersionContent.Content)
}
func TestDocumentVersion_RequestSignature(t *testing.T) {

View File

@@ -393,7 +393,7 @@ func TestControlDocumentMapping_CreateDelete(t *testing.T) {
"input": map[string]any{
"organizationId": owner.GetOrganizationID().String(),
"title": "Document for Control Mapping",
"content": "Document content",
"content": testutil.ProseMirrorTextDoc("Document content"),
"documentType": "POLICY",
"classification": "INTERNAL",
},
@@ -781,7 +781,7 @@ func TestRiskDocumentMapping_CreateDelete(t *testing.T) {
"input": map[string]any{
"organizationId": owner.GetOrganizationID().String(),
"title": "Document for Risk Mapping",
"content": "Document content",
"content": testutil.ProseMirrorTextDoc("Document content"),
"documentType": "POLICY",
"classification": "INTERNAL",
},

View File

@@ -12,21 +12,29 @@
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package documentversion
package testutil
import (
"github.com/spf13/cobra"
"go.probo.inc/probo/pkg/cmd/cmdutil"
"go.probo.inc/probo/pkg/cmd/documentversion/updatecontent"
"encoding/json"
)
func NewCmdDocumentVersion(f *cmdutil.Factory) *cobra.Command {
cmd := &cobra.Command{
Use: "document-version <command>",
Short: "Manage document versions",
// ProseMirrorTextDoc returns minimal ProseMirror/Tiptap JSON accepted by the API:
// a doc with one paragraph containing a single text node.
func ProseMirrorTextDoc(text string) string {
doc := map[string]any{
"type": "doc",
"content": []any{
map[string]any{
"type": "paragraph",
"content": []any{
map[string]any{"type": "text", "text": text},
},
},
},
}
cmd.AddCommand(updatecontent.NewCmdUpdateContent(f))
return cmd
b, err := json.Marshal(doc)
if err != nil {
panic(err)
}
return string(b)
}

View File

@@ -1,140 +0,0 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package updatecontent
import (
"encoding/json"
"fmt"
"io"
"github.com/spf13/cobra"
"go.probo.inc/probo/pkg/cli/api"
"go.probo.inc/probo/pkg/cmd/cmdutil"
"go.probo.inc/probo/pkg/prosemirror"
)
const updateContentMutation = `
mutation($input: UpdateDocumentVersionContentInput!) {
updateDocumentVersionContent(input: $input) {
content
}
}
`
type updateContentResponse struct {
UpdateDocumentVersionContent struct {
Content string `json:"content"`
} `json:"updateDocumentVersionContent"`
}
func NewCmdUpdateContent(f *cmdutil.Factory) *cobra.Command {
var (
flagID string
flagContent string
flagFromMarkdown string
)
cmd := &cobra.Command{
Use: "update-content",
Short: "Update document version content",
Example: ` # Update with ProseMirror JSON
prb document-version update-content --id <version-id> --content '{"type":"doc",...}'
# Update from markdown
prb document-version update-content --id <version-id> --from-markdown "# Hello"
# Update from stdin
cat content.json | prb document-version update-content --id <version-id>`,
RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := f.Config()
if err != nil {
return err
}
host, hc, err := cfg.DefaultHost()
if err != nil {
return err
}
client := api.NewClient(
host,
hc.Token,
"/api/console/v1/graphql",
cfg.HTTPTimeoutDuration(),
)
var content string
switch {
case flagFromMarkdown != "":
doc, err := prosemirror.ParseMarkdown(flagFromMarkdown)
if err != nil {
return err
}
out, err := json.Marshal(doc)
if err != nil {
return fmt.Errorf("cannot marshal prosemirror document: %w", err)
}
content = string(out)
case flagContent != "":
content = flagContent
default:
data, err := io.ReadAll(f.IOStreams.In)
if err != nil {
return fmt.Errorf("cannot read from stdin: %w", err)
}
content = string(data)
}
input := map[string]any{
"id": flagID,
"content": content,
}
data, err := client.Do(
updateContentMutation,
map[string]any{"input": input},
)
if err != nil {
return err
}
var resp updateContentResponse
if err := json.Unmarshal(data, &resp); err != nil {
return fmt.Errorf("cannot parse response: %w", err)
}
_, _ = fmt.Fprintf(
f.IOStreams.Out,
"Updated document version content %s\n",
flagID,
)
return nil
},
}
cmd.Flags().StringVar(&flagID, "id", "", "Document version ID (required)")
cmd.Flags().StringVar(&flagContent, "content", "", "ProseMirror JSON content")
cmd.Flags().StringVar(
&flagFromMarkdown,
"from-markdown",
"",
"Markdown content to convert and upload",
)
_ = cmd.MarkFlagRequired("id")
return cmd
}

View File

@@ -25,7 +25,6 @@ import (
cmdconfig "go.probo.inc/probo/pkg/cmd/config"
cmdcontext "go.probo.inc/probo/pkg/cmd/context"
"go.probo.inc/probo/pkg/cmd/control"
"go.probo.inc/probo/pkg/cmd/documentversion"
"go.probo.inc/probo/pkg/cmd/evidence"
"go.probo.inc/probo/pkg/cmd/finding"
"go.probo.inc/probo/pkg/cmd/framework"
@@ -75,7 +74,6 @@ func NewCmdRoot(f *cmdutil.Factory) *cobra.Command {
cmd.AddCommand(cmdconfig.NewCmdConfig(f))
cmd.AddCommand(cmdcontext.NewCmdContext(f))
cmd.AddCommand(control.NewCmdControl(f))
cmd.AddCommand(documentversion.NewCmdDocumentVersion(f))
cmd.AddCommand(evidence.NewCmdEvidence(f))
cmd.AddCommand(finding.NewCmdFinding(f))
cmd.AddCommand(framework.NewCmdFramework(f))

View File

@@ -40,6 +40,7 @@ import (
"go.probo.inc/probo/pkg/iam"
"go.probo.inc/probo/pkg/mail"
"go.probo.inc/probo/pkg/page"
"go.probo.inc/probo/pkg/prosemirror"
"go.probo.inc/probo/pkg/statelesstoken"
"go.probo.inc/probo/pkg/validator"
"go.probo.inc/probo/pkg/watermarkpdf"
@@ -120,7 +121,12 @@ func (cdr *CreateDocumentRequest) Validate() error {
v.Check(cdr.OrganizationID, "organization_id", validator.Required(), validator.GID(coredata.OrganizationEntityType))
v.Check(cdr.Title, "title", validator.Required(), validator.SafeTextNoNewLine(TitleMaxLength))
v.Check(cdr.Content, "content", validator.MaxLen(documentMaxLength))
v.Check(
cdr.Content,
"content",
validator.MaxLen(documentMaxLength),
validator.ProseMirrorDocumentContent(),
)
v.Check(cdr.Classification, "classification", validator.Required(), validator.OneOfSlice(coredata.DocumentClassifications()))
v.Check(cdr.DocumentType, "document_type", validator.Required(), validator.OneOfSlice(coredata.DocumentTypes()))
v.Check(cdr.TrustCenterVisibility, "trust_center_visibility", validator.OneOfSlice(coredata.TrustCenterVisibilities()))
@@ -143,8 +149,15 @@ func (udvr *UpdateDocumentVersionRequest) Validate() error {
v := validator.New()
v.Check(udvr.ID, "id", validator.Required(), validator.GID(coredata.DocumentVersionEntityType))
v.Check(udvr.Content, "content", validator.NotEmpty(), validator.MaxLen(documentMaxLength))
v.Check(udvr.Classification, "classification", validator.OneOfSlice(coredata.DocumentClassifications()))
v.Check(
udvr.Content,
"content",
validator.Required(),
validator.NotEmpty(),
validator.MaxLen(documentMaxLength),
validator.ProseMirrorDocumentContent(),
)
return v.Error()
}
@@ -518,13 +531,22 @@ func (s *DocumentService) Create(
document.TrustCenterVisibility = *req.TrustCenterVisibility
}
content := req.Content
if strings.TrimSpace(content) != "" {
var sanitizeErr error
content, sanitizeErr = prosemirror.SanitizeDocumentJSON(content)
if sanitizeErr != nil {
return nil, nil, fmt.Errorf("cannot sanitize document content: %w", sanitizeErr)
}
}
documentVersion := &coredata.DocumentVersion{
ID: documentVersionID,
DocumentID: documentID,
Title: req.Title,
Major: 0,
Minor: 1,
Content: req.Content,
Content: content,
Status: coredata.DocumentVersionStatusDraft,
Classification: req.Classification,
CreatedAt: now,
@@ -761,10 +783,17 @@ func (s *DocumentService) UpdateVersion(
return &ErrDocumentVersionNotDraft{}
}
documentVersion.Title = document.Title
var content string
if req.Content != nil {
documentVersion.Content = *req.Content
var err error
content, err = prosemirror.SanitizeDocumentJSON(*req.Content)
if err != nil {
return fmt.Errorf("cannot sanitize document content: %w", err)
}
}
documentVersion.Title = document.Title
documentVersion.Content = content
if req.Classification != nil {
documentVersion.Classification = *req.Classification
}
@@ -1576,45 +1605,6 @@ func (s *DocumentService) Update(
return document, nil
}
func (s *DocumentService) UpdateDocumentVersionContent(
ctx context.Context,
req UpdateDocumentVersionRequest,
) (string, error) {
documentVersion := &coredata.DocumentVersion{}
if err := req.Validate(); err != nil {
return "", err
}
err := s.svc.pg.WithTx(
ctx,
func(conn pg.Conn) error {
if err := documentVersion.LoadByID(ctx, conn, s.svc.scope, req.ID); err != nil {
return fmt.Errorf("cannot load document version %q: %w", req.ID, err)
}
if documentVersion.Status != coredata.DocumentVersionStatusDraft {
return &ErrDocumentVersionNotDraft{}
}
documentVersion.Content = req.Content
documentVersion.UpdatedAt = time.Now()
if err := documentVersion.Update(ctx, conn, s.svc.scope); err != nil {
return fmt.Errorf("cannot update document version: %w", err)
}
return nil
},
)
if err != nil {
return "", err
}
return documentVersion.Content, nil
}
func (s *DocumentService) Archive(
ctx context.Context,
documentID gid.GID,

View File

@@ -18,7 +18,9 @@ import (
"bytes"
"fmt"
"html"
"net/url"
"strconv"
"strings"
)
// RenderHTML renders a ProseMirror document node tree to an HTML string.
@@ -192,7 +194,7 @@ func openMark(buf *bytes.Buffer, m Mark) error {
return fmt.Errorf("cannot render link mark: %w", err)
}
buf.WriteString("<a")
writeAttr(buf, "href", attrs.Href)
writeAttr(buf, "href", safeLinkHref(attrs.Href))
if attrs.Target != nil {
writeAttr(buf, "target", *attrs.Target)
}
@@ -266,3 +268,39 @@ func writeAttr(buf *bytes.Buffer, name, value string) {
buf.WriteString(html.EscapeString(value))
buf.WriteByte('"')
}
// safeLinkHref returns a value safe to use in link mark attrs and to emit in an
// HTML href attribute. URLs with disallowed schemes (for example javascript: or
// data:) are replaced with "#" so escaped text content cannot be combined with an
// executable URL.
func safeLinkHref(href string) string {
href = strings.TrimSpace(href)
if href == "" {
return "#"
}
if href[0] == '#' {
return href
}
if strings.HasPrefix(href, "/") {
if len(href) > 1 && (href[1] == '/' || href[1] == '\\') {
return "#"
}
return href
}
u, err := url.Parse(href)
if err != nil {
return "#"
}
if u.Scheme != "" {
switch strings.ToLower(u.Scheme) {
case "http", "https", "mailto", "tel":
return href
default:
return "#"
}
}
if u.Host != "" {
return "#"
}
return href
}

View File

@@ -561,7 +561,7 @@ func (c *htmlBlockConverter) convertAnchor(n *html.Node) ([]Node, error) {
if t := attrVal(n, "title"); t != "" {
title = &t
}
attrs, err := json.Marshal(LinkAttrs{Href: href, Title: title})
attrs, err := json.Marshal(LinkAttrs{Href: safeLinkHref(href), Title: title})
if err != nil {
return nil, fmt.Errorf("cannot marshal link attrs: %w", err)
}

View File

@@ -16,6 +16,8 @@ package prosemirror
import (
"encoding/json"
"fmt"
"html"
"os"
"testing"
@@ -185,6 +187,49 @@ func TestRenderHTML_LinkMinimalAttrs(t *testing.T) {
assert.Equal(t, `<a href="https://example.com">hi</a>`, got)
}
func TestRenderHTML_LinkSanitizesDangerousHrefs(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
name string
href string
wantHref string
}{
{name: "javascript scheme", href: `javascript:alert(1)`, wantHref: `#`},
{name: "javascript scheme case insensitive", href: `javaScript:alert(1)`, wantHref: `#`},
{name: "data html", href: `data:text/html,<script>alert(1)</script>`, wantHref: `#`},
{name: "protocol-relative", href: `//evil.example/phish`, wantHref: `#`},
{name: "path with leading slash-slash", href: `//not-a-path`, wantHref: `#`},
{name: "empty href", href: ``, wantHref: `#`},
{name: "fragment only", href: `#section`, wantHref: `#section`},
{name: "relative path", href: `docs/page`, wantHref: `docs/page`},
{name: "absolute path", href: `/app/foo`, wantHref: `/app/foo`},
{name: "mailto", href: `mailto:user@example.com`, wantHref: `mailto:user@example.com`},
{name: "tel", href: `tel:+15551212`, wantHref: `tel:+15551212`},
{name: "https preserved", href: `https://example.com/x`, wantHref: `https://example.com/x`},
} {
t.Run(
tc.name,
func(t *testing.T) {
t.Parallel()
hrefJSON, err := json.Marshal(tc.href)
require.NoError(t, err)
raw := fmt.Sprintf(
`{"type":"text","marks":[{"type":"link","attrs":{"href":%s,"target":null,"rel":null,"class":null,"title":null}}],"text":"x"}`,
string(hrefJSON),
)
var n Node
require.NoError(t, json.Unmarshal([]byte(raw), &n))
got, err := RenderHTML(n)
require.NoError(t, err)
want := fmt.Sprintf(`<a href="%s">x</a>`, html.EscapeString(tc.wantHref))
assert.Equal(t, want, got)
},
)
}
}
func TestRenderHTML_Image(t *testing.T) {
t.Parallel()

View File

@@ -426,7 +426,7 @@ func (c *converter) convertCodeSpan(n ast.Node) ([]Node, error) {
func (c *converter) convertLink(n *ast.Link) ([]Node, error) {
linkAttrs := LinkAttrs{
Href: string(n.Destination),
Href: safeLinkHref(string(n.Destination)),
}
if n.Title != nil {
@@ -452,7 +452,7 @@ func (c *converter) convertLink(n *ast.Link) ([]Node, error) {
func (c *converter) convertAutoLink(n *ast.AutoLink) ([]Node, error) {
url := string(n.URL(c.source))
linkAttrs := LinkAttrs{Href: url}
linkAttrs := LinkAttrs{Href: safeLinkHref(url)}
attrs, err := json.Marshal(linkAttrs)
if err != nil {
return nil, fmt.Errorf("cannot marshal link attrs: %w", err)

View File

@@ -205,6 +205,33 @@ func TestParseMarkdown_Link(t *testing.T) {
assert.Equal(t, "https://example.com", linkAttrs.Href)
}
func TestParseMarkdown_LinkSanitizesDangerousHrefs(t *testing.T) {
t.Parallel()
tests := []struct {
name string
markdown string
wantHref string
}{
{name: "javascript scheme", markdown: `[x](javascript:alert(1))`, wantHref: `#`},
{name: "data html", markdown: `[x](data:text/html,<script>alert(1)</script>)`, wantHref: `#`},
{name: "protocol-relative", markdown: `[x](//evil.example/phish)`, wantHref: `#`},
{name: "https preserved", markdown: `[x](https://example.com/y)`, wantHref: `https://example.com/y`},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
doc, err := ParseMarkdown(tt.markdown)
require.NoError(t, err)
txt := doc.Content[0].Content[0]
linkAttrs, err := txt.Marks[0].LinkAttrs()
require.NoError(t, err)
assert.Equal(t, tt.wantHref, linkAttrs.Href)
})
}
}
func TestParseMarkdown_Image(t *testing.T) {
t.Parallel()

View File

@@ -0,0 +1,96 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package prosemirror
import (
"encoding/json"
"fmt"
"strings"
)
// ValidateDocumentContentJSON returns nil if s is empty or whitespace-only.
// Otherwise s must be valid ProseMirror JSON whose root node has type "doc".
func ValidateDocumentContentJSON(s string) error {
if strings.TrimSpace(s) == "" {
return nil
}
_, err := parseDocRoot(s)
return err
}
func parseDocRoot(s string) (Node, error) {
n, err := Parse(s)
if err != nil {
return Node{}, fmt.Errorf("cannot parse document content as ProseMirror JSON: %w", err)
}
if n.Type != NodeDoc {
return Node{}, fmt.Errorf("document content root must be type %q", NodeDoc)
}
return n, nil
}
// SanitizeDocumentJSON parses a ProseMirror/Tiptap JSON document, replaces
// unsafe link mark href values using the same rules as RenderHTML, and
// re-serializes the document. Whitespace-only input is returned unchanged.
// Non-empty content must be valid JSON whose root node has type "doc".
func SanitizeDocumentJSON(s string) (string, error) {
if strings.TrimSpace(s) == "" {
return s, nil
}
n, err := parseDocRoot(s)
if err != nil {
return "", err
}
sanitizeNode(&n)
out, err := json.Marshal(n)
if err != nil {
return "", fmt.Errorf("cannot marshal sanitized document: %w", err)
}
return string(out), nil
}
func sanitizeNode(n *Node) {
for i := range n.Marks {
sanitizeLinkMark(&n.Marks[i])
}
for i := range n.Content {
sanitizeNode(&n.Content[i])
}
}
func sanitizeLinkMark(m *Mark) {
if m.Type != MarkLink {
return
}
attrs, err := m.LinkAttrs()
if err != nil {
m.Attrs = []byte(`{"href":"#"}`)
return
}
attrs.Href = safeLinkHref(attrs.Href)
raw, err := json.Marshal(attrs)
if err != nil {
m.Attrs = []byte(`{"href":"#"}`)
return
}
m.Attrs = raw
}

View File

@@ -0,0 +1,84 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package prosemirror
import (
"encoding/json"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestSanitizeDocumentJSON_EmptyUnchanged(t *testing.T) {
t.Parallel()
got, err := SanitizeDocumentJSON("")
require.NoError(t, err)
assert.Equal(t, "", got)
got, err = SanitizeDocumentJSON(" ")
require.NoError(t, err)
assert.Equal(t, " ", got)
}
func TestSanitizeDocumentJSON_NonJSONError(t *testing.T) {
t.Parallel()
_, err := SanitizeDocumentJSON("plain text is not valid document JSON")
require.Error(t, err)
}
func TestSanitizeDocumentJSON_NonDocRootError(t *testing.T) {
t.Parallel()
_, err := SanitizeDocumentJSON(`{"type":"paragraph","content":[]}`)
require.Error(t, err)
}
func TestSanitizeDocumentJSON_LinkHref(t *testing.T) {
t.Parallel()
raw := `{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","marks":[{"type":"link","attrs":{"href":"javascript:alert(1)","target":"_blank"}}],"text":"click"}]}]}`
out, err := SanitizeDocumentJSON(raw)
require.NoError(t, err)
var doc Node
require.NoError(t, json.Unmarshal([]byte(out), &doc))
txt := doc.Content[0].Content[0]
require.Len(t, txt.Marks, 1)
attrs, err := txt.Marks[0].LinkAttrs()
require.NoError(t, err)
assert.Equal(t, "#", attrs.Href)
require.NotNil(t, attrs.Target)
assert.Equal(t, "_blank", *attrs.Target)
}
func TestSanitizeDocumentJSON_PreservesSafeHref(t *testing.T) {
t.Parallel()
raw := `{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","marks":[{"type":"link","attrs":{"href":"https://example.com"}}],"text":"ok"}]}]}`
out, err := SanitizeDocumentJSON(raw)
require.NoError(t, err)
var doc Node
require.NoError(t, json.Unmarshal([]byte(out), &doc))
txt := doc.Content[0].Content[0]
attrs, err := txt.Marks[0].LinkAttrs()
require.NoError(t, err)
assert.Equal(t, "https://example.com", attrs.Href)
}

View File

@@ -1,17 +1,3 @@
// Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package connect_v1
// This file will be automatically regenerated based on the schema, any resolver

View File

@@ -3766,7 +3766,6 @@ type Mutation {
updateDocument(input: UpdateDocumentInput!): UpdateDocumentPayload!
archiveDocument(input: ArchiveDocumentInput!): ArchiveDocumentPayload!
unarchiveDocument(input: UnarchiveDocumentInput!): UnarchiveDocumentPayload!
updateDocumentVersionContent(input: UpdateDocumentVersionContentInput!): UpdateDocumentVersionContentPayload!
deleteDocument(input: DeleteDocumentInput!): DeleteDocumentPayload!
# Meeting mutations
createMeeting(input: CreateMeetingInput!): CreateMeetingPayload!
@@ -4486,11 +4485,6 @@ input UpdateDocumentInput {
trustCenterVisibility: TrustCenterVisibility
}
input UpdateDocumentVersionContentInput {
id: ID!
content: String!
}
input ExportDocumentVersionPDFInput {
documentVersionId: ID!
withWatermark: Boolean!
@@ -5256,10 +5250,6 @@ type UnarchiveDocumentPayload {
document: Document!
}
type UpdateDocumentVersionContentPayload {
content: String!
}
type DeleteDocumentPayload {
deletedDocumentId: ID!
}

View File

@@ -4734,38 +4734,6 @@ func (r *mutationResolver) UnarchiveDocument(ctx context.Context, input types.Un
}, nil
}
// UpdateDocumentVersionContent is the resolver for the updateDocumentVersionContent field.
func (r *mutationResolver) UpdateDocumentVersionContent(ctx context.Context, input types.UpdateDocumentVersionContentInput) (*types.UpdateDocumentVersionContentPayload, error) {
if err := r.authorize(ctx, input.ID, probo.ActionDocumentUpdate); err != nil {
return nil, err
}
prb := r.ProboService(ctx, input.ID.TenantID())
content, err := prb.Documents.UpdateDocumentVersionContent(
ctx,
probo.UpdateDocumentVersionRequest{
ID: input.ID,
Content: input.Content,
},
)
if err != nil {
if _, ok := errors.AsType[*probo.ErrDocumentVersionNotDraft](err); ok {
return nil, gqlutils.Conflict(ctx, err)
}
if validationErrors, ok := errors.AsType[validator.ValidationErrors](err); ok {
return nil, gqlutils.InvalidValidationErrors(ctx, validationErrors)
}
r.logger.ErrorCtx(ctx, "cannot update document version content", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
return &types.UpdateDocumentVersionContentPayload{
Content: content,
}, nil
}
// DeleteDocument is the resolver for the deleteDocument field.
func (r *mutationResolver) DeleteDocument(ctx context.Context, input types.DeleteDocumentInput) (*types.DeleteDocumentPayload, error) {
if err := r.authorize(ctx, input.DocumentID, probo.ActionDocumentDelete); err != nil {

View File

@@ -3351,29 +3351,3 @@ func (r *Resolver) PublishMinorDocumentVersionTool(ctx context.Context, req *mcp
DocumentVersion: types.NewDocumentVersion(documentVersion),
}, nil
}
func (r *Resolver) UpdateDocumentVersionContentTool(ctx context.Context, req *mcp.CallToolRequest, input *types.UpdateDocumentVersionContentInput) (*mcp.CallToolResult, types.UpdateDocumentVersionContentOutput, error) {
r.MustAuthorize(ctx, input.DocumentVersionID, probo.ActionDocumentUpdate)
svc := r.ProboService(ctx, input.DocumentVersionID)
content, err := svc.Documents.UpdateDocumentVersionContent(
ctx,
probo.UpdateDocumentVersionRequest{
ID: input.DocumentVersionID,
Content: input.Content,
},
)
if err != nil {
var errNotDraft *probo.ErrDocumentVersionNotDraft
if errors.As(err, &errNotDraft) {
return nil, types.UpdateDocumentVersionContentOutput{}, fmt.Errorf("cannot update document version content: %w", err)
}
panic(fmt.Errorf("cannot update document version content: %w", err))
}
return nil, types.UpdateDocumentVersionContentOutput{
Content: content,
}, nil
}

View File

@@ -5597,28 +5597,6 @@ components:
document_version:
$ref: "#/components/schemas/DocumentVersion"
UpdateDocumentVersionContentInput:
type: object
required:
- document_version_id
- content
properties:
document_version_id:
$ref: "#/components/schemas/GID"
description: Document version ID
content:
type: string
description: ProseMirror JSON document content
UpdateDocumentVersionContentOutput:
type: object
required:
- content
properties:
content:
type: string
description: Updated document content
DeleteDraftDocumentVersionInput:
type: object
required:
@@ -7506,14 +7484,6 @@ tools:
$ref: "#/components/schemas/UpdateDocumentVersionInput"
outputSchema:
$ref: "#/components/schemas/UpdateDocumentVersionOutput"
- name: updateDocumentVersionContent
description: Update draft document version body content only (ProseMirror JSON).
hints:
readonly: false
inputSchema:
$ref: "#/components/schemas/UpdateDocumentVersionContentInput"
outputSchema:
$ref: "#/components/schemas/UpdateDocumentVersionContentOutput"
- name: deleteDraftDocumentVersion
description: Delete a draft document version
hints:

View File

@@ -1,17 +1,3 @@
// Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package trust_v1
// This file will be automatically regenerated based on the schema, any resolver

View File

@@ -0,0 +1,40 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package validator
import (
"strings"
"go.probo.inc/probo/pkg/prosemirror"
)
// ProseMirrorDocumentContent requires non-empty string values to be valid
// ProseMirror/Tiptap JSON with root type "doc". Empty and whitespace-only
// strings are allowed.
func ProseMirrorDocumentContent() ValidatorFunc {
return func(value any) *ValidationError {
s, ok := value.(string)
if !ok {
return newValidationError(ErrorCodeInvalidFormat, "value must be a string")
}
if strings.TrimSpace(s) == "" {
return nil
}
if err := prosemirror.ValidateDocumentContentJSON(s); err != nil {
return newValidationError(ErrorCodeInvalidFormat, err.Error())
}
return nil
}
}

View File

@@ -0,0 +1,51 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package validator
import "testing"
func TestProseMirrorDocumentContent(t *testing.T) {
t.Parallel()
validDoc := `{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"hi"}]}]}`
tests := []struct {
name string
value any
wantError bool
}{
{"empty string", "", false},
{"whitespace only", " \n\t ", false},
{"valid doc", validDoc, false},
{"plain text", "not json", true},
{"non-doc root", `{"type":"paragraph","content":[]}`, true},
{"non-string", 1, true},
}
fn := ProseMirrorDocumentContent()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
err := fn(tt.value)
if (err != nil) != tt.wantError {
t.Errorf("ProseMirrorDocumentContent() error = %v, wantError %v", err, tt.wantError)
}
if err != nil && err.Code != ErrorCodeInvalidFormat {
t.Errorf("expected code %s, got %s", ErrorCodeInvalidFormat, err.Code)
}
})
}
}