Skip to content
Open
Show file tree
Hide file tree
Changes from 5 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
f911647
Fix URL signing api for URLs containing special characters
ErykKul Jun 4, 2026
2c94dc8
Merge branch 'develop' into fix-url-signing-special-characters
ErykKul Jun 4, 2026
96be125
Extra URL signing tests to prevent regression
ErykKul Jun 4, 2026
8812582
More URL signing tests
ErykKul Jun 4, 2026
9cb852a
Clean up URL signing tests and comments
ErykKul Jun 4, 2026
12216ba
Merge branch 'develop' into fix-url-signing-special-characters
ErykKul Jun 8, 2026
942f3cf
updated the release note
ErykKul Jun 8, 2026
4387ee9
Clarify URL-signing release note and comment
ErykKul Jun 8, 2026
8b1ab7b
Return 500 instead of 400 when signing secret is not configured
ErykKul Jun 8, 2026
4bbb576
Merge branch 'develop' into fix-url-signing-special-characters
ErykKul Jun 16, 2026
4e8b6ff
Require signing secret for guestbook-response signed download URLs to…
ErykKul Jun 16, 2026
77ba0c8
Centralize signing-secret requirement across all API-token-based URL …
ErykKul Jun 16, 2026
9a4d41f
Merge remote-tracking branch 'refs/remotes/origin/fix-url-signing-spe…
ErykKul Jun 16, 2026
96ec148
Merge branch 'develop' into fix-url-signing-special-characters
ErykKul Jul 6, 2026
5706157
Address review findings: earlier signing-secret gate, byte-exact stri…
ErykKul Jul 6, 2026
ddcf8ff
URL signing secret: extra polish and clarificatoin
ErykKul Jul 6, 2026
06f539e
Merge remote-tracking branch 'origin/develop' into fix-url-signing-sp…
ErykKul Jul 18, 2026
faa7064
Merge branch 'develop' into fix-url-signing-special-characters
ErykKul Aug 14, 2026
2e0749f
Don't fix caller mistakes in signUrl: throw on reserved params, strip…
ErykKul Aug 14, 2026
60c1832
Validate signed URLs verbatim first, decoded as compatibility fallbac…
ErykKul Aug 14, 2026
0e60702
Document signing in decoded form as the robust choice for re-encoding…
ErykKul Aug 14, 2026
ff6d335
Tighten requestSignedUrl docs: sign the decoded form, use the signed …
ErykKul Aug 14, 2026
94f71c0
Strip reserved params from tool-manifest URL templates before signing…
ErykKul Aug 14, 2026
3fb4cc1
Review fixes: 401 instead of 500 for crafted private-url users, log-f…
ErykKul Aug 14, 2026
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
25 changes: 25 additions & 0 deletions doc/release-notes/fix-url-signing-special-characters.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
### Signed URLs work again for URLs with special characters

Requesting a signed URL (e.g. via `/api/admin/requestSignedUrl`, used by external tools, the Globus
integration and third-party integrations) was broken for URLs whose query contained special
characters; most notably persistent IDs such as `doi:10.5072/FK2/ABC` (which contain `:` and
`/`), as well as spaces, percent-encoded values and non-ASCII characters. The signing logic had
started normalizing/re-encoding the URL before signing it, while the signature is a byte-exact MAC
over the URL string; the re-encoded bytes no longer matched what callers presented back, so
validation failed with "signature does not match"/authentication errors.

Signing is now byte-exact again: the base URL is preserved exactly as provided (reserved signing
parameters are still stripped, but without re-encoding the rest of the URL). Clients must continue to
present the signed URL exactly as Dataverse returned it.

### A signing secret is now required to request signed URLs

The `/api/admin/requestSignedUrl` endpoint now requires a non-empty signing secret
(`dataverse.api.signing-secret`) to be configured. Previously an unset secret silently fell back to
using only the user's API token as the signing key, which is too weak. If the secret is not
configured, the endpoint now returns an error instead of issuing a weakly-signed URL.

