MENDELU/Security: per-collection "Allow external URLs" + ORE ingest egress hardening (#860) - #1387
MENDELU/Security: per-collection "Allow external URLs" + ORE ingest egress hardening (#860)#1387milanmajchrak wants to merge 4 commits into
Conversation
…gress hardening OREIngestionCrosswalk fetched the ore:aggregates href from a remote, attacker-controlled ORE record with new URL(href).openStream() and stored the response as a Bitstream. A rogue OAI provider could therefore make DSpace read internal-only hosts (verified: the Solr statistics core) and local files (verified: file:///etc/passwd), and republish them as public downloads. Adds a per-collection "Allow external URLs" flag that widens the set of permitted public hosts only. It never widens the set of permitted IP addresses: loopback, private, link-local, CGNAT, reserved and cloud-metadata targets stay blocked in both flag states, at every redirect hop. The trust anchor for host confinement is harvested_collection.oai_source, the value an administrator typed - not the entryId, which the remote server controls. Upstream anchors on entryId and is bypassable by emitting a crafted atom:link rel="alternate". Enforced regardless of the flag: http(s) schemes only, no userinfo, no null host, every resolved address checked against a byte-based blocked-range table, redirects never followed automatically (each hop re-validated with fresh DNS, https->http refused, hop count capped), the connection pinned to the validated address, connect/read timeouts and a response size cap. A rejected resource fails the record, not the run: the harvester rolls the record back so an existing item is never left stripped of its bitstreams, and does not advance last_harvested, so the record is retried on the next harvest. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The end-of-run INFO logged "successful" even when the run had set STATUS_OAI_ERROR because records were skipped. The oai.cfg note claimed "dspace packager -r -t AIP" now fails closed. It only does so for a package declaring OTHERMDTYPE="ore", which DSpace's own AIP disseminator never emits. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The new package used record-style accessors while every surrounding DSpace class, including HarvestedCollection.isAllowExternalUrls() added in the same change, uses getX()/isX(). Mechanical rename; no behaviour change. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Security hardening for OAI-ORE ingestion to prevent SSRF and local file disclosure by introducing an explicit ORE egress policy (scheme/host/address validation, redirect re-validation, timeouts, and response size caps) and adding a per-collection “allow external URLs” toggle exposed via REST, CLI, and DB migrations.
Changes:
- Add a new
org.dspace.harvest.orepolicy/validation/fetching layer and wire it intoOREIngestionCrosswalk+OAIHarvesterto enforce safe egress and per-record rejection. - Add
harvested_collection.allow_external_urls(Flyway migrations for Postgres/H2), expose it via REST + integration tests, and support enabling it viaharvest -x. - Expand OAI module documentation (
oai.cfg) describing the new behavior and related configuration knobs.
Reviewed changes
Copilot reviewed 22 out of 22 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| dspace/config/modules/oai.cfg | Documents ORE URL validation behavior, internal-address blocking, redirects, timeouts, and size caps. |
| dspace-server-webapp/src/test/java/org/dspace/app/rest/CollectionHarvestSettingsControllerIT.java | Adds REST IT coverage for allow_external_urls defaulting, persistence, and “absent key leaves unchanged” semantics. |
| dspace-server-webapp/src/main/java/org/dspace/app/rest/repository/HarvestedCollectionRestRepository.java | Implements “absent Boolean leaves unchanged” behavior when updating harvest settings. |
| dspace-server-webapp/src/main/java/org/dspace/app/rest/model/HarvestedCollectionRest.java | Adds nullable allow_external_urls field to REST model to support partial-update semantics. |
| dspace-server-webapp/src/main/java/org/dspace/app/rest/converter/HarvestedCollectionConverter.java | Ensures REST responses include allow_external_urls (and defaults to false when no harvesting row exists). |
| dspace-api/src/test/java/org/dspace/harvest/ore/SafeResourceFetcherTest.java | Unit tests for safe fetching: redirects, downgrade prevention, address blocking, and response-size caps. |
| dspace-api/src/test/java/org/dspace/harvest/ore/OreUrlValidatorTest.java | Unit tests for URL parsing/validation and policy behaviors (scheme/host confinement/address blocking). |
| dspace-api/src/test/java/org/dspace/harvest/ore/BlockedAddressPredicateTest.java | Unit tests for blocked-range matching, including IPv6-embedded IPv4 forms. |
| dspace-api/src/main/resources/org/dspace/storage/rdbms/sqlmigration/postgres/V9.1_2026.07.31__harvested_collection_allow_external_urls.sql | Adds allow_external_urls column and seeds existing harvest_type=3 rows. |
| dspace-api/src/main/resources/org/dspace/storage/rdbms/sqlmigration/h2/V9.1_2026.07.31__harvested_collection_allow_external_urls.sql | Same as Postgres migration for H2. |
| dspace-api/src/main/java/org/dspace/harvest/ore/SafeResourceFetcher.java | Implements redirect-safe, address-pinned fetching with timeouts and a streaming size cap. |
| dspace-api/src/main/java/org/dspace/harvest/ore/RejectionReason.java | Defines canonical rejection reasons for policy decisions vs transfer failures. |
| dspace-api/src/main/java/org/dspace/harvest/ore/OreUrlValidator.java | Validates scheme/authority/host confinement/DNS resolution/address blocking and returns validated addresses. |
| dspace-api/src/main/java/org/dspace/harvest/ore/OreResourceRejectedException.java | Crosswalk-compatible exception to signal per-record rejection with structured reason. |
| dspace-api/src/main/java/org/dspace/harvest/ore/OreEgressPolicy.java | Immutable policy derived from collection settings + config (timeouts, max bytes, redirects, allowlists). |
| dspace-api/src/main/java/org/dspace/harvest/ore/HarvestPolicyAware.java | Interface to inject per-collection egress policy into crosswalks that fetch remote content. |
| dspace-api/src/main/java/org/dspace/harvest/ore/BlockedAddressPredicate.java | Implements byte-level blocked-range checks (incl. IPv6 tunnels) for internal/reserved addresses. |
| dspace-api/src/main/java/org/dspace/harvest/OAIHarvester.java | Injects policy into ORE crosswalk, rejects failing records safely (rollback), and adjusts harvest status/watermark logic. |
| dspace-api/src/main/java/org/dspace/harvest/HarvestedCollection.java | Adds persisted allowExternalUrls flag to the harvest settings entity. |
| dspace-api/src/main/java/org/dspace/content/crosswalk/OREIngestionCrosswalk.java | Uses the safe fetcher + policy, adds null-guards, and converts transfer failures into per-record rejection. |
| dspace-api/src/main/java/org/dspace/app/harvest/HarvestScriptConfiguration.java | Adds -x/--allow-external-urls CLI option. |
| dspace-api/src/main/java/org/dspace/app/harvest/Harvest.java | Implements nullable CLI flag semantics so absence of -x leaves stored DB value unchanged. |
Suppressed comments (1)
dspace-api/src/main/java/org/dspace/content/crosswalk/OREIngestionCrosswalk.java:213
entryIdcan now be null (missing alternate link), so this message can end with": null", which isn’t helpful for debugging. Build the message to omit the identifier when it’s absent.
} else {
throw new CrosswalkException("Entry did not contain link to resource: " + entryId);
}
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
entryId became nullable with the null-guard added in this branch, so the message could end with ": null". Copilot review, PR #1387. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Copilot review addressed in eab245f. Applied (1) — the suppressed comment on Skipped (1) — the |
Problem description
Issue: https://github.com/dataquest-dev/dspace-customers/issues/860
A collection harvesting with "metadata and bitstreams" downloads whatever URL the remote ORE record names, without any check. A rogue OAI provider can therefore point us at our own internal network, or at a local file, and get the result stored as a public bitstream.
Reproduced on this branch before fixing:
http://dspacesolr:8983/solr/statistics/select?q=*:*ended up as a bitstream containing the Solr response, andfile:///etc/passwdas a bitstream containing the container's password file. So it is a local file disclosure as well, not only SSRF.Analysis
Adds the Allow external URLs checkbox per collection, off by default. It only widens which public hosts are allowed — internal addresses stay blocked in both states, including on redirects. With it off, files must come from the same host as the configured OAI provider.
Enforced regardless of the checkbox: http(s) only, no credentials in the URL, every resolved address checked against private/reserved ranges, redirects re-validated per hop, connect/read timeouts and a size cap. A rejected file now skips the whole record, instead of importing an item that silently has no files.
Two things worth knowing when reviewing:
allow_external_urls = TRUEfor existingharvest_type = 3collections so no running harvest breaks. If none of them fetch files from a host other than their OAI provider,FALSEis the safer default — one line in the migration. Worth checking first withSELECT collection_id, oai_source FROM harvested_collection WHERE harvest_type = 3;validateUrlPrefixstays commented out there.Needs the UI part: dataquest-dev/dspace-angular#1414. Merge this one first.
Manual Testing (if applicable)
Copilot review