Repository navigation
BAH-4789: Fix Form Builder pagination CORS error from absolute next links #141
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
53e89e8
3be21be
7077130
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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) { | ||
| 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:') | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
| || /^[\\/]{2}/.test(nextUrl.pathname)) { | ||
| throw new Error(`Invalid pagination link: ${nextLink.uri}`); | ||
| } | ||
| return `${nextUrl.pathname}${nextUrl.search}`; | ||
|
anushkadubey-star marked this conversation as resolved.
|
||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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'; | ||
|
|
@@ -48,36 +49,34 @@ | |
| } | ||
|
|
||
| getFormData() { | ||
| const initialForms = []; | ||
| const forms = []; | ||
| const queryParams = '?='; | ||
| const fetchFormsUrl = `${formBuilderConstants.formUrl}?v=custom:(id,uuid,name,version,published,auditInfo)`; | ||
| return httpInterceptor.get(fetchFormsUrl) | ||
| .then((initialForms) => { | ||
| this.collectAllForms(initialForms, forms); | ||
| }) | ||
| .then((initialForms) => this.collectAllForms(initialForms, forms)) | ||
|
anushkadubey-star marked this conversation as resolved.
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nit: |
||
| .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() { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. question: |
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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'; | ||
|
|
@@ -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); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. blocking: This pagination chain has no Mirror |
||
| if (nextUri) { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. blocking: Unlike Thread the same |
||
| httpInterceptor.get(nextUri) | ||
| .then((privileges) => this.collectAllPrivileges(privileges, allPrivileges)); | ||
| } else { | ||
|
|
@@ -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({ | ||
|
|
||
| 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'); | ||
|
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); | ||
| }); | ||
| }); | ||
| }); | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
suggestion: Both
collectAllFormsandcollectAllPrivilegesduplicate 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.