Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions web/src/ar/common/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,3 +47,5 @@ export function getPackageTypesForApiQueryParams(packageTypes: RepositoryPackage
export const encodeFileName = (fileName: string): string => {
return fileName.replace(INVALID_FILENAME_CHARS, '_')
}

export { decodeHtmlEntities } from './utils/decodeHtmlEntities'
40 changes: 40 additions & 0 deletions web/src/ar/common/utils/decodeHtmlEntities.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
/*
* Copyright 2024 Harness, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

/**
* Safely decodes HTML entities in a string without XSS vulnerability.
* Uses DOMParser which is safer than innerHTML as it doesn't execute scripts.
*
* @param text - The text containing HTML entities to decode
* @returns The decoded text with HTML entities converted to their character equivalents
*
* @example
* decodeHtmlEntities('github.com/rs/xid') // returns 'github.com/rs/xid'
* decodeHtmlEntities('&lt;script&gt;alert(1)&lt;/script&gt;') // returns '<script>alert(1)</script>' (safe, no execution)
*/
Comment on lines +25 to +27

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

JSDoc second example has an incorrect return value.

When '&lt;script&gt;alert(1)&lt;/script&gt;' is passed to DOMParser.parseFromString, the HTML tokenizer decodes the entities first, producing <script>alert(1)</script>, which the parser materialises as an actual <script> DOM element. doc.documentElement.textContent then returns 'alert(1)' — the text content of the script node — not '<script>alert(1)</script>' as the comment claims.

The security note ("safe, no execution") is correct, but the stated return value is misleading. More broadly, any decoded string that forms valid HTML elements (e.g. &lt;b&gt;text&lt;/b&gt;) would have the tag markup stripped from the result, which could cause a display mismatch if package keys ever contain <, > encoded as HTML entities.

📝 Corrected JSDoc example
- * decodeHtmlEntities('&lt;script&gt;alert(1)&lt;/script&gt;') // returns '<script>alert(1)</script>' (safe, no execution)
+ * decodeHtmlEntities('&lt;script&gt;alert(1)&lt;/script&gt;') // returns 'alert(1)' (HTML tags stripped; safe, no execution)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
* decodeHtmlEntities('github.com&#x2F;rs&#x2F;xid') // returns 'github.com/rs/xid'
* decodeHtmlEntities('&lt;script&gt;alert(1)&lt;/script&gt;') // returns '<script>alert(1)</script>' (safe, no execution)
*/
* decodeHtmlEntities('github.com&#x2F;rs&#x2F;xid') // returns 'github.com/rs/xid'
* decodeHtmlEntities('&lt;script&gt;alert(1)&lt;/script&gt;') // returns 'alert(1)' (HTML tags stripped; safe, no execution)
*/
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web/src/ar/common/utils/decodeHtmlEntities.ts` around lines 25 - 27, Update
the JSDoc for decodeHtmlEntities to correct the second example and clarify
behavior: change the example input '&lt;script&gt;alert(1)&lt;/script&gt;' to
show that the function returns the script element's textContent (e.g.
'alert(1)') rather than the literal '<script>...</script>', and add a brief note
that any decoded input that forms real HTML elements will have tags stripped
because the function uses DOMParser/documentElement.textContent; reference the
decodeHtmlEntities function name so the comment sits next to the implementation.

export const decodeHtmlEntities = (text: string): string => {
if (!text || typeof text !== 'string') {
return text
}

// Use DOMParser which is safer than innerHTML
// It parses the content as text/html but doesn't execute scripts
const parser = new DOMParser()
const doc = parser.parseFromString(text, 'text/html')

// Extract the text content which automatically decodes entities
return doc.documentElement.textContent || text
}
8 changes: 7 additions & 1 deletion web/src/ar/components/Form/DeleteModalContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ interface DeleteModalContentProps {
content: string
placeholder: string
inputLabel: string
inputLabelValue?: string
deleteBtnText?: string
}

Expand All @@ -41,9 +42,14 @@ function DeleteModalContent({
content,
placeholder,
inputLabel,
inputLabelValue,
deleteBtnText
}: DeleteModalContentProps) {
const { getString } = useStrings()

// Construct the label with the value appended if provided
const finalLabel = inputLabelValue ? `${inputLabel} (${inputLabelValue})` : inputLabel

return (
<Formik
initialValues={{ value: '' }}
Expand All @@ -61,7 +67,7 @@ function DeleteModalContent({
<Container>
<Layout.Vertical spacing="medium">
<Text>{content}</Text>
<FormInput.Text label={inputLabel} name="value" placeholder={placeholder} intent={Intent.PRIMARY} />
<FormInput.Text label={finalLabel} name="value" placeholder={placeholder} intent={Intent.PRIMARY} />
</Layout.Vertical>
<Layout.Horizontal spacing="medium" margin={{ top: 'large' }}>
<Button
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { useGetSpaceRef, useParentHooks } from '@ar/hooks'
import { useStrings } from '@ar/frameworks/strings'
import { encodeRef } from '@ar/hooks/useGetSpaceRef'
import DeleteModalContent from '@ar/components/Form/DeleteModalContent'
import { decodeHtmlEntities } from '@ar/common/utils'

interface useDeleteArtifactModalProps {
repoKey: string
Expand All @@ -39,6 +40,9 @@ export default function useDeleteArtifactModal(props: useDeleteArtifactModalProp

const { mutateAsync: deleteArtifact } = useDeleteArtifactMutation()

// Decode HTML entities to display properly
const decodedArtifactKey = decodeHtmlEntities(artifactKey)

const handleDeleteArtifact = async (): Promise<void> => {
try {
const response = await deleteArtifact({
Expand Down Expand Up @@ -67,12 +71,13 @@ export default function useDeleteArtifactModal(props: useDeleteArtifactModalProp
contentText: (
<DeleteModalContent
entity="package"
value={artifactKey}
value={decodedArtifactKey}
onSubmit={handleDeleteArtifact}
onClose={handleCloseDialog}
content={getString('artifactDetails.deleteModal.contentText')}
placeholder={getString('artifactDetails.deleteModal.inputPlaceholder')}
inputLabel={getString('artifactDetails.deleteModal.inputLabel')}
inputLabelValue={decodedArtifactKey}
/>
),
customButtons: <></>,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { useDeleteRegistryMutation } from '@harnessio/react-har-service-client'
import { useStrings } from '@ar/frameworks/strings'
import { useGetSpaceRef, useParentHooks } from '@ar/hooks'
import DeleteModalContent from '@ar/components/Form/DeleteModalContent'
import { decodeHtmlEntities } from '@ar/common/utils'

interface useDeleteRepositoryModalProps {
repoKey: string
Expand All @@ -36,6 +37,9 @@ export default function useDeleteRepositoryModal(props: useDeleteRepositoryModal

const { mutateAsync: deleteRepository } = useDeleteRegistryMutation()

// Decode HTML entities to display properly
const decodedRepoKey = decodeHtmlEntities(repoKey)

const handleDeleteRepository = async (): Promise<void> => {
try {
const response = await deleteRepository({
Expand All @@ -60,12 +64,13 @@ export default function useDeleteRepositoryModal(props: useDeleteRepositoryModal
contentText: (
<DeleteModalContent
entity="repository"
value={repoKey}
value={decodedRepoKey}
onSubmit={handleDeleteRepository}
onClose={handleCloseDialog}
content={getString('repositoryList.deleteModal.contentText')}
placeholder={getString('repositoryList.deleteModal.inputPlaceholder')}
inputLabel={getString('repositoryList.deleteModal.inputLabel')}
inputLabelValue={decodedRepoKey}
/>
),
customButtons: <></>,
Expand Down
4 changes: 2 additions & 2 deletions web/src/ar/pages/repository-list/strings/strings.en.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,8 @@ compact: Compact View
deleteModal:
title: '{{ $.artifactList.table.actions.deleteRepository }}'
contentText: This action will delete registry and all it's images and versions. Are you absolutely sure you want to proceed?
inputLabel: Type the registry name or ID to confirm
inputPlaceholder: Registry Name or ID
inputLabel: Type the registry name to confirm
inputPlaceholder: Registry Name
softDeleteModal:
title: Archive Registry
contentText: This action will archive registry and all it's artifacts and versions. You can still restore it from the archived list.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,14 @@ import React from 'react'
import { useStrings } from '@ar/frameworks/strings'
import { useParentComponents } from '@ar/hooks'
import { PermissionIdentifier, ResourceType } from '@ar/common/permissionTypes'
import type { RepositoryPackageType } from '@ar/common/types'

import type { VersionActionProps } from './types'
import useDeleteVersionModal from '../../hooks/useDeleteVersionModal'
import { useUtilsForDeleteVersion } from '../../hooks/useUtilsForDeleteVersion'

export default function DeleteVersionMenuItem(props: VersionActionProps): JSX.Element {
const { artifactKey, repoKey, readonly, onClose, versionKey, pageType, data } = props
const { artifactKey, repoKey, readonly, onClose, versionKey, pageType, data, digest } = props
const { getString } = useStrings()
const { RbacMenuItem } = useParentComponents()

Expand All @@ -41,6 +42,8 @@ export default function DeleteVersionMenuItem(props: VersionActionProps): JSX.El
repoKey,
versionKey,
artifactType: data?.artifactType,
packageType: data?.packageType as RepositoryPackageType,
digest,
onSuccess: handleAfterDeleteVersion
})

Expand Down
21 changes: 19 additions & 2 deletions web/src/ar/pages/version-details/hooks/useDeleteVersionModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,22 +23,38 @@ import { useConfirmationDialog, useGetSpaceRef } from '@ar/hooks'
import { useStrings } from '@ar/frameworks/strings'
import { encodeRef } from '@ar/hooks/useGetSpaceRef'
import DeleteModalContent from '@ar/components/Form/DeleteModalContent'
import { RepositoryPackageType } from '@ar/common/types'
import { decodeHtmlEntities } from '@ar/common/utils'

interface useDeleteVersionModalProps {
repoKey: string
artifactKey: string
versionKey: string
artifactType?: ArtifactType
packageType?: RepositoryPackageType
digest?: string
onSuccess: () => void
}
export default function useDeleteVersionModal(props: useDeleteVersionModalProps) {
const { repoKey, onSuccess, artifactKey, versionKey, artifactType } = props
const { repoKey, onSuccess, artifactKey, versionKey, artifactType, packageType, digest } = props
const { getString } = useStrings()
const { showSuccess, showError, clear } = useToaster()
const spaceRef = useGetSpaceRef(repoKey)

const { mutateAsync: deleteVersion } = useDeleteArtifactVersionMutation()

// For OCI packages (Docker/Helm), use digest; for non-OCI packages, use version
const isDockerPackage = packageType === RepositoryPackageType.DOCKER
const isHelmPackage = packageType === RepositoryPackageType.HELM
const isOCIPackage = isDockerPackage || isHelmPackage

// Determine which value to use for confirmation
const shouldUseDigest = isOCIPackage && digest
const confirmationValue = shouldUseDigest ? digest : versionKey

// Decode HTML entities to display properly
const decodedConfirmationValue = decodeHtmlEntities(confirmationValue)
Comment on lines +52 to +56

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

cat -n web/src/ar/pages/version-details/hooks/useDeleteVersionModal.tsx | head -100

Repository: abhinavcode/harness

Length of output: 4524


🏁 Script executed:

find . -name "tsconfig.json" -o -name "tsconfig*.json" | head -5

Repository: abhinavcode/harness

Length of output: 172


🏁 Script executed:

cat web/tsconfig.json

Repository: abhinavcode/harness

Length of output: 862


confirmationValue has type string | undefined — potential TypeScript error under strict mode.

TypeScript does not narrow digest through the intermediate shouldUseDigest variable. When shouldUseDigest ? digest : versionKey is evaluated, TS sees digest as string | undefined in the truthy branch, making confirmationValue: string | undefined. Passing it to decodeHtmlEntities(text: string) violates the type contract under strict mode.

Use an inline condition so TypeScript can narrow digest directly:

🛠️ Proposed fix
-  const shouldUseDigest = isOCIPackage && digest
-  const confirmationValue = shouldUseDigest ? digest : versionKey
+  const confirmationValue = (isOCIPackage && digest) ? digest : versionKey
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const shouldUseDigest = isOCIPackage && digest
const confirmationValue = shouldUseDigest ? digest : versionKey
// Decode HTML entities to display properly
const decodedConfirmationValue = decodeHtmlEntities(confirmationValue)
const confirmationValue = (isOCIPackage && digest) ? digest : versionKey
// Decode HTML entities to display properly
const decodedConfirmationValue = decodeHtmlEntities(confirmationValue)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web/src/ar/pages/version-details/hooks/useDeleteVersionModal.tsx` around
lines 58 - 62, The variable confirmationValue is inferred as string | undefined
because digest isn't narrowed through shouldUseDigest; change the assignment to
use an inline conditional that directly narrows digest (e.g. const
confirmationValue = isOCIPackage && digest ? digest : versionKey) so TypeScript
knows the truthy branch is string, then pass confirmationValue to
decodeHtmlEntities(confirmationValue); if you cannot guarantee a string, provide
a safe fallback or explicit non-null assertion where used.


const handleDeleteVersion = async (): Promise<void> => {
try {
const response = await deleteVersion({
Expand Down Expand Up @@ -69,12 +85,13 @@ export default function useDeleteVersionModal(props: useDeleteVersionModalProps)
contentText: (
<DeleteModalContent
entity="version"
value={versionKey}
value={decodedConfirmationValue}
onSubmit={handleDeleteVersion}
onClose={handleCloseDialog}
content={getString('versionDetails.deleteVersionModal.contentText')}
placeholder={getString('versionDetails.deleteVersionModal.inputPlaceholder')}
inputLabel={getString('versionDetails.deleteVersionModal.inputLabel')}
inputLabelValue={decodedConfirmationValue}
/>
),
customButtons: <></>,
Expand Down