Skip to content
Merged
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
35 changes: 35 additions & 0 deletions src/common/utils/paginationUtils.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public License,
* v. 2.0. If a copy of the MPL was not distributed with this file, You can
* obtain one at https://www.bahmni.org/license/mplv2hd.
*
* Copyright (C) OpenMRS Inc. OpenMRS is a registered trademark and the OpenMRS
* graphic logo is a trademark of OpenMRS Inc.
*/

// OpenMRS builds absolute pagination links from the request it sees behind the proxy
// (e.g. http://localhost/openmrs/...), which need not match the origin of the browser.
// Only the path and query of the `next` link are kept so that the request is made to the
// current origin. Returns undefined when there is no next page and throws for an invalid link.
export function getNextPageUrl(response) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

suggestion: Both collectAllForms and collectAllPrivileges duplicate the same "push results → check limit → getNextPageUrl → fetch next → recurse" loop. Only the URL computation was extracted into this shared util, which is likely why the two copies have already drifted (one has catch + loop-guard, the other doesn't — see comments on FormPrivilegeTable.jsx).

Consider extracting a shared "fetch all pages" helper here that handles accumulation, getNextPageUrl, repeated-link detection, and error propagation, so both call sites can't diverge again.

const links = Array.isArray(response && response.links) ? response.links : [];
const nextLink = links.find((link) => link.rel === 'next');
if (!nextLink) {
return undefined;
}
let nextUrl;
try {
if (!nextLink.uri) {
throw new Error('missing uri');
}
nextUrl = new URL(nextLink.uri, window.location.origin);
} catch (e) {
throw new Error(`Invalid pagination link: ${nextLink.uri}`);
}
// A path starting with two slashes would be read as a network-path reference to another host
if ((nextUrl.protocol !== 'http:' && nextUrl.protocol !== 'https:')

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

question: This host-confusion guard works correctly against the dot-segment/backslash/triple-slash vectors I tried, but only because host/origin are discarded entirely and the URL parser normalizes backslashes/dot-segments before pathname is read here. Worth a one-line comment calling that invariant out explicitly, so a future refactor doesn't swap in nextUrl.href/host and silently reopen this?

|| /^[\\/]{2}/.test(nextUrl.pathname)) {
throw new Error(`Invalid pagination link: ${nextLink.uri}`);
}
return `${nextUrl.pathname}${nextUrl.search}`;
Comment thread
anushkadubey-star marked this conversation as resolved.
}
25 changes: 12 additions & 13 deletions src/form-builder/components/FormBuilderContainer.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
import get from 'lodash/get';
import map from 'lodash/map';
import sortBy from 'lodash/sortBy';
import { getNextPageUrl } from 'common/utils/paginationUtils';
import formHelper from '../helpers/formHelper';
import { fetchAllowedDomains } from '../helpers/hyperlinkValidationHelper';
import { connect } from 'react-redux';
Expand Down Expand Up @@ -48,36 +49,34 @@
}

getFormData() {
const initialForms = [];

Check warning on line 52 in src/form-builder/components/FormBuilderContainer.jsx

View workflow job for this annotation

GitHub Actions / Build & Package

'initialForms' is assigned a value but never used
const forms = [];
const queryParams = '?=';

Check warning on line 54 in src/form-builder/components/FormBuilderContainer.jsx

View workflow job for this annotation

GitHub Actions / Build & Package

'queryParams' is assigned a value but never used
const fetchFormsUrl = `${formBuilderConstants.formUrl}?v=custom:(id,uuid,name,version,published,auditInfo)`;

Check warning on line 55 in src/form-builder/components/FormBuilderContainer.jsx

View workflow job for this annotation

GitHub Actions / Build & Package

Line 55 exceeds the maximum line length of 100
return httpInterceptor.get(fetchFormsUrl)
.then((initialForms) => {
this.collectAllForms(initialForms, forms);
})
.then((initialForms) => this.collectAllForms(initialForms, forms))
Comment thread
anushkadubey-star marked this conversation as resolved.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

nit: requestedUrls starts empty here, so it never includes the initial fetchFormsUrl. A next link that cycled back to page 1's exact URL wouldn't be caught by this guard (though any further repeat would be). Low-likelihood given real OpenMRS URL formats differ between the initial and next requests, but could seed requestedUrls with the initial URL for completeness.

.catch((error) => {
this.showErrors(error);
this.setState({ loading: false });
});
}

