Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
e7aca92
7.42.3-fb-redirectGH1023.0
cnathe Jun 23, 2026
e673a6c
GitHub Issue #1023: Add redirect() helper that uses core-safeRedirect
cnathe Jun 23, 2026
b7bd943
useAppNavigate to replace usage of setting window.location.href with …
cnathe Jun 23, 2026
fa4bbcd
7.42.3-fb-redirectGH1023.1
cnathe Jun 23, 2026
2a31aee
update comment
cnathe Jun 23, 2026
9029e5c
remove if check
cnathe Jun 23, 2026
7815f92
7.42.3-fb-redirectGH1023.2
cnathe Jun 23, 2026
694edfe
Merge remote-tracking branch 'origin/develop' into fb_redirectGH1023
cnathe Jun 24, 2026
7f87456
7.43.1-fb-redirectGH1023.0
cnathe Jun 24, 2026
cd7c23b
Merge remote-tracking branch 'origin/develop' into fb_redirectGH1023
cnathe Jun 25, 2026
32cf66d
7.45.1-fb-redirectGH1023.0
cnathe Jun 25, 2026
cd4f57c
update redirect helper to handle more cases - checking for AppURL, ch…
cnathe Jun 26, 2026
fea6854
more usages of redirect helper
cnathe Jun 26, 2026
9c4b89a
move isSameOrigin tests from Plate designer
cnathe Jun 26, 2026
b4d4256
7.45.1-fb-redirectGH1023.1
cnathe Jun 26, 2026
3bc5a79
Merge remote-tracking branch 'origin/develop' into fb_redirectGH1023
cnathe Jun 26, 2026
7896a53
7.45.2-fb-redirectGH1023.0
cnathe Jun 26, 2026
64bcd70
more usages of redirect helper
cnathe Jun 26, 2026
4c4bea9
7.45.2-fb-redirectGH1023.1
cnathe Jun 26, 2026
aa39f87
add cross-container test cases for getRedirectUrl()
cnathe Jun 29, 2026
f5c3d8d
prefer usages of navigate() over redirect()
cnathe Jun 29, 2026
45a3e56
npm run lint-branch-fix
cnathe Jun 29, 2026
3bbc17e
update release notes
cnathe Jun 29, 2026
ee27ebd
7.45.2
cnathe Jun 29, 2026
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
4 changes: 2 additions & 2 deletions packages/components/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion packages/components/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@labkey/components",
"version": "7.45.1",
"version": "7.45.2",
"description": "Components, models, actions, and utility functions for LabKey applications and pages",
"sideEffects": false,
"files": [
Expand Down
4 changes: 4 additions & 0 deletions packages/components/releaseNotes/components.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
# @labkey/components
Components, models, actions, and utility functions for LabKey applications and pages

### version 7.45.2
*Released*: 29 June 2026
- GitHub Issue #1023: Add redirect() helper that uses core-safeRedirect when necessary to check url before redirecting

### version 7.45.1
*Released*: 25 June 2026
- GH Issue #1164: Don't allow negative delta value during checkin
Expand Down
5 changes: 3 additions & 2 deletions packages/components/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { enableMapSet, enablePatches } from 'immer';
import { applyURL, AppURL, buildURL, spliceURL } from './internal/url/AppURL';
import { AppLink } from './internal/url/AppLink';
import { useAppNavigate } from './internal/url/useAppNavigate';
import { hasParameter, imageURL, toggleParameter } from './internal/url/ActionURL';
import { hasParameter, imageURL, redirect, toggleParameter } from './internal/url/ActionURL';
import { getIntegerSearchParam } from './internal/url/utils';
import { Container } from './internal/components/base/models/Container';
import { hasAllPermissions, hasAnyPermissions, hasPermissions, User } from './internal/components/base/models/User';
Expand Down Expand Up @@ -423,7 +423,7 @@ import {
TestLineageAPIWrapper,
} from './internal/components/lineage/actions';
import { withLineage } from './internal/components/lineage/withLineage';
import { DEFAULT_LINEAGE_DISTANCE, LINEAGE_GRAPH_FILTER_METRIC} from './internal/components/lineage/constants';
import { DEFAULT_LINEAGE_DISTANCE, LINEAGE_GRAPH_FILTER_METRIC } from './internal/components/lineage/constants';
import {
LINEAGE_DIRECTIONS,
LINEAGE_GROUPING_GENERATIONS,
Expand Down Expand Up @@ -1617,6 +1617,7 @@ export {
QuerySort,
quoteValueWithDelimiters,
RANGE_URIS,
redirect,
registerDefaultURLMappers,
registerFilterType,
registerInputRenderer,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ import {
} from './constants';

import { DomainFieldLabel } from './DomainFieldLabel';
import { redirect } from '../../url/ActionURL';

interface AdvancedSettingsProps {
allowUniqueConstraintProperties: boolean;
Expand Down Expand Up @@ -231,7 +232,7 @@ export class AdvancedSettings extends React.PureComponent<AdvancedSettingsProps,
params['providerName'] = ActionURL.getParameter('providerName');
}

window.location.href = ActionURL.buildURL(controller, action, undefined, params);
redirect(ActionURL.buildURL(controller, action, undefined, params));
}
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import React, { FC, memo, useCallback, useMemo } from 'react';
import { ActionURL, Filter } from '@labkey/api';

import { AppURL } from '../../url/AppURL';
import { useAppNavigate } from '../../url/useAppNavigate';
import { FIND_SAMPLES_BY_FILTER_KEY } from '../../app/constants';
import { DisableableMenuItem } from '../samples/DisableableMenuItem';
import { formatDateTime } from '../../util/Date';
Expand Down Expand Up @@ -190,6 +191,7 @@ interface Props {
export const FindDerivativesMenuItem: FC<Props> = memo(props => {
const { baseEntityDataType, baseModel, baseFilter, model, entityDataType, metricFeatureArea, titleCol } = props;
const { api } = useAppContext();
const { navigate } = useAppNavigate();

const viewAndUserFilters = useMemo(
() => (!model.queryInfo ? [] : [].concat(model.viewFilters).concat(model.filterArray)),
Expand Down Expand Up @@ -228,10 +230,19 @@ export const FindDerivativesMenuItem: FC<Props> = memo(props => {
sessionStorage.setItem(getSampleFinderLocalStorageKey(), searchFiltersToJson(filterProps, 0, currentTimestamp));
api.query.incrementClientSideMetricCount(metricFeatureArea, 'sampleFinderFindDerivatives');

window.location.href = AppURL.create('search', FIND_SAMPLES_BY_FILTER_KEY)
.addParam('view', sessionViewName)
.toHref();
}, [api.query, baseFilter, baseModel, entityDataType, metricFeatureArea, model, viewAndUserFilters]);
navigate(AppURL.create('search', FIND_SAMPLES_BY_FILTER_KEY).addParam('view', sessionViewName));
}, [
api.query,
baseEntityDataType,
baseFilter,
baseModel,
entityDataType,
metricFeatureArea,
model,
navigate,
titleCol,
viewAndUserFilters,
]);

if (!model.queryInfo) return null;

Expand Down
112 changes: 112 additions & 0 deletions packages/components/src/internal/url/ActionURL.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
/*
* Copyright (c) 2026 LabKey Corporation. All rights reserved. No portion of this work may be reproduced
* in any form or by any electronic or mechanical means without written permission from LabKey Corporation.
*/
import { ActionURL } from '@labkey/api';

import { getRedirectUrl, isSameOrigin } from './ActionURL';
import { AppURL, buildURL } from './AppURL';

// window.location.origin is "http://localhost" in the jsdom test environment.
const safeRedirectUrl = (returnUrl: string): string =>
ActionURL.buildURL('core', 'safeRedirect', undefined, { returnUrl });

describe('getRedirectUrl', () => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I think we should add some cross-container URLs to these tests

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

done

describe('AppURL', () => {
test('navigates directly to an in-app AppURL via toHref()', () => {
const url = AppURL.create('registry', 'molecule');
expect(url.toHref()).toEqual('#/registry/molecule'); // sanity check: in-app hash path
expect(getRedirectUrl(url)).toEqual('#/registry/molecule');
});

test('navigates directly to a cross-app AppURL via toHref(), never through safeRedirect', () => {
const url = AppURL.create('registry', 'molecule').setProductId('biologics');
expect(url.toHref()).not.toMatch(/^#/); // sanity check: full cross-app URL, not a hash path
expect(getRedirectUrl(url)).toEqual(url.toHref());
expect(getRedirectUrl(url)).not.toContain('safeRedirect');
});
});

describe('same-origin string -> navigates directly', () => {
test.each([
['relative path', '/labkey/MyContainer/some-action.view?rowId=4'],
['relative path with hash', '/labkey/MyContainer/app.view#/samples/123'],
['absolute same-origin URL', 'http://localhost/labkey/MyContainer/some-action.view'],
['root-relative path', '/home/project-begin.view'],
['cross-container relative path', '/labkey/OtherProject/SubFolder/some-action.view?rowId=4'],
['cross-container nested path', '/labkey/OtherProject/Sub/Deep/Folder/project-begin.view'],
[
'cross-container absolute same-origin URL',
'http://localhost/labkey/OtherProject/SubFolder/app.view#/samples/123',
],
])('%s', (_label, url) => {
expect(getRedirectUrl(url)).toEqual(url);
});

test('a buildURL() result navigates directly', () => {
// Mirrors SamplesEditButton: a self-built server action URL is same-origin, so it is not wrapped.
const url = buildURL('publish', 'sampleTypePublishStart.view', { rowId: 4 });
expect(getRedirectUrl(url)).toEqual(url);
expect(getRedirectUrl(url)).not.toContain('safeRedirect');
});
});

describe('cross-origin / opaque string -> routes through safeRedirect', () => {
test.each([
['external https URL', 'https://evil.example.com/phish'],
['protocol-relative URL', '//evil.example.com/phish'],
['userinfo confusion (real host is evil)', 'https://localhost@evil.example.com/phish'],
['backslash trickery', 'https:/\\/\\evil.example.com/phish'],
])('%s', (_label, url) => {
const result = getRedirectUrl(url);
expect(result).toEqual(safeRedirectUrl(url));
expect(result).toContain('core-safeRedirect.view');
expect(result).toContain('returnUrl=');
expect(result).not.toEqual(url);
});

test('javascript: URL has an opaque origin and is routed through safeRedirect', () => {
const url = 'javascript:alert(1)'; // eslint-disable-line no-script-url
expect(getRedirectUrl(url)).toEqual(safeRedirectUrl(url));
});

test('unparseable string is treated as unsafe and routed through safeRedirect', () => {
const url = 'http://[::::::]:not-a-port';
expect(getRedirectUrl(url)).toEqual(safeRedirectUrl(url));
});
});
});

describe('isSameOrigin', () => {
// jsdom sets window.location.origin to 'http://localhost'

test('returns true for a URL on the same origin', () => {
expect(isSameOrigin('http://localhost/some/path')).toBe(true);
});

test('returns true for a root-relative URL (same origin by construction)', () => {
expect(isSameOrigin('/labkey/plate/plateList.view')).toBe(true);
});

test('returns false for a different hostname', () => {
expect(isSameOrigin('http://evil.com/path')).toBe(false);
});

test('returns false for a different scheme', () => {
expect(isSameOrigin('https://localhost/path')).toBe(false);
});

test('returns false for a different port', () => {
expect(isSameOrigin('http://localhost:8080/path')).toBe(false);
});

test('returns false for a javascript: URL (XSS guard)', () => {
expect(isSameOrigin('javascript:alert(1)')).toBe(false);
});

test('returns false for an absolute URL with an invalid host (throws during construction)', () => {
// 'http://a b' has a space in the hostname which is invalid; the URL constructor throws,
// and the catch block returns false.
expect(isSameOrigin('http://a b/path')).toBe(false);
});
});
34 changes: 34 additions & 0 deletions packages/components/src/internal/url/ActionURL.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
import { OrderedMap } from 'immutable';
import { ActionURL, Utils } from '@labkey/api';

import { AppURL } from './AppURL';

// This is similar to LABKEY.Filter.getSortFromUrl, however, it does not assume the urlPrefix.
export function getSortFromUrl(queryString: string, urlPrefix?: string): string {
const params = ActionURL.getParameters(queryString);
Expand Down Expand Up @@ -83,3 +85,35 @@ export function imageURL(iconDir: string, src: string): string {
export function toggleParameter(parameterName: string, value: any): void {
setParameter(parameterName, hasParameter(parameterName) ? undefined : value);
}

// GitHub Issue #1023: Navigate the browser to the given URL, guarding against open-redirect vulnerabilities.
// - AppURL: always constructed by us to point at one of our apps, so it is inherently local and we navigate directly.
// - A string that resolves to the current origin (relative URLs, buildURL() results, same-host absolute URLs):
// inherently local, so we navigate directly.
// - Any other string (a different origin, or an unparseable/opaque value such as a javascript: URL): treated as a
// potential open-redirect target and routed through core/safeRedirect for authoritative server-side validation
// (URLHelper.isAllowableHost, which also honors the admin external-redirect allowlist and the home-page fallback).
// Note: For those rare cases where we do need to go to a trusted external URL, set window.location.href directly instead of using redirect().
export function redirect(url: AppURL | string): void {
window.location.href = getRedirectUrl(url);
}

export function getRedirectUrl(url: AppURL | string): string {
if (url instanceof AppURL) {
return url.toHref();
}

if (isSameOrigin(url)) {
return url;
}

return ActionURL.buildURL('core', 'safeRedirect', undefined, { returnUrl: url });
}

export function isSameOrigin(url: string): boolean {
try {
return new URL(url, window.location.href).origin === window.location.origin;
} catch {
return false;
}
}
9 changes: 5 additions & 4 deletions packages/components/src/internal/url/useAppNavigate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import { useNavigate } from 'react-router-dom';
import { useCallback, useMemo } from 'react';

import { redirect } from './ActionURL';
import { AppURL } from './AppURL';
import { parseAppPath } from './AppLink';

Expand All @@ -16,9 +17,9 @@ export type NavigateFn = (url: string | AppURL, replace?: boolean) => void;
* - If url is an AppURL it navigates via React Router's navigate method
* - If url is a string prefixed with # it will assume the URL is an app path, and use RR's navigate method
* - If url is a string pointing to an app path for the current app it will navigate via RR's navigate method
* - In all other cases it will use window.location.href to navigate to the given URL
* string it navigates via window.location.href = url. If you have an AppURL you should always pass it, instead of
* AppURL.toString() or AppURL.toHref().
* - In all other cases it navigates to the given URL via the redirect() helper, which routes through the
* core/safeRedirect action to guard against open-redirect vulnerabilities. If you have an AppURL you should always
* pass it, instead of AppURL.toString() or AppURL.toHref().
*/
interface AppNavigateState {
goBack: () => void;
Expand All @@ -42,7 +43,7 @@ export function useAppNavigate(): AppNavigateState {
return;
}

window.location.href = url.toString();
redirect(url);
},
[rrNavigate]
);
Expand Down