**Upgrade note:** installations that use signed URLs through this endpoint (including the
`rdm-integration` connector) must set `dataverse.api.signing-secret`. See the
[Configuration Guide](https://guides.dataverse.org/en/latest/installation/config.html#dataverse-api-signing-secret).
Treat the value like a password.
7 changes: 4 additions & 3 deletions doc/sphinx-guides/source/installation/config.rst
Original file line number Diff line number Diff line change
Expand Up @@ -3349,9 +3349,10 @@ are time limited and only allow the action of the API call in the URL. See :ref:
:ref:`api-native-signed-url` for more details.

The key used to sign a URL is created from the API token of the creating user plus a signing-secret provided by an administrator.
**Using a signing-secret is highly recommended.** This setting defaults to an empty string. Using a non-empty
signing-secret makes it impossible for someone who knows an API token from forging signed URLs and provides extra security by
making the overall signing key longer.
**A non-empty signing-secret is required to request signed URLs through the API.** If it is not configured, the
``/api/admin/requestSignedUrl`` endpoint (see :ref:`api-native-signed-url`) returns an error instead of issuing a
weakly-signed URL. (The setting otherwise defaults to an empty string.) A non-empty signing-secret makes it impossible for
someone who only knows an API token to forge signed URLs, and provides extra security by making the overall signing key longer.
Comment thread
ErykKul marked this conversation as resolved.

**WARNING**:
*Since the signing-secret is sensitive, you should treat it like a password.*
Expand Down
1 change: 1 addition & 0 deletions docker-compose-dev.yml
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ services:
-Ddataverse.pid.fake.label=FakeDOIProvider
-Ddataverse.pid.fake.authority=10.5072
-Ddataverse.pid.fake.shoulder=FK2/
-Ddataverse.api.signing-secret=dev-only-signing-secret-change-me
-Ddataverse.cors.origin=* \
-Ddataverse.cors.methods=GET,POST,PUT,DELETE,OPTIONS \
-Ddataverse.cors.headers.allow=range,content-type,x-dataverse-key,accept \
Expand Down
8 changes: 7 additions & 1 deletion src/main/java/edu/harvard/iq/dataverse/api/Admin.java
Original file line number Diff line number Diff line change
Expand Up @@ -2453,7 +2453,13 @@ public Response getSignedUrl(@Context ContainerRequestContext crc, JsonObject ur
if (superuser == null || !superuser.isSuperuser()) {
return error(Response.Status.FORBIDDEN, "Requesting signed URLs is restricted to superusers.");
}


// Require a signing secret: without it the key is only the user's API token, which is too weak.
if (JvmSettings.API_SIGNING_SECRET.lookupOptional().orElse("").isEmpty()) {
return error(Response.Status.BAD_REQUEST,
"Requesting signed URLs requires a signing secret to be configured. Please set the dataverse.api.signing-secret JVM option.");
}

@stevenwinship stevenwinship Jun 15, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

My concern with this being here is that it doesn't cover the URL signing when requesting a file download with a guestbook response. Those APIs still work without the secret. (See Access.java) These APIs all call UrlSignerUtil.signUrl, which should have this code to return an error if no signing-secret exists.

{
"status": "OK",
"data": {
"signedUrl": "http://localhost:8080/api/v1/access/dataset/4?gbrecs=true&gbrids=12&until=2026-06-15T20:36:32.302&user=usere7c863fd&method=GET&token=c99c3f9393be5ddedbd72829b6a8b29de3310b06f6692b77286351ba079c82af177c0e8c8df237afe7d6611b7c044e79cc7e402042bf07aa1f9cbbb9cf855298"
}
}

@ErykKul ErykKul Jun 16, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Good catch. Two commits: first I added the same guard to the guestbook download (Access.returnSignedUrl), then I centralized it in UrlSignerUtil so every API-token-based signer requires the secret.

I kept it out of signUrl() itself because the remote/Globus overlay stores sign with their own per-store key and must keep working without the global one.

Heads up: centralizing means all such signing now needs the secret: external tool/Globus callbacks and permission-history links too, not just the two endpoints. Every install using them must set dataverse.api.signing-secret.

That may be too much; happy to scope it back to just requestSignedUrl + the guestbook download if you prefer.

String userId = urlInfo.getString("user");
String key=null;
if (userId != null) {
Expand Down
45 changes: 30 additions & 15 deletions src/main/java/edu/harvard/iq/dataverse/util/UrlSignerUtil.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,10 @@

import org.apache.commons.codec.digest.DigestUtils;
import org.apache.http.NameValuePair;
import org.apache.http.client.utils.URIBuilder;
import org.apache.http.client.utils.URLEncodedUtils;
import org.joda.time.LocalDateTime;

import java.net.MalformedURLException;
import java.net.URISyntaxException;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.util.List;
Expand Down Expand Up @@ -45,19 +43,10 @@ public class UrlSignerUtil {
*/
public static String signUrl(String baseUrl, Integer timeout, String user, String method, String key) {

// check for reserved parameter names ("until","user", "method", or "token")
String[] urlQP = baseUrl.split("\\?");
if (urlQP.length > 1) {
try {
URIBuilder uriBuilder = new URIBuilder(baseUrl);
List<NameValuePair> params = uriBuilder.getQueryParams();
params.removeIf(pair -> reservedParameters.contains(pair.getName()));
uriBuilder.setParameters(params);
baseUrl = uriBuilder.build().toString();
} catch (URISyntaxException e) {
logger.severe("Invalid URL for signing: " + baseUrl + " " + e.getMessage());
}
}
// Strip reserved signing params that may already be in the base URL. Done with exact-string
// surgery (not URIBuilder): the signature is a byte-exact MAC, so re-encoding the URL here
// (e.g. percent-encoding ':' and '/' in DOIs) would change the hashed bytes and break it.
baseUrl = stripReservedParameters(baseUrl);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Thanks for working with the existing code. FWIW: I think it would be cleaner overall for this general utility to not try fixing a caller's mistakes - perhaps it should throw an exception if one of the four params needed by the algorithm are sent (and never touch the url on the good path and thus not needing to strip anything). Similarly flagging/removing signed and key here because the caller doesn't strip them seems backwards.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

This wasn't introduced in this PR. It came in with the 6.10 guestbook changes (#12001), where the ?signed=true flow signs the incoming request URI, which can carry key=<apitoken> and signed=true. But agreed it's worth picking up here: I'll make signUrl throw if any of the four algorithm params are present and never touch the URL otherwise, and move the (byte-exact) stripping helper out of signUrl to the callers that build URLs from incoming requests.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Done in 2e0749f: signUrl now throws an IllegalArgumentException if the base URL already contains one of the four signing params and never touches the URL otherwise. The byte-exact stripping helper moved out of signUrl to the ?signed=true flow in Access (the caller that actually receives those params in the request URI), and /api/admin/requestSignedUrl now returns a 400 naming the offending parameter instead of silently rewriting the supplied URL. Release note, docs and tests updated accordingly.

boolean firstParam = !baseUrl.contains("?");
StringBuilder signedUrlBuilder = new StringBuilder(baseUrl);

Expand Down Expand Up @@ -87,6 +76,32 @@ public static String signUrl(String baseUrl, Integer timeout, String user, Strin
return signedUrl;
}

/**
* Removes the reserved signing parameters from the query, preserving the exact bytes of the path
* and of every other parameter (unlike URIBuilder, which would re-encode and break the MAC).
*/
static String stripReservedParameters(String baseUrl) {
int queryStart = baseUrl.indexOf('?');
if (queryStart < 0) {
return baseUrl;
}
String path = baseUrl.substring(0, queryStart);
String query = baseUrl.substring(queryStart + 1);
StringBuilder kept = new StringBuilder();
for (String pair : query.split("&")) {
int equals = pair.indexOf('=');
String name = (equals < 0) ? pair : pair.substring(0, equals);
if (reservedParameters.contains(name)) {
continue;
}
if (kept.length() > 0) {
kept.append('&');
}
kept.append(pair);
}
return kept.length() > 0 ? path + "?" + kept : path;
}

/**
* This method will only return true if the URL and parameters except the
* "token" are unchanged from the original/match the values sent to this method,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,17 @@
import edu.harvard.iq.dataverse.authorization.users.ApiToken;
import edu.harvard.iq.dataverse.authorization.users.AuthenticatedUser;
import edu.harvard.iq.dataverse.authorization.users.User;
import edu.harvard.iq.dataverse.util.UrlSignerUtil;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;

import jakarta.ws.rs.container.ContainerRequestContext;

import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.util.List;

import static edu.harvard.iq.dataverse.api.auth.SignedUrlAuthMechanism.RESPONSE_MESSAGE_BAD_SIGNED_URL;
import static org.junit.jupiter.api.Assertions.*;

Expand Down Expand Up @@ -96,4 +101,86 @@ public void testFindUserFromRequest_SignedUrlTokenProvided_UserDoesNotExistForTh

assertEquals(RESPONSE_MESSAGE_BAD_SIGNED_URL, wrappedUnauthorizedAuthErrorResponse.getMessage());
}

// End-to-end validation through the REAL SignedUrlAuthMechanism (URLDecoder.decode + isValidUrl),
// which the isValidUrl-only tests in UrlSignerUtilTest do not exercise. No signing secret is
// configured here, so the signing key is just the API token.

private void givenUserWithSigningKey(String key) {
AuthenticationServiceBean authStub = Mockito.mock(AuthenticationServiceBean.class);
Mockito.when(authStub.getAuthenticatedUser(TEST_SIGNED_URL_USER_ID)).thenReturn(testAuthenticatedUser);
ApiToken apiToken = Mockito.mock(ApiToken.class);
Mockito.when(apiToken.getTokenString()).thenReturn(key);
Mockito.when(authStub.findApiTokenByUser(testAuthenticatedUser)).thenReturn(apiToken);
sut.authSvc = authStub;
}

@Test
public void testEndToEnd_tamperedSignedUrl_userNotAuthenticated() {
givenUserWithSigningKey(TEST_SIGNED_URL_TOKEN);
String base = "http://localhost:8080/api/v1/datasets/:persistentId?persistentId=doi:10.5072/FK2/ABC";
String signedUrl = UrlSignerUtil.signUrl(base, 1000, TEST_SIGNED_URL_USER_ID, "GET", TEST_SIGNED_URL_TOKEN);
// Alter the signed portion of the URL after signing -> the signature must no longer validate.
String tampered = signedUrl.replace("FK2/ABC", "FK2/HACKED");

ContainerRequestContext request = new SignedUrlContainerRequestTestFake(TEST_SIGNED_URL_TOKEN, TEST_SIGNED_URL_USER_ID, tampered);

assertThrows(WrappedUnauthorizedAuthErrorResponse.class, () -> sut.findUserFromRequest(request));
}

// Runs the real rdm flow: un-escape, sign, request the original (encoded) URL + signature, then the
// server URL-decodes the request and checks it. Returns true iff the user authenticates.
private boolean validatesEndToEndAsRdmClient(String urlAsClientBuilds) {
givenUserWithSigningKey(TEST_SIGNED_URL_TOKEN);
String canonical = URLDecoder.decode(urlAsClientBuilds, StandardCharsets.UTF_8);
String signed = UrlSignerUtil.signUrl(canonical, 1000, TEST_SIGNED_URL_USER_ID, "GET", TEST_SIGNED_URL_TOKEN);
String requestUri = urlAsClientBuilds + signed.substring(canonical.length());
ContainerRequestContext request = new SignedUrlContainerRequestTestFake(TEST_SIGNED_URL_TOKEN, TEST_SIGNED_URL_USER_ID, requestUri);
try {
return testAuthenticatedUser.equals(sut.findUserFromRequest(request));
} catch (WrappedAuthErrorResponse e) {
return false;
}
}

@Test
public void testEndToEnd_allRdmIntegrationUrls_authenticate() {
final String s = "https://demo.dataverse.org";
final String pid = "doi:10.5072/FK2/ABC"; // raw, as most rdm paths send it
final String escPid = "doi%3A10.5072%2FFK2%2FABC"; // url.QueryEscape form (GetDatasetMetadata, GetDatasetUserPermissions)

// Every URL shape rdm-integration signs - each must authenticate end to end.
List<String> urls = List.of(
// raw persistentId in the query (GetNodeMap, CheckPermission, globus, writes, dataverse plugin)
s + "/api/v1/datasets/:persistentId/versions/:latest/files?persistentId=" + pid,
s + "/api/v1/datasets/:persistentId?persistentId=" + pid,
s + "/api/v1/admin/permissions/:persistentId?persistentId=" + pid + "&unblock-key=UNBLOCK",
s + "/api/v1/datasets/:persistentId/requestGlobusUploadPaths?persistentId=" + pid,
s + "/api/v1/datasets/:persistentId/addGlobusFiles?persistentId=" + pid,
s + "/api/v1/datasets/:persistentId/requestGlobusDownload?persistentId=" + pid,
s + "/api/v1/datasets/:persistentId/monitorGlobusDownload?persistentId=" + pid,
s + "/api/v1/datasets/:persistentId/globusDownloadParameters?persistentId=" + pid + "&downloadId=globus-task-123",
s + "/api/v1/datasets/:persistentId/add?persistentId=" + pid,
s + "/api/v1/datasets/:persistentId/addFiles?persistentId=" + pid,
s + "/api/v1/datasets/:persistentId/replaceFiles?persistentId=" + pid,
s + "/api/v1/datasets/:persistentId/deleteFiles?persistentId=" + pid,
s + "/api/v1/datasets/:persistentId/cleanStorage?persistentId=" + pid,
// url-escaped persistentId (GetDatasetMetadata, GetDatasetUserPermissions)
s + "/api/v1/datasets/:persistentId?persistentId=" + escPid + "&excludeFiles=true",
s + "/api/v1/datasets/:persistentId/userPermissions?persistentId=" + escPid,
// mydata/retrieve: url-escaped search term, '+' for spaces, repeated query params
s + "/api/v1/mydata/retrieve?selected_page=1&dvobject_types=Dataset"
+ "&published_states=Published&published_states=Unpublished&published_states=Draft"
+ "&role_ids=1&role_ids=6&mydata_search_term=text%3A%22hello+world%22",
// numeric-id / no-persistentId paths
s + "/api/v1/access/datafile/123/metadata/ddi",
s + "/api/v1/access/datafile/123",
s + "/api/v1/files/123",
s + "/api/v1/users/:me",
s + "/api/v1/datasets/42/versions/:latest?excludeFiles=true"
);
for (String url : urls) {
assertTrue(validatesEndToEndAsRdmClient(url), "signed URL must authenticate end to end: " + url);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,11 @@ public class SignedUrlContainerRequestTestFake extends ContainerRequestTestFake
private final UriInfo uriInfo;

public SignedUrlContainerRequestTestFake(String signedUrlToken, String signedUrlUserId) {
this.uriInfo = new SignedUrlUriInfoTestFake(signedUrlToken, signedUrlUserId);
this(signedUrlToken, signedUrlUserId, null);
}

public SignedUrlContainerRequestTestFake(String signedUrlToken, String signedUrlUserId, String requestUriOverride) {
this.uriInfo = new SignedUrlUriInfoTestFake(signedUrlToken, signedUrlUserId, requestUriOverride);
}

@Override
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,18 +15,29 @@ public class SignedUrlUriInfoTestFake extends UriInfoTestFake {

private final String signedUrlToken;
private final String signedUrlUserId;
private final String requestUriOverride;

private static final String SIGNED_URL_BASE_URL = "http://localhost:8080/api/test1";
private static final Integer SIGNED_URL_TIMEOUT = 1000;


public SignedUrlUriInfoTestFake(String signedUrlToken, String signedUrlUserId) {
this(signedUrlToken, signedUrlUserId, null);
}

// Lets a test supply the exact request URI the server would see, to exercise the real
// URLDecoder.decode + isValidUrl validation path.
public SignedUrlUriInfoTestFake(String signedUrlToken, String signedUrlUserId, String requestUriOverride) {
this.signedUrlToken = signedUrlToken;
this.signedUrlUserId = signedUrlUserId;
this.requestUriOverride = requestUriOverride;
}

@Override
public URI getRequestUri() {
if (requestUriOverride != null) {
return URI.create(requestUriOverride);
}
return URI.create(UrlSignerUtil.signUrl(SIGNED_URL_BASE_URL, SIGNED_URL_TIMEOUT, signedUrlUserId, GET, signedUrlToken));
}

Expand Down
65 changes: 65 additions & 0 deletions src/test/java/edu/harvard/iq/dataverse/util/UrlSignerUtilTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import java.util.logging.Level;
import java.util.logging.Logger;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;

Expand Down Expand Up @@ -86,4 +87,68 @@ public void testSignAndValidateWithParams() {
System.out.println(signedUrl3);
assertTrue(signedUrl3.contains("&p2&")); // Show that this works with params that have no value
}

@Test
public void testStripReservedParametersPreservesSpecialCharacters() {
// The signature is a byte-exact MAC over the URL string, so removing the reserved signing
// params must not re-encode anything else: a DOI's ':' and '/' must survive unchanged.
String doiUrl = "http://localhost:8080/api/v1/datasets/:persistentId?persistentId=doi:10.5072/FK2/ABC123&foo=bar";
assertEquals(doiUrl, UrlSignerUtil.stripReservedParameters(doiUrl));

// Reserved params (here token/user/signed) are removed; everything else is left byte-for-byte.
String withReserved = "http://localhost:8080/api/v1/datasets/:persistentId?persistentId=doi:10.5072/FK2/ABC123&token=spoofed&user=Mallory&signed=true&foo=bar";
assertEquals("http://localhost:8080/api/v1/datasets/:persistentId?persistentId=doi:10.5072/FK2/ABC123&foo=bar",
UrlSignerUtil.stripReservedParameters(withReserved));

// A URL with no query string is returned unchanged.
String noQuery = "http://localhost:8080/api/v1/datasets/:persistentId";
assertEquals(noQuery, UrlSignerUtil.stripReservedParameters(noQuery));
}

@Test
public void testSignAndValidateSpecialCharacters() {
final int longTimeout = 1000;
final String user = "Alice";
final String method = "GET";
final String key = "abracadabara open sesame";

// DOIs (':' and '/'), pre-encoded values, spaces, unicode and embedded URLs must all sign
// byte-exact and be accepted by isValidUrl over those exact bytes (the signing primitive;
// end-to-end validation with the server's URLDecoder.decode is in SignedUrlAuthMechanismTest).
String[] baseUrls = new String[] {
"http://localhost:8080/api/v1/datasets/:persistentId?persistentId=doi:10.5072/FK2/ABC123&foo=bar",
"http://localhost:8080/api/v1/datasets/:persistentId?persistentId=doi%3A10.5072%2FFK2%2FABC123",
"http://localhost:8080/api/v1/search?q=hello%20world&persistentId=doi:10.1/2",
"http://localhost:8080/api/v1/search?q=hello world&pid=doi:10.1/2",
"http://localhost:8080/api/v1/search?q=café&name=測試",
"http://localhost:8080/api/v1/redirect?url=http%3A%2F%2Fexample.com%2Ff%3Fa%3D1%26b%3D2"
};
for (String baseUrl : baseUrls) {
String signedUrl = UrlSignerUtil.signUrl(baseUrl, longTimeout, user, method, key);
// The base URL is preserved byte-for-byte in the signed URL (no re-encoding).
assertTrue(signedUrl.startsWith(baseUrl + "&"),
"base URL must be preserved byte-for-byte: " + signedUrl);
assertTrue(UrlSignerUtil.isValidUrl(signedUrl, user, method, key),
"signed URL should validate when used verbatim: " + signedUrl);
}
}

@Test
public void testSignedUrlIsByteExact() {
// Byte-exact contract: the signature is over the URL as provided, so a re-encoded variant
// must fail validation. This is the regression that URIBuilder normalization caused.
final int longTimeout = 1000;
final String user = "Alice";
final String method = "GET";
final String key = "abracadabara open sesame";

String baseUrl = "http://localhost:8080/api/v1/datasets/:persistentId?persistentId=doi:10.5072/FK2/ABC123";
String signedUrl = UrlSignerUtil.signUrl(baseUrl, longTimeout, user, method, key);
assertTrue(UrlSignerUtil.isValidUrl(signedUrl, user, method, key));

// Re-encoding ':' and '/' in the DOI changes the signed bytes, so it must be rejected.
String reEncoded = signedUrl.replace("doi:10.5072/FK2/ABC123", "doi%3A10.5072%2FFK2%2FABC123");
assertFalse(UrlSignerUtil.isValidUrl(reEncoded, user, method, key),
"a re-encoded variant must not validate (byte-exact contract)");
}
}
Loading