collectAllForms(initialForms, forms) {
collectAllForms(initialForms, forms, requestedUrls = []) {
forms.push(...initialForms.results);
if (forms.length === formBuilderConstants.dataLimit) {
this.setState({ data: this.orderFormByVersion(forms), loading: false });
return;
return Promise.resolve();
}
if (initialForms.links !== undefined && initialForms.links.length > 0 && initialForms.links.find(link => link.rel === 'next') !== undefined) {
httpInterceptor.get(initialForms.links[0].uri)
.then((privileges) => this.collectAllForms(privileges, forms))
.catch((error) => {
this.showErrors(error);
this.setState({ loading: false });
});
} else {
const nextUrl = getNextPageUrl(initialForms);
if (!nextUrl) {
this.setState({ data: this.orderFormByVersion(forms), loading: false });
return Promise.resolve();
}
if (requestedUrls.includes(nextUrl)) {
throw new Error('Unable to load all forms: repeated pagination link');
}
return httpInterceptor.get(nextUrl)
.then((nextForms) => this.collectAllForms(nextForms, forms, [...requestedUrls, nextUrl]));
}

getDefaultLocale() {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

question: getFormData/collectAllForms were specifically reworked in this PR to return their promise chains so componentDidMount can .then() off full completion. Should getDefaultLocale() follow the same pattern (return its promise) for consistency, even though nothing currently awaits it?

Expand Down
21 changes: 3 additions & 18 deletions src/form-builder/components/FormPrivilegeTable.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { selectSource, setChangedProperty } from 'form-builder/actions/control';
import { commonConstants } from 'common/constants';
import filter from 'lodash/filter';
import { httpInterceptor } from 'common/utils/httpInterceptor';
import { getNextPageUrl } from 'common/utils/paginationUtils';
import Select from 'react-select';
import { UrlHelper } from 'form-builder/helpers/UrlHelper';
import sortBy from 'lodash/sortBy';
Expand Down Expand Up @@ -107,13 +108,8 @@ export default class FormPrivilegeTable extends Component {
this.setState({ availablePrivileges: this.arrangePrivileges(allPrivileges), loading: false });
return;
}
if (initialPrivileges.links !== undefined && initialPrivileges.links.length > 0) {
const nextLink = initialPrivileges.links.find(link => link.rel === 'next');
if (!nextLink) {
this.setState({ availablePrivileges: this.arrangePrivileges(allPrivileges), loading: false });
return;
}
const nextUri = this.getNextPaginationRequestUrl(nextLink.uri);
const nextUri = getNextPageUrl(initialPrivileges);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

blocking: This pagination chain has no .catch anywhere — not in fetchPrivileges() (lines 94-103), nor here. getNextPageUrl throws synchronously for an invalid/malformed next link, and since that throw happens inside an unguarded .then(), it becomes an unhandled promise rejection: no notification is shown, and loading stays true forever (stuck spinner).

Mirror FormBuilderContainer.getFormData()'s pattern: have collectAllPrivileges return its httpInterceptor.get(...).then(...) chain, and add a .catch((error) => { this.setErrorMessage(error); this.setState({ loading: false }); }) in fetchPrivileges().

if (nextUri) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

blocking: Unlike FormBuilderContainer.collectAllForms (which now tracks requestedUrls and throws on a repeated next-link), this method has no protection against a repeated/looping next link — the exact OpenMRS proxy-URL bug class this PR fixes. The only incidental stop is allPrivileges.length === formBuilderConstants.dataLimit (9999, strict equality), which won't trigger if the page size doesn't evenly divide 9999 — making this an effectively unbounded request loop in that case.

Thread the same requestedUrls guard used in FormBuilderContainer.collectAllForms through here.

httpInterceptor.get(nextUri)
.then((privileges) => this.collectAllPrivileges(privileges, allPrivileges));
} else {
Expand Down Expand Up @@ -301,17 +297,6 @@ export default class FormPrivilegeTable extends Component {
return formPrivilegeObj;
}

getNextPaginationRequestUrl(uri) {
if (uri.indexOf('http') === -1) {
return uri;
}
const parsedUrl = new URL(uri);
if (parsedUrl.protocol != window.location.protocol) {
parsedUrl.protocol = window.location.protocol;
}
return parsedUrl.toString();
}

removeSelectedPrivilege(e) {
let array = this.state.availablePrivileges.filter((item) => item.label !== e);
this.setState({
Expand Down
91 changes: 91 additions & 0 deletions test/common/utils/paginationUtils.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public License,
* v. 2.0. If a copy of the MPL was not distributed with this file, You can
* obtain one at https://www.bahmni.org/license/mplv2hd.
*
* Copyright (C) OpenMRS Inc. OpenMRS is a registered trademark and the OpenMRS
* graphic logo is a trademark of OpenMRS Inc.
*/

import { expect } from 'chai';
import { getNextPageUrl } from 'common/utils/paginationUtils';

describe('paginationUtils', () => {
describe('getNextPageUrl', () => {
it('should drop the origin of an absolute http link and keep path and encoded query', () => {
const uri = 'http://localhost/openmrs/ws/rest/v1/form?v=custom%3A%28id%2Cuuid%2Cname%29' +
'&limit=2&startIndex=2';
expect(getNextPageUrl({ links: [{ rel: 'next', uri }] }))
.to.eql('/openmrs/ws/rest/v1/form?v=custom%3A%28id%2Cuuid%2Cname%29&limit=2&startIndex=2');
});

it('should return the path and query of a link on the current origin', () => {
const uri = `${window.location.origin}/openmrs/ws/rest/v1/form?limit=2&startIndex=4`;
expect(getNextPageUrl({ links: [{ rel: 'next', uri }] }))
.to.eql('/openmrs/ws/rest/v1/form?limit=2&startIndex=4');
});

it('should keep a relative link unchanged', () => {
expect(getNextPageUrl({ links: [{ rel: 'next', uri: '/openmrs/ws/rest/v1/form?limit=2' }] }))
.to.eql('/openmrs/ws/rest/v1/form?limit=2');
});

it('should pick the link with rel next when it is not the first link', () => {
const response = { links: [
{ rel: 'prev', uri: 'http://localhost/openmrs/ws/rest/v1/form?limit=2&' },
{ rel: 'next', uri: 'http://localhost/openmrs/ws/rest/v1/form?limit=2&startIndex=6' },
] };
expect(getNextPageUrl(response)).to.eql('/openmrs/ws/rest/v1/form?limit=2&startIndex=6');
});

it('should return undefined when there is no next link', () => {
expect(getNextPageUrl({ results: [] })).to.eql(undefined);
expect(getNextPageUrl({ links: [] })).to.eql(undefined);
expect(getNextPageUrl({ links: [{ rel: 'prev', uri: '/a' }] })).to.eql(undefined);
expect(getNextPageUrl(undefined)).to.eql(undefined);
});

it('should throw for a next link that is missing or invalid', () => {
expect(() => getNextPageUrl({ links: [{ rel: 'next' }] })).to.throw('Invalid pagination');
Comment thread
anushkadubey-star marked this conversation as resolved.
expect(() => getNextPageUrl({ links: [{ rel: 'next', uri: 'http://' }] }))
.to.throw('Invalid pagination');
expect(() => getNextPageUrl({ links: [{ rel: 'next', uri: 'ftp://localhost/openmrs' }] }))
.to.throw('Invalid pagination');
});

it('should throw for a next link whose path could resolve to another host', () => {
['http://localhost//evil.com/a', 'http://localhost/\\evil.com/a', 'http://localhost///evil.com']
.forEach((uri) => {
expect(() => getNextPageUrl({ links: [{ rel: 'next', uri }] }))
.to.throw('Invalid pagination');
});
});

it('should keep only path and query of a protocol-relative link to another host', () => {
const uri = '//evil.example/openmrs/ws/rest/v1/form?limit=2&startIndex=2';
expect(getNextPageUrl({ links: [{ rel: 'next', uri }] }))
.to.eql('/openmrs/ws/rest/v1/form?limit=2&startIndex=2');
});

it('should throw for dot-segment and protocol-relative paths that normalise to //host', () => {
['http://localhost/.//evil.com/a', 'http://localhost/a/..//evil.com/a']
.forEach((uri) => {
expect(() => getNextPageUrl({ links: [{ rel: 'next', uri }] }))
.to.throw('Invalid pagination');
});
});

it('should never return a value that resolves to another origin', () => {
['http://other.example/openmrs/a?x=1', '//evil.example/a', '///evil.com/a', 'https://evil.example:8443/a?b=%2F%2F']
.forEach((uri) => {
const result = getNextPageUrl({ links: [{ rel: 'next', uri }] });
expect(new URL(result, window.location.origin).origin).to.eql(window.location.origin);
});
});

it('should ignore links that are not an array', () => {
expect(getNextPageUrl({ links: {} })).to.eql(undefined);
expect(getNextPageUrl({ links: 'next' })).to.eql(undefined);
});
});
});
Loading
Loading