mirror of
https://github.com/argoproj/argo-cd.git
synced 2026-03-30 21:38:49 +02:00
fix: invalid URL or protocol not validated consistently by server and UI (#27052)
Signed-off-by: Atif Ali <atali@redhat.com>
This commit is contained in:
@@ -62,6 +62,17 @@ func SanitizeCluster(cluster *v1alpha1.Cluster) (*unstructured.Unstructured, err
|
||||
})
|
||||
}
|
||||
|
||||
func managedByURLFromAnnotations(annotations map[string]any) (string, bool) {
|
||||
managedByURL, ok := annotations[v1alpha1.AnnotationKeyManagedByURL].(string)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
if err := settings.ValidateExternalURL(managedByURL); err != nil {
|
||||
return "", false
|
||||
}
|
||||
return managedByURL, true
|
||||
}
|
||||
|
||||
func CreateDeepLinksObject(resourceObj *unstructured.Unstructured, app *unstructured.Unstructured, cluster *unstructured.Unstructured, project *unstructured.Unstructured) map[string]any {
|
||||
deeplinkObj := map[string]any{}
|
||||
if resourceObj != nil {
|
||||
@@ -72,12 +83,10 @@ func CreateDeepLinksObject(resourceObj *unstructured.Unstructured, app *unstruct
|
||||
deeplinkObj[AppDeepLinkShortKey] = app.Object
|
||||
|
||||
// Add managed-by URL if present in annotations
|
||||
if app.Object["metadata"] != nil {
|
||||
if metadata, ok := app.Object["metadata"].(map[string]any); ok {
|
||||
if annotations, ok := metadata["annotations"].(map[string]any); ok {
|
||||
if managedByURL, ok := annotations[v1alpha1.AnnotationKeyManagedByURL].(string); ok {
|
||||
deeplinkObj[ManagedByURLKey] = managedByURL
|
||||
}
|
||||
if metadata, ok := app.Object["metadata"].(map[string]any); ok {
|
||||
if annotations, ok := metadata["annotations"].(map[string]any); ok {
|
||||
if managedByURL, ok := managedByURLFromAnnotations(annotations); ok {
|
||||
deeplinkObj[ManagedByURLKey] = managedByURL
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -237,6 +237,29 @@ func TestManagedByURLAnnotation(t *testing.T) {
|
||||
assert.Equal(t, managedByURL, deeplinksObj[ManagedByURLKey])
|
||||
})
|
||||
|
||||
t.Run("application with invalid managed-by-url annotation is omitted", func(t *testing.T) {
|
||||
// Non http(s) protocols are invalid and should not be used in deep link generation.
|
||||
managedByURL := "ftp://localhost:8081"
|
||||
|
||||
app := &v1alpha1.Application{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test-app",
|
||||
Annotations: map[string]string{
|
||||
v1alpha1.AnnotationKeyManagedByURL: managedByURL,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
obj, err := runtime.DefaultUnstructuredConverter.ToUnstructured(app)
|
||||
require.NoError(t, err)
|
||||
unstructuredObj := &unstructured.Unstructured{Object: obj}
|
||||
|
||||
deeplinksObj := CreateDeepLinksObject(nil, unstructuredObj, nil, nil)
|
||||
|
||||
_, exists := deeplinksObj[ManagedByURLKey]
|
||||
assert.False(t, exists)
|
||||
})
|
||||
|
||||
t.Run("application without managed-by-url annotation", func(t *testing.T) {
|
||||
// Create an application without managed-by-url annotation
|
||||
app := &v1alpha1.Application{
|
||||
|
||||
@@ -12,13 +12,16 @@ import {
|
||||
createdOrNodeKey,
|
||||
resourceStatusToResourceNode,
|
||||
getApplicationLinkURLFromNode,
|
||||
getManagedByURLFromNode
|
||||
getManagedByURLFromNode,
|
||||
MANAGED_BY_URL_INVALID_TEXT,
|
||||
MANAGED_BY_URL_INVALID_COLOR
|
||||
} from '../utils';
|
||||
import {AppDetailsPreferences} from '../../../shared/services';
|
||||
import {Consumer} from '../../../shared/context';
|
||||
import Moment from 'react-moment';
|
||||
import {format} from 'date-fns';
|
||||
import {HealthPriority, ResourceNode, SyncPriority, SyncStatusCode} from '../../../shared/models';
|
||||
import {isValidManagedByURL} from '../../../shared/utils';
|
||||
import './application-resource-list.scss';
|
||||
|
||||
export interface ApplicationResourceListProps {
|
||||
@@ -201,6 +204,20 @@ export const ApplicationResourceList = (props: ApplicationResourceListProps) =>
|
||||
? getApplicationLinkURLFromNode(node, ctx.baseHref)
|
||||
: {url: ctx.baseHref + 'applications/' + res.namespace + '/' + res.name, isExternal: false};
|
||||
const managedByURL = node ? getManagedByURLFromNode(node) : null;
|
||||
const managedByURLInvalid = !!managedByURL && !isValidManagedByURL(managedByURL);
|
||||
if (managedByURLInvalid) {
|
||||
return (
|
||||
<span
|
||||
className='application-details__external_link'
|
||||
style={{cursor: 'not-allowed', display: 'inline-flex', alignItems: 'center'}}
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
title={`Open application\n${MANAGED_BY_URL_INVALID_TEXT}`}>
|
||||
<i className='fa fa-external-link-alt' style={{color: MANAGED_BY_URL_INVALID_COLOR}} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className='application-details__external_link'>
|
||||
<a
|
||||
|
||||
@@ -6,6 +6,7 @@ import Moment from 'react-moment';
|
||||
import * as moment from 'moment';
|
||||
|
||||
import * as models from '../../../shared/models';
|
||||
import {isValidManagedByURL, MANAGED_BY_URL_INVALID_TEXT, MANAGED_BY_URL_INVALID_COLOR} from '../../../shared/utils';
|
||||
|
||||
import {EmptyState} from '../../../shared/components';
|
||||
import {AppContext, Consumer} from '../../../shared/context';
|
||||
@@ -496,6 +497,20 @@ function renderPodGroup(props: ApplicationResourceTreeProps, id: string, node: R
|
||||
{ctx => {
|
||||
// For nested applications, use the node's data to construct the URL
|
||||
const linkInfo = getApplicationLinkURLFromNode(node, ctx.baseHref);
|
||||
const managedByURL = getManagedByURLFromNode(node);
|
||||
const managedByURLInvalid = !!managedByURL && !isValidManagedByURL(managedByURL);
|
||||
if (managedByURLInvalid) {
|
||||
return (
|
||||
<span
|
||||
role='link'
|
||||
aria-disabled={true}
|
||||
style={{cursor: 'not-allowed', display: 'inline-flex', alignItems: 'center'}}
|
||||
onClick={e => e.stopPropagation()}
|
||||
title={`Open application\n${MANAGED_BY_URL_INVALID_TEXT}`}>
|
||||
<i className='fa fa-external-link-alt' style={{color: MANAGED_BY_URL_INVALID_COLOR}} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<a
|
||||
href={linkInfo.url}
|
||||
@@ -504,7 +519,7 @@ function renderPodGroup(props: ApplicationResourceTreeProps, id: string, node: R
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
title={getManagedByURLFromNode(node) ? `Open application\nmanaged-by-url: ${getManagedByURLFromNode(node)}` : 'Open application'}>
|
||||
title={managedByURL ? `Open application\nmanaged-by-url: ${managedByURL}` : 'Open application'}>
|
||||
<i className='fa fa-external-link-alt' />
|
||||
</a>
|
||||
);
|
||||
@@ -806,6 +821,20 @@ function renderResourceNode(props: ApplicationResourceTreeProps, id: string, nod
|
||||
{ctx => {
|
||||
// For nested applications, use the node's data to construct the URL
|
||||
const linkInfo = getApplicationLinkURLFromNode(node, ctx.baseHref);
|
||||
const managedByURL = getManagedByURLFromNode(node);
|
||||
const managedByURLInvalid = !!managedByURL && !isValidManagedByURL(managedByURL);
|
||||
if (managedByURLInvalid) {
|
||||
return (
|
||||
<span
|
||||
role='link'
|
||||
aria-disabled={true}
|
||||
style={{cursor: 'not-allowed', display: 'inline-flex', alignItems: 'center'}}
|
||||
onClick={e => e.stopPropagation()}
|
||||
title={`Open application\n${MANAGED_BY_URL_INVALID_TEXT}`}>
|
||||
<i className='fa fa-external-link-alt' style={{color: MANAGED_BY_URL_INVALID_COLOR}} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<a
|
||||
href={linkInfo.url}
|
||||
@@ -814,7 +843,7 @@ function renderResourceNode(props: ApplicationResourceTreeProps, id: string, nod
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
title={getManagedByURLFromNode(node) ? `Open application\nmanaged-by-url: ${getManagedByURLFromNode(node)}` : 'Open application'}>
|
||||
title={managedByURL ? `Open application\nmanaged-by-url: ${managedByURL}` : 'Open application'}>
|
||||
<i className='fa fa-external-link-alt' />
|
||||
</a>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {DropDownMenu, Tooltip} from 'argo-ui';
|
||||
import {DropDownMenu, NotificationType, Tooltip} from 'argo-ui';
|
||||
import * as React from 'react';
|
||||
import Moment from 'react-moment';
|
||||
import {Cluster} from '../../../shared/components';
|
||||
@@ -6,7 +6,8 @@ import {ContextApis} from '../../../shared/context';
|
||||
import * as models from '../../../shared/models';
|
||||
import {ApplicationURLs} from '../application-urls';
|
||||
import * as AppUtils from '../utils';
|
||||
import {getAppDefaultSource, OperationState, getApplicationLinkURL, getManagedByURL} from '../utils';
|
||||
import {getAppDefaultSource, OperationState, getApplicationLinkURL, getManagedByURL, MANAGED_BY_URL_INVALID_TEXT, MANAGED_BY_URL_INVALID_TOOLTIP} from '../utils';
|
||||
import {isValidManagedByURL} from '../../../shared/utils';
|
||||
import {ApplicationsLabels} from './applications-labels';
|
||||
import {ApplicationsSource} from './applications-source';
|
||||
import {services} from '../../../shared/services';
|
||||
@@ -27,6 +28,8 @@ export const ApplicationTableRow = ({app, selected, pref, ctx, syncApplication,
|
||||
const healthStatus = app.status.health.status;
|
||||
const linkInfo = getApplicationLinkURL(app, ctx.baseHref);
|
||||
const source = getAppDefaultSource(app);
|
||||
const managedByURL = getManagedByURL(app);
|
||||
const managedByURLInvalid = !!managedByURL && !isValidManagedByURL(managedByURL);
|
||||
|
||||
const handleFavoriteToggle = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
@@ -40,6 +43,18 @@ export const ApplicationTableRow = ({app, selected, pref, ctx, syncApplication,
|
||||
|
||||
const handleExternalLinkClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (managedByURLInvalid) {
|
||||
ctx.notifications.show({
|
||||
content: (
|
||||
<div>
|
||||
<div style={{fontWeight: 600}}>{MANAGED_BY_URL_INVALID_TEXT}</div>
|
||||
<div style={{marginTop: 6}}>{MANAGED_BY_URL_INVALID_TOOLTIP}</div>
|
||||
</div>
|
||||
),
|
||||
type: NotificationType.Warning
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (linkInfo.isExternal) {
|
||||
window.open(linkInfo.url, '_blank', 'noopener,noreferrer');
|
||||
} else {
|
||||
@@ -92,9 +107,11 @@ export const ApplicationTableRow = ({app, selected, pref, ctx, syncApplication,
|
||||
<span>{app.metadata.name}</span>
|
||||
</Tooltip>
|
||||
<button
|
||||
type='button'
|
||||
className={managedByURLInvalid ? 'managed-by-url-invalid' : undefined}
|
||||
onClick={handleExternalLinkClick}
|
||||
style={{marginLeft: '0.5em'}}
|
||||
title={`Link: ${linkInfo.url}\nmanaged-by-url: ${getManagedByURL(app) || 'none'}`}>
|
||||
style={{marginLeft: '0.5em', cursor: managedByURLInvalid ? 'not-allowed' : undefined}}
|
||||
title={managedByURLInvalid ? MANAGED_BY_URL_INVALID_TEXT : `Link: ${linkInfo.url}\nmanaged-by-url: ${managedByURL || 'none'}`}>
|
||||
<i className='fa fa-external-link-alt' />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {Tooltip} from 'argo-ui';
|
||||
import {NotificationType, Tooltip} from 'argo-ui';
|
||||
import * as classNames from 'classnames';
|
||||
import * as React from 'react';
|
||||
import {Cluster} from '../../../shared/components';
|
||||
@@ -6,7 +6,8 @@ import {ContextApis, AuthSettingsCtx} from '../../../shared/context';
|
||||
import * as models from '../../../shared/models';
|
||||
import {ApplicationURLs} from '../application-urls';
|
||||
import * as AppUtils from '../utils';
|
||||
import {getAppDefaultSource, OperationState, getApplicationLinkURL, getManagedByURL} from '../utils';
|
||||
import {getAppDefaultSource, OperationState, getApplicationLinkURL, getManagedByURL, MANAGED_BY_URL_INVALID_TEXT, MANAGED_BY_URL_INVALID_TOOLTIP} from '../utils';
|
||||
import {isValidManagedByURL} from '../../../shared/utils';
|
||||
import {services} from '../../../shared/services';
|
||||
import {ViewPreferences} from '../../../shared/services';
|
||||
|
||||
@@ -30,6 +31,8 @@ export const ApplicationTile = ({app, selected, pref, ctx, tileRef, syncApplicat
|
||||
const targetRevision = source ? source.targetRevision || 'HEAD' : 'Unknown';
|
||||
const linkInfo = getApplicationLinkURL(app, ctx.baseHref);
|
||||
const healthStatus = app.status.health.status;
|
||||
const managedByURL = getManagedByURL(app);
|
||||
const managedByURLInvalid = !!managedByURL && !isValidManagedByURL(managedByURL);
|
||||
|
||||
const handleFavoriteToggle = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
@@ -43,6 +46,18 @@ export const ApplicationTile = ({app, selected, pref, ctx, tileRef, syncApplicat
|
||||
|
||||
const handleExternalLinkClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (managedByURLInvalid) {
|
||||
ctx.notifications.show({
|
||||
content: (
|
||||
<div>
|
||||
<div style={{fontWeight: 600}}>{MANAGED_BY_URL_INVALID_TEXT}</div>
|
||||
<div style={{marginTop: 6}}>{MANAGED_BY_URL_INVALID_TOOLTIP}</div>
|
||||
</div>
|
||||
),
|
||||
type: NotificationType.Warning
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (linkInfo.isExternal) {
|
||||
window.open(linkInfo.url, '_blank', 'noopener,noreferrer');
|
||||
} else {
|
||||
@@ -67,9 +82,20 @@ export const ApplicationTile = ({app, selected, pref, ctx, tileRef, syncApplicat
|
||||
<div className={app.status.summary?.externalURLs?.length > 0 ? 'columns small-2' : 'columns small-1'}>
|
||||
<div className='applications-list__external-link'>
|
||||
<ApplicationURLs urls={app.status.summary?.externalURLs} />
|
||||
<button onClick={handleExternalLinkClick} title={getManagedByURL(app) ? `Managed by: ${getManagedByURL(app)}` : 'Open application'}>
|
||||
<i className='fa fa-external-link-alt' />
|
||||
</button>
|
||||
{managedByURLInvalid ? (
|
||||
<button
|
||||
type='button'
|
||||
className='managed-by-url-invalid'
|
||||
onClick={handleExternalLinkClick}
|
||||
style={{cursor: 'not-allowed'}}
|
||||
title={MANAGED_BY_URL_INVALID_TEXT}>
|
||||
<i className='fa fa-external-link-alt' />
|
||||
</button>
|
||||
) : (
|
||||
<button type='button' onClick={handleExternalLinkClick} title={managedByURL ? `Managed by: ${managedByURL}` : 'Open application'}>
|
||||
<i className='fa fa-external-link-alt' />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
title={favList?.includes(app.metadata.name) ? 'Remove Favorite' : 'Add Favorite'}
|
||||
className='large-text-height'
|
||||
|
||||
@@ -39,28 +39,36 @@
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.applications-table-source__labels {
|
||||
max-width: 40%;
|
||||
}
|
||||
}
|
||||
|
||||
.applications-list__external-link {
|
||||
button {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
color: inherit;
|
||||
|
||||
&:hover {
|
||||
color: $argo-color-teal-5;
|
||||
.applications-table-source__labels {
|
||||
max-width: 40%;
|
||||
}
|
||||
|
||||
i {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.applications-list__external-link {
|
||||
button {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
color: inherit;
|
||||
|
||||
&:hover {
|
||||
color: $argo-color-teal-5;
|
||||
}
|
||||
|
||||
i {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.applications-list__table-row button.managed-by-url-invalid {
|
||||
color: #f4c030;
|
||||
|
||||
&:hover {
|
||||
color: #f4c030;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,9 +70,26 @@
|
||||
&:hover {
|
||||
color: $argo-color-teal-5;
|
||||
}
|
||||
|
||||
&.managed-by-url-invalid {
|
||||
color: #f4c030;
|
||||
|
||||
&:hover {
|
||||
color: #f4c030;
|
||||
}
|
||||
}
|
||||
|
||||
i {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Table / name column external-link (not under .applications-list__external-link) */
|
||||
.applications-list__table-row button.managed-by-url-invalid {
|
||||
color: #f4c030;
|
||||
|
||||
&:hover {
|
||||
color: #f4c030;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import {Tooltip} from 'argo-ui';
|
||||
import {NotificationType, Tooltip} from 'argo-ui';
|
||||
import * as React from 'react';
|
||||
import Moment from 'react-moment';
|
||||
import {ContextApis} from '../../../shared/context';
|
||||
import * as models from '../../../shared/models';
|
||||
import * as AppUtils from '../utils';
|
||||
import {getApplicationLinkURL, getManagedByURL, getAppSetHealthStatus} from '../utils';
|
||||
import {getApplicationLinkURL, getManagedByURL, getAppSetHealthStatus, MANAGED_BY_URL_INVALID_TEXT, MANAGED_BY_URL_INVALID_TOOLTIP} from '../utils';
|
||||
import {services} from '../../../shared/services';
|
||||
import {ViewPreferences} from '../../../shared/services';
|
||||
import {isValidManagedByURL} from '../../../shared/utils';
|
||||
|
||||
export interface AppSetTableRowProps {
|
||||
appSet: models.ApplicationSet;
|
||||
@@ -19,6 +20,8 @@ export const AppSetTableRow = ({appSet, selected, pref, ctx}: AppSetTableRowProp
|
||||
const favList = pref.appList.favoritesAppList || [];
|
||||
const healthStatus = getAppSetHealthStatus(appSet);
|
||||
const linkInfo = getApplicationLinkURL(appSet, ctx.baseHref);
|
||||
const managedByURL = getManagedByURL(appSet);
|
||||
const managedByURLInvalid = !!managedByURL && !isValidManagedByURL(managedByURL);
|
||||
|
||||
const handleFavoriteToggle = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
@@ -32,6 +35,18 @@ export const AppSetTableRow = ({appSet, selected, pref, ctx}: AppSetTableRowProp
|
||||
|
||||
const handleExternalLinkClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (managedByURLInvalid) {
|
||||
ctx.notifications.show({
|
||||
content: (
|
||||
<div>
|
||||
<div style={{fontWeight: 600}}>{MANAGED_BY_URL_INVALID_TEXT}</div>
|
||||
<div style={{marginTop: 6}}>{MANAGED_BY_URL_INVALID_TOOLTIP}</div>
|
||||
</div>
|
||||
),
|
||||
type: NotificationType.Warning
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (linkInfo.isExternal) {
|
||||
window.open(linkInfo.url, '_blank', 'noopener,noreferrer');
|
||||
} else {
|
||||
@@ -81,9 +96,11 @@ export const AppSetTableRow = ({appSet, selected, pref, ctx}: AppSetTableRowProp
|
||||
<span>{appSet.metadata.name}</span>
|
||||
</Tooltip>
|
||||
<button
|
||||
type='button'
|
||||
className={managedByURLInvalid ? 'managed-by-url-invalid' : undefined}
|
||||
onClick={handleExternalLinkClick}
|
||||
style={{marginLeft: '0.5em'}}
|
||||
title={`Link: ${linkInfo.url}\nmanaged-by-url: ${getManagedByURL(appSet) || 'none'}`}>
|
||||
style={{marginLeft: '0.5em', cursor: managedByURLInvalid ? 'not-allowed' : undefined}}
|
||||
title={managedByURLInvalid ? MANAGED_BY_URL_INVALID_TEXT : `Link: ${linkInfo.url}\nmanaged-by-url: ${managedByURL || 'none'}`}>
|
||||
<i className='fa fa-external-link-alt' />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import {Tooltip} from 'argo-ui';
|
||||
import {NotificationType, Tooltip} from 'argo-ui';
|
||||
import * as React from 'react';
|
||||
import {ContextApis, AuthSettingsCtx} from '../../../shared/context';
|
||||
import * as models from '../../../shared/models';
|
||||
import * as AppUtils from '../utils';
|
||||
import {getApplicationLinkURL, getManagedByURL, getAppSetHealthStatus} from '../utils';
|
||||
import {getApplicationLinkURL, getManagedByURL, getAppSetHealthStatus, MANAGED_BY_URL_INVALID_TEXT, MANAGED_BY_URL_INVALID_TOOLTIP} from '../utils';
|
||||
import {services} from '../../../shared/services';
|
||||
import {ViewPreferences} from '../../../shared/services';
|
||||
import {ResourceIcon} from '../resource-icon';
|
||||
import {isValidManagedByURL} from '../../../shared/utils';
|
||||
|
||||
export interface AppSetTileProps {
|
||||
appSet: models.ApplicationSet;
|
||||
@@ -22,6 +23,8 @@ export const AppSetTile = ({appSet, selected, pref, ctx, tileRef}: AppSetTilePro
|
||||
|
||||
const linkInfo = getApplicationLinkURL(appSet, ctx.baseHref);
|
||||
const healthStatus = getAppSetHealthStatus(appSet);
|
||||
const managedByURL = getManagedByURL(appSet);
|
||||
const managedByURLInvalid = !!managedByURL && !isValidManagedByURL(managedByURL);
|
||||
|
||||
const handleFavoriteToggle = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
@@ -35,6 +38,18 @@ export const AppSetTile = ({appSet, selected, pref, ctx, tileRef}: AppSetTilePro
|
||||
|
||||
const handleExternalLinkClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (managedByURLInvalid) {
|
||||
ctx.notifications.show({
|
||||
content: (
|
||||
<div>
|
||||
<div style={{fontWeight: 600}}>{MANAGED_BY_URL_INVALID_TEXT}</div>
|
||||
<div style={{marginTop: 6}}>{MANAGED_BY_URL_INVALID_TOOLTIP}</div>
|
||||
</div>
|
||||
),
|
||||
type: NotificationType.Warning
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (linkInfo.isExternal) {
|
||||
window.open(linkInfo.url, '_blank', 'noopener,noreferrer');
|
||||
} else {
|
||||
@@ -58,9 +73,20 @@ export const AppSetTile = ({appSet, selected, pref, ctx, tileRef}: AppSetTilePro
|
||||
</div>
|
||||
<div className='columns small-1'>
|
||||
<div className='applications-list__external-link'>
|
||||
<button onClick={handleExternalLinkClick} title={getManagedByURL(appSet) ? `Managed by: ${getManagedByURL(appSet)}` : 'Open application'}>
|
||||
<i className='fa fa-external-link-alt' />
|
||||
</button>
|
||||
{managedByURLInvalid ? (
|
||||
<button
|
||||
type='button'
|
||||
className='managed-by-url-invalid'
|
||||
onClick={handleExternalLinkClick}
|
||||
style={{cursor: 'not-allowed'}}
|
||||
title={MANAGED_BY_URL_INVALID_TEXT}>
|
||||
<i className='fa fa-external-link-alt' />
|
||||
</button>
|
||||
) : (
|
||||
<button type='button' onClick={handleExternalLinkClick} title={managedByURL ? `Managed by: ${managedByURL}` : 'Open application'}>
|
||||
<i className='fa fa-external-link-alt' />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
title={favList?.includes(appSet.metadata.name) ? 'Remove Favorite' : 'Add Favorite'}
|
||||
className='large-text-height'
|
||||
|
||||
@@ -8,7 +8,7 @@ import * as moment from 'moment';
|
||||
import {BehaviorSubject, combineLatest, concat, from, fromEvent, Observable, Observer, Subscription} from 'rxjs';
|
||||
import {debounceTime, map} from 'rxjs/operators';
|
||||
import {AppContext, Context, ContextApis} from '../../shared/context';
|
||||
import {isValidURL} from '../../shared/utils';
|
||||
import {isValidManagedByURL} from '../../shared/utils';
|
||||
import {ResourceTreeNode} from './application-resource-tree/application-resource-tree';
|
||||
|
||||
import {CheckboxField, COLORS, ErrorNotification, Revision} from '../../shared/components';
|
||||
@@ -18,6 +18,14 @@ import {ApplicationSource} from '../../shared/models';
|
||||
|
||||
require('./utils.scss');
|
||||
|
||||
export {
|
||||
MANAGED_BY_URL_INVALID_COLOR,
|
||||
MANAGED_BY_URL_INVALID_TEXT,
|
||||
MANAGED_BY_URL_INVALID_TOOLTIP,
|
||||
managedByURLInvalidLabelStyle,
|
||||
managedByURLInvalidLabelStyleCompact
|
||||
} from '../../shared/utils';
|
||||
|
||||
export interface NodeId {
|
||||
kind: string;
|
||||
namespace: string;
|
||||
@@ -1990,7 +1998,7 @@ export function getApplicationLinkURL(app: any, baseHref: string, node?: any): {
|
||||
let url, isExternal;
|
||||
if (managedByURL) {
|
||||
// Validate the managed-by URL using the same validation as external links
|
||||
if (!isValidURL(managedByURL)) {
|
||||
if (!isValidManagedByURL(managedByURL)) {
|
||||
// If URL is invalid, fall back to local URL for security
|
||||
console.warn(`Invalid managed-by URL for application ${app.metadata.name}: ${managedByURL}`);
|
||||
url = baseHref + 'applications/' + app.metadata.namespace + '/' + app.metadata.name;
|
||||
@@ -2018,7 +2026,7 @@ export function getApplicationLinkURLFromNode(node: any, baseHref: string): {url
|
||||
let url, isExternal;
|
||||
if (managedByURL) {
|
||||
// Validate the managed-by URL using the same validation as external links
|
||||
if (!isValidURL(managedByURL)) {
|
||||
if (!isValidManagedByURL(managedByURL)) {
|
||||
// If URL is invalid, fall back to local URL for security
|
||||
console.warn(`Invalid managed-by URL for application ${node.name}: ${managedByURL}`);
|
||||
url = baseHref + 'applications/' + node.namespace + '/' + node.name;
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
/* eslint-env jest */
|
||||
declare const test: any;
|
||||
declare const expect: any;
|
||||
declare const describe: any;
|
||||
import {concatMaps} from './utils';
|
||||
import {isValidManagedByURL} from './utils';
|
||||
|
||||
test('map concatenation', () => {
|
||||
const map1 = {
|
||||
@@ -12,3 +17,24 @@ test('map concatenation', () => {
|
||||
const map3 = concatMaps(map1, map2);
|
||||
expect(map3).toEqual(new Map(Object.entries({a: '9', b: '2', c: '8'})));
|
||||
});
|
||||
|
||||
describe('isValidManagedByURL', () => {
|
||||
test('accepts http/https URLs', () => {
|
||||
expect(isValidManagedByURL('http://example.com')).toBe(true);
|
||||
expect(isValidManagedByURL('https://example.com')).toBe(true);
|
||||
expect(isValidManagedByURL('https://localhost:8081')).toBe(true);
|
||||
});
|
||||
|
||||
test('rejects non-http(s) protocols', () => {
|
||||
expect(isValidManagedByURL('ftp://localhost:8081')).toBe(false);
|
||||
expect(isValidManagedByURL('file:///etc/passwd')).toBe(false);
|
||||
expect(isValidManagedByURL('javascript:alert(1)')).toBe(false);
|
||||
expect(isValidManagedByURL('data:text/html,<script>alert(1)</script>')).toBe(false);
|
||||
expect(isValidManagedByURL('vbscript:msgbox(1)')).toBe(false);
|
||||
});
|
||||
|
||||
test('rejects invalid URL strings', () => {
|
||||
expect(isValidManagedByURL('not-a-url')).toBe(false);
|
||||
expect(isValidManagedByURL('')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React from 'react';
|
||||
import {useEffect, useState} from 'react';
|
||||
import type {CSSProperties} from 'react';
|
||||
import {Cluster} from './models';
|
||||
|
||||
export function hashCode(str: string) {
|
||||
@@ -38,6 +39,38 @@ export function isValidURL(url: string): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
// managed-by-url is expected to mostly if not always point to another Argo CD instance URL,
|
||||
// so we only consider http/https valid for click-through behavior.
|
||||
export function isValidManagedByURL(url: string): boolean {
|
||||
try {
|
||||
const parsedUrl = new URL(url);
|
||||
return parsedUrl.protocol === 'http:' || parsedUrl.protocol === 'https:';
|
||||
} catch (err) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export const MANAGED_BY_URL_INVALID_TEXT = 'managed-by-url: invalid url provided';
|
||||
export const MANAGED_BY_URL_INVALID_TOOLTIP = 'managed-by-url must be a valid http(s) URL for the managing Argo CD instance. The external link is disabled until this is fixed.';
|
||||
|
||||
export const MANAGED_BY_URL_INVALID_COLOR = '#f4c030';
|
||||
|
||||
export const managedByURLInvalidLabelStyle: CSSProperties = {
|
||||
color: MANAGED_BY_URL_INVALID_COLOR,
|
||||
marginLeft: '0.5em',
|
||||
fontSize: '13px',
|
||||
fontWeight: 500,
|
||||
lineHeight: 1.35,
|
||||
whiteSpace: 'nowrap'
|
||||
};
|
||||
|
||||
export const managedByURLInvalidLabelStyleCompact: CSSProperties = {
|
||||
...managedByURLInvalidLabelStyle,
|
||||
marginLeft: '4px',
|
||||
fontSize: '12px',
|
||||
fontWeight: 600
|
||||
};
|
||||
|
||||
export const colorSchemes = {
|
||||
light: '(prefers-color-scheme: light)',
|
||||
dark: '(prefers-color-scheme: dark)'
|
||||
@@ -81,9 +114,9 @@ export const useSystemTheme = (cb: (theme: string) => void) => {
|
||||
};
|
||||
|
||||
export const useTheme = (props: {theme: string}) => {
|
||||
const [theme, setTheme] = React.useState(getTheme(props.theme));
|
||||
const [theme, setTheme] = useState(getTheme(props.theme));
|
||||
|
||||
React.useEffect(() => {
|
||||
useEffect(() => {
|
||||
let destroyListener: (() => void) | undefined;
|
||||
|
||||
// change theme by system, only register listener when theme is auto
|
||||
|
||||
Reference in New Issue
Block a user