From 6950e600b2da14b201832a962f8048b95c6d35f6 Mon Sep 17 00:00:00 2001 From: Andrew Still Date: Tue, 13 Jan 2026 22:25:21 -0500 Subject: [PATCH 1/2] un-escape html strings --- .changes/unreleased/Bugfix-20260113-222243.yaml | 3 +++ service.go | 5 +++++ 2 files changed, 8 insertions(+) create mode 100644 .changes/unreleased/Bugfix-20260113-222243.yaml diff --git a/.changes/unreleased/Bugfix-20260113-222243.yaml b/.changes/unreleased/Bugfix-20260113-222243.yaml new file mode 100644 index 00000000..b8342510 --- /dev/null +++ b/.changes/unreleased/Bugfix-20260113-222243.yaml @@ -0,0 +1,3 @@ +kind: Bugfix +body: unescape html strings +time: 2026-01-13T22:22:43.397635-05:00 diff --git a/service.go b/service.go index 3f2acd8b..d608df70 100644 --- a/service.go +++ b/service.go @@ -3,6 +3,7 @@ package opslevel import ( "errors" "fmt" + "html" "slices" "strings" ) @@ -115,6 +116,10 @@ func (service *Service) HasTool(category ToolCategory, name string, environment } func (service *Service) Hydrate(client *Client) error { + service.Description = html.UnescapeString(service.Description) + service.Note = html.UnescapeString(service.Note) + service.Product = html.UnescapeString(service.Product) + if service.Tags == nil { service.Tags = &TagConnection{} } From 748a2e6dbb76a790cac581832581be75f34f7eb3 Mon Sep 17 00:00:00 2001 From: Andrew Still Date: Tue, 13 Jan 2026 23:23:28 -0500 Subject: [PATCH 2/2] fix other known issues --- domain.go | 41 +++++++++++++++++++++++++------ domain_test.go | 41 +++++++++++++++++++++++++++++++ service_test.go | 64 +++++++++++++++++++++++++++++++++++++++++++++++++ system.go | 41 +++++++++++++++++++++++++------ system_test.go | 50 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 223 insertions(+), 14 deletions(-) diff --git a/domain.go b/domain.go index 9bf44040..49e28f35 100644 --- a/domain.go +++ b/domain.go @@ -3,6 +3,7 @@ package opslevel import ( "errors" "fmt" + "html" "slices" ) @@ -18,6 +19,12 @@ func (d *Domain) UniqueIdentifiers() []string { return uniqueIdentifiers } +func (d *Domain) Hydrate(client *Client) error { + d.Description = html.UnescapeString(d.Description) + d.Note = html.UnescapeString(d.Note) + return nil +} + func (d *Domain) ReconcileAliases(client *Client, aliasesWanted []string) error { aliasesToCreate, aliasesToDelete := extractAliases(d.Aliases, aliasesWanted) @@ -140,8 +147,13 @@ func (client *Client) CreateDomain(input DomainInput) (*Domain, error) { v := PayloadVariables{ "input": input, } - err := client.Mutate(&m, v, WithName("DomainCreate")) - return &m.Payload.Domain, HandleErrors(err, m.Payload.Errors) + if err := client.Mutate(&m, v, WithName("DomainCreate")); err != nil { + return nil, err + } + if err := m.Payload.Domain.Hydrate(client); err != nil { + return &m.Payload.Domain, err + } + return &m.Payload.Domain, HandleErrors(nil, m.Payload.Errors) } func (client *Client) GetDomain(identifier string) (*Domain, error) { @@ -153,8 +165,13 @@ func (client *Client) GetDomain(identifier string) (*Domain, error) { v := PayloadVariables{ "input": *NewIdentifier(identifier), } - err := client.Query(&q, v, WithName("DomainGet")) - return &q.Account.Domain, HandleErrors(err, nil) + if err := client.Query(&q, v, WithName("DomainGet")); err != nil { + return nil, err + } + if err := q.Account.Domain.Hydrate(client); err != nil { + return &q.Account.Domain, err + } + return &q.Account.Domain, nil } func (client *Client) ListDomains(variables *PayloadVariables) (*DomainConnection, error) { @@ -175,7 +192,12 @@ func (client *Client) ListDomains(variables *PayloadVariables) (*DomainConnectio if err != nil { return nil, err } - q.Account.Domains.Nodes = append(q.Account.Domains.Nodes, resp.Nodes...) + for _, node := range resp.Nodes { + if err := node.Hydrate(client); err != nil { + return nil, err + } + q.Account.Domains.Nodes = append(q.Account.Domains.Nodes, node) + } q.Account.Domains.PageInfo = resp.PageInfo } q.Account.Domains.TotalCount = len(q.Account.Domains.Nodes) @@ -190,8 +212,13 @@ func (client *Client) UpdateDomain(identifier string, input DomainInput) (*Domai "domain": *NewIdentifier(identifier), "input": input, } - err := client.Mutate(&m, v, WithName("DomainUpdate")) - return &m.Payload.Domain, HandleErrors(err, m.Payload.Errors) + if err := client.Mutate(&m, v, WithName("DomainUpdate")); err != nil { + return nil, err + } + if err := m.Payload.Domain.Hydrate(client); err != nil { + return &m.Payload.Domain, err + } + return &m.Payload.Domain, HandleErrors(nil, m.Payload.Errors) } func (client *Client) DeleteDomain(identifier string) error { diff --git a/domain_test.go b/domain_test.go index 3d9435f7..5b7d9fee 100644 --- a/domain_test.go +++ b/domain_test.go @@ -1,6 +1,7 @@ package opslevel_test import ( + "strings" "testing" ol "github.com/opslevel/opslevel-go/v2025" @@ -304,3 +305,43 @@ func TestDomainDelete(t *testing.T) { // Assert autopilot.Ok(t, err) } + +func TestDomainGetWithHTMLEntities(t *testing.T) { + // Arrange + testRequest := autopilot.NewTestRequest( + `query DomainGet($input:IdentifierInput!){account{domain(input: $input){id,aliases,description,htmlUrl,managedAliases,name,note,owner{... on Team{teamAlias:alias,id}}}}}`, + `{"input": { {{ template "id1" }} } }`, + `{"data": {"account": {"domain": { + {{ template "id1" }}, + "aliases": ["test-domain"], + "name": "TestDomain", + "description": "A domain with <html> & special characters "quoted"", + "htmlUrl": "https://app.opslevel.com/catalog/domains/test-domain", + "note": "Note with <b>bold</b> and & ampersand", + "managedAliases": [] + }}}}`, + ) + + client := BestTestClient(t, "domain/html_entities", testRequest) + // Act + result, err := client.GetDomain(string(id1)) + // Assert + autopilot.Ok(t, err) + + // Verify HTML entities are unescaped + if strings.Contains(result.Description, "<") || strings.Contains(result.Description, ">") || + strings.Contains(result.Description, "&") || strings.Contains(result.Description, """) { + t.Errorf("Domain Description still contains HTML entities: %s", result.Description) + } + if strings.Contains(result.Note, "<") || strings.Contains(result.Note, ">") || + strings.Contains(result.Note, "&") { + t.Errorf("Domain Note still contains HTML entities: %s", result.Note) + } + + // Verify expected unescaped values + expectedDescription := `A domain with & special characters "quoted"` + expectedNote := "Note with bold and & ampersand" + + autopilot.Equals(t, expectedDescription, result.Description) + autopilot.Equals(t, expectedNote, result.Note) +} diff --git a/service_test.go b/service_test.go index 1d5316b8..f38286d5 100644 --- a/service_test.go +++ b/service_test.go @@ -2,6 +2,7 @@ package opslevel_test import ( "fmt" + "strings" "testing" ol "github.com/opslevel/opslevel-go/v2025" @@ -1669,3 +1670,66 @@ func TestListServicesWithInputFilterCaseSensitivity(t *testing.T) { autopilot.Ok(t, err) autopilot.Equals(t, 0, response.TotalCount) } + +func TestServiceGetWithHTMLEntities(t *testing.T) { + // Arrange + testRequest := autopilot.NewTestRequest( + `query ServiceGet($service:ID!){account{service(id: $service){apiDocumentPath,description,framework,htmlUrl,id,aliases,language,lifecycle{alias,description,id,index,name},locked,managedAliases,maturityReport{overallLevel{alias,checks{id,name},description,id,index,name}},name,note,owner{alias,id},parent{id,aliases},preferredApiDocument{id,htmlUrl,source{... on ApiDocIntegration{id,name,type},... on ServiceRepository{baseDirectory,displayName,id,repository{id,defaultAlias},service{id,aliases}}},timestamps{createdAt,updatedAt}},preferredApiDocumentSource,product,repos{edges{node{id,defaultAlias},serviceRepositories{baseDirectory,displayName,id,repository{id,defaultAlias},service{id,aliases}}},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor}},defaultServiceRepository{baseDirectory,displayName,id,repository{id,defaultAlias},service{id,aliases}},tags{nodes{id,key,value},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor}},tier{alias,description,id,index,name},timestamps{createdAt,updatedAt},tools{nodes{category,categoryAlias,displayName,environment,id,service{id,aliases},url},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor}},type{id,aliases}}}}`, + `{ "service": "{{ template "id1_string" }}" }`, + `{"data": {"account": {"service": { + {{ template "id1" }}, + "aliases": ["test-service"], + "name": "TestService", + "description": "Service with <em>emphasis</em> & "quotes"", + "htmlUrl": "https://app.opslevel.com/services/test-service", + "note": "Additional notes: <strong>important</strong> & critical", + "product": "Product with & ampersand", + "apiDocumentPath": null, + "framework": null, + "language": null, + "lifecycle": null, + "locked": false, + "managedAliases": [], + "maturityReport": null, + "owner": null, + "parent": null, + "preferredApiDocument": null, + "preferredApiDocumentSource": null, + "repos": {"edges": [], "pageInfo": {"hasNextPage": false, "hasPreviousPage": false, "startCursor": "", "endCursor": ""}}, + "defaultServiceRepository": null, + "tags": {"nodes": [], "pageInfo": {"hasNextPage": false, "hasPreviousPage": false, "startCursor": "", "endCursor": ""}}, + "tier": null, + "timestamps": null, + "tools": {"nodes": [], "pageInfo": {"hasNextPage": false, "hasPreviousPage": false, "startCursor": "", "endCursor": ""}}, + "type": null + }}}}`, + ) + + client := BestTestClient(t, "service/html_entities", testRequest) + // Act + result, err := client.GetService(string(id1)) + // Assert + autopilot.Ok(t, err) + + // Verify HTML entities are unescaped + if strings.Contains(result.Description, "<") || strings.Contains(result.Description, ">") || + strings.Contains(result.Description, "&") || strings.Contains(result.Description, """) { + t.Errorf("Service Description still contains HTML entities: %s", result.Description) + } + if strings.Contains(result.Note, "<") || strings.Contains(result.Note, ">") || + strings.Contains(result.Note, "&") { + t.Errorf("Service Note still contains HTML entities: %s", result.Note) + } + if strings.Contains(result.Product, "&") { + t.Errorf("Service Product still contains HTML entities: %s", result.Product) + } + + // Verify expected unescaped values + expectedDescription := `Service with emphasis & "quotes"` + expectedNote := "Additional notes: important & critical" + expectedProduct := "Product with & ampersand" + + autopilot.Equals(t, expectedDescription, result.Description) + autopilot.Equals(t, expectedNote, result.Note) + autopilot.Equals(t, expectedProduct, result.Product) +} diff --git a/system.go b/system.go index fe040415..1e77f6b1 100644 --- a/system.go +++ b/system.go @@ -3,6 +3,7 @@ package opslevel import ( "errors" "fmt" + "html" "slices" ) @@ -71,6 +72,12 @@ func (system *System) UniqueIdentifiers() []string { return uniqueIdentifiers } +func (system *System) Hydrate(client *Client) error { + system.Description = html.UnescapeString(system.Description) + system.Note = html.UnescapeString(system.Note) + return nil +} + func (system *SystemId) ReconcileAliases(client *Client, aliasesWanted []string) error { aliasesToCreate, aliasesToDelete := extractAliases(system.Aliases, aliasesWanted) @@ -139,8 +146,13 @@ func (client *Client) CreateSystem(input SystemInput) (*System, error) { v := PayloadVariables{ "input": input, } - err := client.Mutate(&m, v, WithName("SystemCreate")) - return &m.Payload.System, HandleErrors(err, m.Payload.Errors) + if err := client.Mutate(&m, v, WithName("SystemCreate")); err != nil { + return nil, err + } + if err := m.Payload.System.Hydrate(client); err != nil { + return &m.Payload.System, err + } + return &m.Payload.System, HandleErrors(nil, m.Payload.Errors) } func (client *Client) GetSystem(identifier string) (*System, error) { @@ -152,8 +164,13 @@ func (client *Client) GetSystem(identifier string) (*System, error) { v := PayloadVariables{ "input": *NewIdentifier(identifier), } - err := client.Query(&q, v, WithName("SystemGet")) - return &q.Account.System, HandleErrors(err, nil) + if err := client.Query(&q, v, WithName("SystemGet")); err != nil { + return nil, err + } + if err := q.Account.System.Hydrate(client); err != nil { + return &q.Account.System, err + } + return &q.Account.System, nil } func (client *Client) ListSystems(variables *PayloadVariables) (*SystemConnection, error) { @@ -174,7 +191,12 @@ func (client *Client) ListSystems(variables *PayloadVariables) (*SystemConnectio if err != nil { return nil, err } - q.Account.Systems.Nodes = append(q.Account.Systems.Nodes, resp.Nodes...) + for _, node := range resp.Nodes { + if err := node.Hydrate(client); err != nil { + return nil, err + } + q.Account.Systems.Nodes = append(q.Account.Systems.Nodes, node) + } q.Account.Systems.PageInfo = resp.PageInfo } q.Account.Systems.TotalCount = len(q.Account.Systems.Nodes) @@ -189,8 +211,13 @@ func (client *Client) UpdateSystem(identifier string, input SystemInput) (*Syste "system": *NewIdentifier(identifier), "input": input, } - err := client.Mutate(&s, v, WithName("SystemUpdate")) - return &s.Payload.System, HandleErrors(err, s.Payload.Errors) + if err := client.Mutate(&s, v, WithName("SystemUpdate")); err != nil { + return nil, err + } + if err := s.Payload.System.Hydrate(client); err != nil { + return &s.Payload.System, err + } + return &s.Payload.System, HandleErrors(nil, s.Payload.Errors) } func (client *Client) DeleteSystem(identifier string) error { diff --git a/system_test.go b/system_test.go index 7eac85e9..7eb9ca8c 100644 --- a/system_test.go +++ b/system_test.go @@ -1,6 +1,7 @@ package opslevel_test import ( + "strings" "testing" ol "github.com/opslevel/opslevel-go/v2025" @@ -325,3 +326,52 @@ func TestSystemReconcileAliases(t *testing.T) { autopilot.Ok(t, err) autopilot.Equals(t, system.Aliases, aliasesWanted) } + +func TestSystemGetWithHTMLEntities(t *testing.T) { + // Arrange + testRequest := autopilot.NewTestRequest( + `query SystemGet($input:IdentifierInput!){account{system(input: $input){id,aliases,description,htmlUrl,managedAliases,name,note,owner{... on Team{teamAlias:alias,id}},parent{id,aliases,description,htmlUrl,managedAliases,name,note,owner{... on Team{teamAlias:alias,id}}}}}}`, + `{"input": { {{ template "id1" }} } }`, + `{"data": {"account": {"system": { + {{ template "id1" }}, + "aliases": ["test-system"], + "name": "TestSystem", + "description": "System with <script>alert('xss')</script> and & symbol", + "htmlUrl": "https://app.opslevel.com/catalog/systems/test-system", + "note": "Note: <p>paragraph</p> & more "text"", + "managedAliases": [], + "parent": { + {{ template "id2" }}, + "aliases": [], + "name": "ParentDomain", + "description": "", + "htmlUrl": "https://app.opslevel.com/catalog/domains/parent", + "note": "", + "managedAliases": [] + } + }}}}`, + ) + + client := BestTestClient(t, "system/html_entities", testRequest) + // Act + result, err := client.GetSystem(string(id1)) + // Assert + autopilot.Ok(t, err) + + // Verify HTML entities are unescaped + if strings.Contains(result.Description, "<") || strings.Contains(result.Description, ">") || + strings.Contains(result.Description, "&") || strings.Contains(result.Description, "'") { + t.Errorf("System Description still contains HTML entities: %s", result.Description) + } + if strings.Contains(result.Note, "<") || strings.Contains(result.Note, ">") || + strings.Contains(result.Note, "&") || strings.Contains(result.Note, """) { + t.Errorf("System Note still contains HTML entities: %s", result.Note) + } + + // Verify expected unescaped values + expectedDescription := "System with and & symbol" + expectedNote := `Note:

paragraph

& more "text"` + + autopilot.Equals(t, expectedDescription, result.Description) + autopilot.Equals(t, expectedNote, result.Note) +}