Skip to content

Commit 40b4cd7

Browse files
Tr/fix/issue 512/malformed links (#1045)
* feat: adding hash function to ensure indexes are all lowercase and we can handle upper case collection names fix: fix tests that broke as a result of fix * update changelog * fix: applying PR feedback * tests: PR feedback, adding test that validates two collections with same names but diffeent cases can both be added * fix: add more robust checking of the bbox for valid coordinate bounds tests: add new tests for more robust tests on bbox check * docs: update changelog * fix: adding function to remove 'id' and 'collection' from exclude * tests: adding tests and fixing issues highlighted by tests * fix: adding comments for context, docs: updating changelog
1 parent 0d9f4af commit 40b4cd7

File tree

6 files changed

+193
-11
lines changed

6 files changed

+193
-11
lines changed

.nsprc

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -14,24 +14,29 @@
1414
"notes": "fast-xml-parser is a transitive dependency of @aws-sdk/xml-builder and is not directly used by application code. XML parsing from untrusted sources is not part of our usage context.",
1515
"expiry": "2026-04-23"
1616
},
17-
"1113371": {
17+
"1113407": {
1818
"active": true,
19-
"notes": "minimatch is a transitive dependency of @redocly/cli and redoc, used only for API documentation rendering. The vulnerable wildcard pattern matching is not exposed to user input in our usage context, making ReDoS exploitation not feasible.",
19+
"notes": "fast-xml-parser is a transitive dependency of @aws-sdk/xml-builder and is not directly used by application code. Entity encoding bypass via regex injection in DOCTYPE is not exploitable as we do not parse untrusted XML input.",
2020
"expiry": "2026-04-23"
2121
},
22-
"1113398": {
22+
"1113428": {
2323
"active": true,
2424
"notes": "ajv is a transitive dependency used by webpack build tooling and schema-utils. The $data option ReDoS vulnerability is not exploitable as we do not use ajv directly or pass untrusted data through it.",
25-
"expiry": "2026-04-23"
25+
"expiry": "2026-04-24"
2626
},
27-
"1113399": {
27+
"1113429": {
2828
"active": true,
2929
"notes": "ajv is a transitive dependency used by webpack build tooling and schema-utils. The $data option ReDoS vulnerability is not exploitable as we do not use ajv directly or pass untrusted data through it.",
30-
"expiry": "2026-04-23"
30+
"expiry": "2026-04-24"
3131
},
32-
"1113407": {
32+
"1113461": {
3333
"active": true,
34-
"notes": "fast-xml-parser is a transitive dependency of @aws-sdk/xml-builder and is not directly used by application code. Entity encoding bypass via regex injection in DOCTYPE is not exploitable as we do not parse untrusted XML input.",
35-
"expiry": "2026-04-23"
34+
"notes": "minimatch is a transitive dependency of @redocly/cli and redoc, used only for API documentation rendering. The vulnerable wildcard pattern matching is not exposed to user input in our usage context, making ReDoS exploitation not feasible.",
35+
"expiry": "2026-04-25"
36+
},
37+
"1113466": {
38+
"active": true,
39+
"notes": "minimatch is a transitive dependency of @redocly/cli and redoc, used only for API documentation rendering. The vulnerable wildcard pattern matching is not exposed to user input in our usage context, making ReDoS exploitation not feasible.",
40+
"expiry": "2026-04-25"
3641
}
3742
}

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.
1010
### Fixed
1111

1212
- Added more robust checks on valid/invalid latitude and longitudes for bounding box inputs ([1041](https://github.com/stac-utils/stac-server/pull/1041))
13+
- Fixed issue where using `fields` extension to `exclude` either `id` or `collection` fields resulted in malformed links with 'undefined' in them due to those fields being required to generate item links. Those fields, if excluded are now removed after backend opensearch query ([1045](https://github.com/stac-utils/stac-server/pull/1045))
14+
1315

1416
### Added
1517

src/lib/api.js

Lines changed: 55 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,32 @@ const validateStartAndEndDatetimes = function (startDateTime, endDateTime) {
198198
}
199199
}
200200

201+
/**
202+
* ensure that fields necessary for creating links i.e. 'collection' and 'id'
203+
* are not excluded from query. These fields will be removed later to ensure
204+
* results match user expectations
205+
* @param {Object} fields
206+
* @returns {Object}
207+
*/
208+
export const createQueryFields = function (fields) {
209+
const { exclude } = fields
210+
if (exclude) {
211+
const filteredExclude = exclude.filter(
212+
(field) => field !== 'id' && field !== 'collection'
213+
)
214+
if (filteredExclude.length === 0) {
215+
const { exclude: _removed, ...rest } = fields
216+
return rest
217+
}
218+
219+
return {
220+
...fields,
221+
exclude: filteredExclude
222+
}
223+
}
224+
return fields
225+
}
226+
201227
export const extractDatetime = function (params) {
202228
const { datetime } = params
203229

@@ -540,7 +566,6 @@ export const addItemLinks = function (results, endpoint) {
540566
results.forEach((result) => {
541567
let { links } = result
542568
const { id, collection } = result
543-
544569
links = (links === undefined) ? [] : links
545570
// self link
546571
links.splice(0, 0, {
@@ -577,6 +602,32 @@ export const addItemLinks = function (results, endpoint) {
577602
return results
578603
}
579604

605+
/**
606+
* If 'id' or 'collection' were in the 'excluded' fields, they must
607+
* be removed. They were necessary for STAC Item link generation and
608+
* can now be removed after link generation if a user wanted to exclude them
609+
* Impure, we are potentially mutating 'results'
610+
* @param {Object} results
611+
* @param {Object} fields - {'exclude': [string], 'include': [string]}
612+
* @returns {Object}
613+
*/
614+
export const removeSpecialExcludeFields = function (results, fields) {
615+
const { exclude } = fields
616+
if (!exclude) return results
617+
618+
const removeId = exclude.includes('id')
619+
const removeCollection = exclude.includes('collection')
620+
621+
// exit early and avoid forEach loop if possible
622+
if (!removeId && !removeCollection) return results
623+
624+
results.forEach((item) => {
625+
if (removeId) delete item.id
626+
if (removeCollection) delete item.collection
627+
})
628+
return results
629+
}
630+
580631
const wrapResponseInFeatureCollection = function (features, links,
581632
numberMatched, numberReturned, limit) {
582633
const fc = {
@@ -688,6 +739,7 @@ const searchItems = async function (
688739
extractRestrictionCql2Filter(parameters, headers)
689740
)
690741
const fields = extractFields(parameters)
742+
const queryFields = createQueryFields(fields)
691743
const ids = extractIds(parameters)
692744
const allowedCollectionIds = extractAllowedCollectionIds(
693745
parameters,
@@ -704,7 +756,7 @@ const searchItems = async function (
704756
query,
705757
filter: combinedFilter,
706758
sortby,
707-
fields,
759+
fields: queryFields,
708760
ids,
709761
collections,
710762
next
@@ -797,6 +849,7 @@ const searchItems = async function (
797849
}
798850

799851
addItemLinks(responseItems, endpoint)
852+
removeSpecialExcludeFields(responseItems, fields)
800853

801854
return wrapResponseInFeatureCollection(responseItems, links, numberMatched, numberReturned, limit)
802855
}

src/lib/database.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -778,6 +778,7 @@ async function populateUnrestrictedIndices() {
778778
}
779779

780780
export async function constructSearchParams(parameters, page, limit) {
781+
// console.log('DEBUG - parameters %j', parameters)
781782
const { id, collections, filter } = parameters
782783

783784
let body

tests/system/test-api-search-get.js

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -444,3 +444,67 @@ test('/search invalid bbox throws error', async (t) => {
444444
)
445445
}
446446
})
447+
448+
test('GET /search using "exclude" returns properly formatted links', async (t) => {
449+
const fixtureFiles = [
450+
'LC80100102015050LGN00.json',
451+
'LC80100102015082LGN00.json'
452+
]
453+
const items = await Promise.all(fixtureFiles.map((x) => loadJson(x)))
454+
await processMessages(items)
455+
await refreshIndices()
456+
457+
// specificall testing that using 'collection' and/or 'id' does not return
458+
// any 'undefined' links, fields required to generate links
459+
{
460+
const response = await t.context.api.client.get('search', {
461+
resolveBodyOnly: false,
462+
searchParams: new URLSearchParams({
463+
collections: 'landsat-8-l1',
464+
limit: 1,
465+
fields: '-assets,-bbox,-stac_version,-collection,-id',
466+
})
467+
})
468+
t.is(response.statusCode, 200)
469+
t.is(response.body.features.length, 1)
470+
471+
// check 'self' link
472+
const selfLink = response.body.features[0].links.find((l) => l.rel === 'self')
473+
const selfPath = new URL(selfLink.href).pathname
474+
t.is(selfPath, '/collections/landsat-8-l1/items/LC80100102015082LGN00')
475+
476+
const parentLink = response.body.features[0].links.find((l) => l.rel === 'parent')
477+
const parentPath = new URL(parentLink.href).pathname
478+
t.is(parentPath, '/collections/landsat-8-l1')
479+
480+
const collectionLink = response.body.features[0].links.find((l) => l.rel === 'collection')
481+
const collectionPath = new URL(collectionLink.href).pathname
482+
t.is(collectionPath, '/collections/landsat-8-l1')
483+
}
484+
{
485+
// test without excluding required 'id' or 'collection' fields to generate link
486+
const response = await t.context.api.client.get('search', {
487+
resolveBodyOnly: false,
488+
searchParams: new URLSearchParams({
489+
collections: 'landsat-8-l1',
490+
limit: 1,
491+
fields: '-assets,-bbox,-stac_version',
492+
})
493+
})
494+
t.is(response.statusCode, 200)
495+
t.is(response.body.features.length, 1)
496+
497+
// check 'self' link
498+
const selfLink = response.body.features[0].links.find((l) => l.rel === 'self')
499+
const selfPath = new URL(selfLink.href).pathname
500+
t.is(selfPath, '/collections/landsat-8-l1/items/LC80100102015082LGN00')
501+
502+
const parentLink = response.body.features[0].links.find((l) => l.rel === 'parent')
503+
const parentPath = new URL(parentLink.href).pathname
504+
t.is(parentPath, '/collections/landsat-8-l1')
505+
506+
const collectionLink = response.body.features[0].links.find((l) => l.rel === 'collection')
507+
const collectionPath = new URL(collectionLink.href).pathname
508+
t.is(collectionPath, '/collections/landsat-8-l1')
509+
}
510+
})

tests/system/test-api-search-post.js

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1679,3 +1679,60 @@ test('/search invalid bbox throws error', async (t) => {
16791679
)
16801680
}
16811681
})
1682+
1683+
test('POST /search using "exclude" returns properly formatted links', async (t) => {
1684+
{
1685+
// test by excluding fields required to generate links ('id' and 'collection')
1686+
const response = await t.context.api.client.post(
1687+
'search',
1688+
{
1689+
resolveBodyOnly: false,
1690+
json: {
1691+
collections: ['landsat-8-l1'],
1692+
limit: 1,
1693+
fields: { exclude: ['assets', 'bbox', 'stac_version', 'id', 'collection'] }
1694+
} }
1695+
)
1696+
t.is(response.statusCode, 200)
1697+
t.is(response.body.features.length, 1)
1698+
1699+
const selfLink = response.body.features[0].links.find((l) => l.rel === 'self')
1700+
const selfPath = new URL(selfLink.href).pathname
1701+
t.is(selfPath, '/collections/landsat-8-l1/items/LC80100102015082LGN00')
1702+
1703+
const parentLink = response.body.features[0].links.find((l) => l.rel === 'parent')
1704+
const parentPath = new URL(parentLink.href).pathname
1705+
t.is(parentPath, '/collections/landsat-8-l1')
1706+
1707+
const collectionLink = response.body.features[0].links.find((l) => l.rel === 'collection')
1708+
const collectionPath = new URL(collectionLink.href).pathname
1709+
t.is(collectionPath, '/collections/landsat-8-l1')
1710+
}
1711+
{
1712+
// test without excluding fields required to generate links
1713+
const response = await t.context.api.client.post(
1714+
'search',
1715+
{
1716+
resolveBodyOnly: false,
1717+
json: {
1718+
collections: ['landsat-8-l1'],
1719+
limit: 1,
1720+
fields: { exclude: ['assets', 'bbox', 'stac_version'] }
1721+
} }
1722+
)
1723+
t.is(response.statusCode, 200)
1724+
t.is(response.body.features.length, 1)
1725+
1726+
const selfLink = response.body.features[0].links.find((l) => l.rel === 'self')
1727+
const selfPath = new URL(selfLink.href).pathname
1728+
t.is(selfPath, '/collections/landsat-8-l1/items/LC80100102015082LGN00')
1729+
1730+
const parentLink = response.body.features[0].links.find((l) => l.rel === 'parent')
1731+
const parentPath = new URL(parentLink.href).pathname
1732+
t.is(parentPath, '/collections/landsat-8-l1')
1733+
1734+
const collectionLink = response.body.features[0].links.find((l) => l.rel === 'collection')
1735+
const collectionPath = new URL(collectionLink.href).pathname
1736+
t.is(collectionPath, '/collections/landsat-8-l1')
1737+
}
1738+
})

0 commit comments

Comments
 (0)