Skip to content

Commit 6e81855

Browse files
Fix REST rel information leak: run the authorization check before entity resolution on clarinlruallowances rels (#1438)
GET /api/core/clarinlruallowances/{id} answers an anonymous caller 401 whether or not the allowance exists, but two of its three rels answered 404 for an unknown id and 401 for an existing one. The status code was therefore an oracle for entity existence, and the 404 body named the entity. The cause is ordering, not the check itself: the link methods resolve the entity first, and ClarinLicenseResourceUserAllowanceService.find returns null for a missing row before it ever reaches authorizeClruaAction. Adding @PreAuthorize("hasAuthority('AUTHENTICATED')") to getUserRegistration and getUserMetadata lets Spring Security reject the caller before the body runs, the same idiom the three sibling repositories already use, so the rels match their parent. The AuthorizeException translation stays, so an authenticated non-owner still gets 403. Also fixes the message typo "for if: " to "for id: ". ClarinResourceMappingCLicenseLinkRepository deliberately keeps no guard - its parent findOne is permitAll() and the Angular licence agreement page follows that rel anonymously - and now says so in a javadoc, with a test that fails if someone guards it. Card X-11, owner decision O-7. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent e94ced2 commit 6e81855

4 files changed

Lines changed: 256 additions & 1 deletion

File tree

dspace-server-webapp/src/main/java/org/dspace/app/rest/repository/CLRUAUUserRegistrationLinkRepository.java

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
import org.springframework.beans.factory.annotation.Autowired;
2525
import org.springframework.data.domain.Pageable;
2626
import org.springframework.data.rest.webmvc.ResourceNotFoundException;
27+
import org.springframework.security.access.prepost.PreAuthorize;
2728
import org.springframework.stereotype.Component;
2829

2930
/**
@@ -37,6 +38,21 @@ public class CLRUAUUserRegistrationLinkRepository extends AbstractDSpaceRestRepo
3738
@Autowired
3839
ClarinLicenseResourceUserAllowanceService clarinLicenseResourceUserAllowanceService;
3940

41+
/**
42+
* The user registration behind a CLARIN license resource user allowance is readable by the user the
43+
* allowance belongs to and by administrators. The guard has to run <em>before</em> the entity is
44+
* resolved: {@code ClarinLicenseResourceUserAllowanceService.find} returns {@code null} for a row that
45+
* does not exist before it ever calls {@code authorizeClruaAction}, so without {@code @PreAuthorize} an
46+
* anonymous caller got 404 for an unknown id and 401 for an existing one - which tells them whether the
47+
* allowance exists, while the parent
48+
* {@code ClarinLicenseResourceUserAllowanceRestRepository.findOne} answers 401 either way. Spring
49+
* Security intercepts this method before its body runs, so the rel now matches its parent.
50+
* <p>
51+
* The checked {@link AuthorizeException} translation below stays: it is what gives a logged-in caller
52+
* who does not own the allowance a 403 instead of the 500 that
53+
* {@code RestResourceController.findRelInternal} would produce from a checked exception.
54+
*/
55+
@PreAuthorize("hasAuthority('AUTHENTICATED')")
4056
public ClarinUserRegistrationRest getUserRegistration(@Nullable HttpServletRequest request,
4157
Integer clruaID,
4258
@Nullable Pageable optionalPageable,

dspace-server-webapp/src/main/java/org/dspace/app/rest/repository/CLRUAUserMetadataLinkRepository.java

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
import org.springframework.data.domain.Page;
2828
import org.springframework.data.domain.Pageable;
2929
import org.springframework.data.rest.webmvc.ResourceNotFoundException;
30+
import org.springframework.security.access.prepost.PreAuthorize;
3031
import org.springframework.stereotype.Component;
3132

3233
/**
@@ -40,6 +41,21 @@ public class CLRUAUserMetadataLinkRepository extends AbstractDSpaceRestRepositor
4041
@Autowired
4142
ClarinLicenseResourceUserAllowanceService clarinLicenseResourceUserAllowanceService;
4243

44+
/**
45+
* The user metadata behind a CLARIN license resource user allowance is readable by the user the
46+
* allowance belongs to and by administrators. The guard has to run <em>before</em> the entity is
47+
* resolved: {@code ClarinLicenseResourceUserAllowanceService.find} returns {@code null} for a row that
48+
* does not exist before it ever calls {@code authorizeClruaAction}, so without {@code @PreAuthorize} an
49+
* anonymous caller got 404 for an unknown id and 401 for an existing one - which tells them whether the
50+
* allowance exists, while the parent
51+
* {@code ClarinLicenseResourceUserAllowanceRestRepository.findOne} answers 401 either way. Spring
52+
* Security intercepts this method before its body runs, so the rel now matches its parent.
53+
* <p>
54+
* The checked {@link AuthorizeException} translation below stays: it is what gives a logged-in caller
55+
* who does not own the allowance a 403 instead of the 500 that
56+
* {@code RestResourceController.findRelInternal} would produce from a checked exception.
57+
*/
58+
@PreAuthorize("hasAuthority('AUTHENTICATED')")
4359
public Page<ClarinUserMetadataRest> getUserMetadata(@Nullable HttpServletRequest request,
4460
Integer clruaID,
4561
@Nullable Pageable optionalPageable,
@@ -54,7 +70,7 @@ public Page<ClarinUserMetadataRest> getUserMetadata(@Nullable HttpServletRequest
5470
throw new RESTAuthorizationException(e);
5571
}
5672
if (Objects.isNull(clarinLicenseResourceUserAllowance)) {
57-
throw new ResourceNotFoundException("The ClarinLicenseResourceUserAllowance for if: " + clruaID +
73+
throw new ResourceNotFoundException("The ClarinLicenseResourceUserAllowance for id: " + clruaID +
5874
" couldn't be found");
5975
}
6076

dspace-server-webapp/src/main/java/org/dspace/app/rest/repository/ClarinResourceMappingCLicenseLinkRepository.java

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,18 @@ public class ClarinResourceMappingCLicenseLinkRepository extends AbstractDSpaceR
3232
@Autowired
3333
ClarinLicenseResourceMappingService clarinLicenseResourceMappingService;
3434

35+
/**
36+
* Deliberately public - do not put a method security guard on this method. Its parent,
37+
* {@code ClarinLicenseResourceMappingRestRepository.findOne}, permits all callers, and the Angular
38+
* licence agreement page follows this rel anonymously
39+
* ({@code clarin-license-agreement-page.component.ts}, {@code followLink('clarinLicense')}), so guarding
40+
* it would break the anonymous download flow. The 404 an anonymous caller gets for an unknown mapping is
41+
* parity with the parent, not an information leak - unlike the sibling allowance rels, whose 404 on an
42+
* unknown id sat next to a 401 on an existing one.
43+
* <p>
44+
* {@code ClarinLinkRestRepositoryBeanNameIT.anonymousResourceMappingClarinLicenseRelStaysPublic} fails
45+
* if a later security cleanup guards it anyway.
46+
*/
3547
public ClarinLicenseRest getClarinLicense(@Nullable HttpServletRequest request,
3648
Integer mappingID,
3749
@Nullable Pageable optionalPageable,

dspace-server-webapp/src/test/java/org/dspace/app/rest/ClarinLinkRestRepositoryBeanNameIT.java

Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,23 @@
88
package org.dspace.app.rest;
99

1010
import static org.hamcrest.Matchers.hasSize;
11+
import static org.hamcrest.Matchers.is;
1112
import static org.junit.Assert.assertEquals;
13+
import static org.junit.Assert.assertFalse;
1214
import static org.junit.Assert.assertNotNull;
1315
import static org.junit.Assert.assertTrue;
1416
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
1517
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
1618
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
1719

20+
import java.io.ByteArrayInputStream;
21+
import java.io.InputStream;
22+
import java.nio.charset.StandardCharsets;
23+
import java.util.ArrayList;
24+
import java.util.HashSet;
25+
import java.util.List;
1826
import java.util.Map;
27+
import java.util.Objects;
1928

2029
import org.dspace.app.rest.exception.RepositoryNotFoundException;
2130
import org.dspace.app.rest.model.BitstreamRest;
@@ -28,17 +37,33 @@
2837
import org.dspace.app.rest.repository.LinkRestRepository;
2938
import org.dspace.app.rest.test.AbstractControllerIntegrationTest;
3039
import org.dspace.app.rest.utils.Utils;
40+
import org.dspace.builder.BitstreamBuilder;
41+
import org.dspace.builder.ClarinLicenseBuilder;
42+
import org.dspace.builder.ClarinLicenseLabelBuilder;
3143
import org.dspace.builder.ClarinLicenseResourceMappingBuilder;
3244
import org.dspace.builder.ClarinLicenseResourceUserAllowanceBuilder;
3345
import org.dspace.builder.ClarinUserMetadataBuilder;
3446
import org.dspace.builder.ClarinUserRegistrationBuilder;
47+
import org.dspace.builder.CollectionBuilder;
48+
import org.dspace.builder.CommunityBuilder;
3549
import org.dspace.builder.EPersonBuilder;
50+
import org.dspace.builder.ItemBuilder;
51+
import org.dspace.content.Bitstream;
52+
import org.dspace.content.Collection;
53+
import org.dspace.content.Item;
54+
import org.dspace.content.clarin.ClarinLicense;
55+
import org.dspace.content.clarin.ClarinLicenseLabel;
56+
import org.dspace.content.clarin.ClarinLicenseResourceMapping;
3657
import org.dspace.content.clarin.ClarinLicenseResourceUserAllowance;
3758
import org.dspace.content.clarin.ClarinUserRegistration;
59+
import org.dspace.content.service.clarin.ClarinLicenseLabelService;
60+
import org.dspace.content.service.clarin.ClarinLicenseResourceMappingService;
61+
import org.dspace.content.service.clarin.ClarinLicenseService;
3862
import org.dspace.eperson.EPerson;
3963
import org.junit.Test;
4064
import org.springframework.beans.factory.annotation.Autowired;
4165
import org.springframework.context.ApplicationContext;
66+
import org.springframework.mock.web.MockHttpServletResponse;
4267

4368
/**
4469
* Guards the DSpace 9 link-repository contract for the CLARIN models: the bean naming that decides
@@ -76,14 +101,38 @@ public class ClarinLinkRestRepositoryBeanNameIT extends AbstractControllerIntegr
76101
private static final String ALLOWANCES_URL = "/api/core/clarinlruallowances/";
77102
private static final String OTHER_EPERSON_EMAIL = "other-eperson@mail.com";
78103

104+
/** An id no fixture can own, used to ask about an entity that does not exist. */
105+
private static final int UNKNOWN_ID = Integer.MAX_VALUE;
106+
107+
/**
108+
* The CLARIN models that own rels. The cells are read off their {@link LinksRest} annotations rather
109+
* than hardcoded, so a rel added to any of them is covered without touching this test - the same
110+
* reason {@code _sync3/sweeps/rest-matrix.sh} enumerates from the source instead of from a list.
111+
*/
112+
private static final List<Class<? extends RestAddressableModel>> CLARIN_MODELS_WITH_RELS = List.of(
113+
ClarinLicenseResourceUserAllowanceRest.class,
114+
ClarinUserRegistrationRest.class,
115+
ClarinLicenseResourceMappingRest.class);
116+
79117
@Autowired
80118
private Utils utils;
81119

82120
@Autowired
83121
private ApplicationContext applicationContext;
84122

123+
@Autowired
124+
private ClarinLicenseService clarinLicenseService;
125+
126+
@Autowired
127+
private ClarinLicenseLabelService clarinLicenseLabelService;
128+
129+
@Autowired
130+
private ClarinLicenseResourceMappingService clarinLicenseResourceMappingService;
131+
85132
private ClarinLicenseResourceUserAllowance allowance;
86133

134+
private ClarinLicenseResourceMapping publicResourceMapping;
135+
87136
/**
88137
* The CLARIN builders are not part of the ordered cleanup map, so they are torn down in the order they
89138
* were first used: the user registration would be deleted while the allowance still references it. Drop
@@ -95,6 +144,11 @@ public void destroy() throws Exception {
95144
ClarinLicenseResourceUserAllowanceBuilder.deleteClarinLicenseResourceUserAllowance(allowance.getID());
96145
allowance = null;
97146
}
147+
// Same reason: this mapping carries a licence, whose builder would otherwise be torn down first.
148+
if (publicResourceMapping != null) {
149+
ClarinLicenseResourceMappingBuilder.delete(publicResourceMapping.getID());
150+
publicResourceMapping = null;
151+
}
98152
super.destroy();
99153
}
100154

@@ -350,4 +404,161 @@ public void clruaUserRegistrationAndUserMetadataRelsHonourRoles() throws Excepti
350404
getClient(adminToken).perform(get(ALLOWANCES_URL + allowance.getID() + "/userRegistration"))
351405
.andExpect(status().isOk());
352406
}
407+
/**
408+
* Enumerates every CLARIN rel cell from the {@link LinksRest} annotations of the models above, the way
409+
* {@code _sync3/sweeps/rest-matrix.sh} does from the source.
410+
*
411+
* @return one {category, typePlural, rel} triple per declared rel
412+
*/
413+
private List<String[]> clarinRelCells() throws ReflectiveOperationException {
414+
List<String[]> cells = new ArrayList<>();
415+
for (Class<? extends RestAddressableModel> modelClass : CLARIN_MODELS_WITH_RELS) {
416+
RestAddressableModel model = modelClass.getDeclaredConstructor().newInstance();
417+
LinksRest linksRest = modelClass.getDeclaredAnnotation(LinksRest.class);
418+
assertNotNull(modelClass.getSimpleName() + " is expected to declare @LinksRest", linksRest);
419+
for (LinkRest linkRest : linksRest.links()) {
420+
cells.add(new String[] {model.getCategory(), model.getTypePlural(), linkRest.name()});
421+
}
422+
}
423+
return cells;
424+
}
425+
426+
private String parentUrl(String[] cell, Object id) {
427+
return "/api/" + cell[0] + "/" + cell[1] + "/" + id;
428+
}
429+
430+
private int anonymousStatus(String url) throws Exception {
431+
return getClient().perform(get(url)).andReturn().getResponse().getStatus();
432+
}
433+
434+
/**
435+
* A rel must answer an anonymous caller exactly as its own parent does, or the status code becomes an
436+
* oracle: {@code ClarinLicenseResourceUserAllowanceService.find} returns null for a missing row before
437+
* {@code authorizeClruaAction} ever runs, so an unguarded link method answered 404 for an unknown id and
438+
* 401 for an existing one, while the parent findOne answers 401 for both. Anonymous callers could
439+
* therefore probe which allowance ids exist.
440+
* <P>
441+
* The cells come from the models' own annotations, so this covers all six CLARIN rels rather than the two
442+
* that leaked, and picks up any rel added later.
443+
*/
444+
@Test
445+
public void anonymousRelStatusMatchesParentForUnknownId() throws Exception {
446+
List<String[]> cells = clarinRelCells();
447+
assertEquals("Expected the six CLARIN rel cells; the matrix changed shape: " + cells.size(),
448+
6, cells.size());
449+
450+
for (String[] cell : cells) {
451+
String parent = parentUrl(cell, UNKNOWN_ID);
452+
String rel = parent + "/" + cell[2];
453+
assertEquals("Anonymous " + rel + " must answer the same status as its parent " + parent,
454+
anonymousStatus(parent), anonymousStatus(rel));
455+
}
456+
}
457+
458+
/**
459+
* The other half of the same leak, seen from the entity rather than from the parent: for an anonymous
460+
* caller the answer must not depend on whether the allowance exists.
461+
*/
462+
@Test
463+
public void anonymousClruaRelsRevealNothingAboutExistence() throws Exception {
464+
ClarinLicenseResourceUserAllowance existing = allowanceOwnedByEPerson();
465+
466+
for (String rel : new String[] {ClarinLicenseResourceUserAllowanceRest.RESOURCE_MAPPING,
467+
ClarinLicenseResourceUserAllowanceRest.USER_REGISTRATION,
468+
ClarinLicenseResourceUserAllowanceRest.USER_METADATA}) {
469+
int onExisting = anonymousStatus(ALLOWANCES_URL + existing.getID() + "/" + rel);
470+
int onUnknown = anonymousStatus(ALLOWANCES_URL + UNKNOWN_ID + "/" + rel);
471+
assertEquals("The anonymous status of the " + rel + " rel tells the caller whether the allowance"
472+
+ " exists (existing id vs unknown id)", onExisting, onUnknown);
473+
}
474+
}
475+
476+
/**
477+
* The not-found message of the userMetadata rel said "for if:" instead of "for id:" on both dtq-dev and
478+
* the v9 base. An authenticated caller is past the guard, so this is the caller who can still see it.
479+
*/
480+
@Test
481+
public void clruaUserMetadataNotFoundMessageUsesId() throws Exception {
482+
String epersonToken = getAuthToken(eperson.getEmail(), password);
483+
MockHttpServletResponse response = getClient(epersonToken)
484+
.perform(get(ALLOWANCES_URL + UNKNOWN_ID + "/"
485+
+ ClarinLicenseResourceUserAllowanceRest.USER_METADATA))
486+
.andExpect(status().isNotFound())
487+
.andReturn().getResponse();
488+
// MockMvc renders no error page, so the text of a sendError() lands in the error message rather
489+
// than in the body; read both so the assertion holds however the advice reports it.
490+
String message = Objects.toString(response.getErrorMessage(), "") + response.getContentAsString();
491+
492+
assertTrue("The not-found message should read \"for id: \", but was: " + message,
493+
message.contains("for id: "));
494+
assertFalse("The \"for if: \" typo is back: " + message, message.contains("for if: "));
495+
}
496+
497+
/**
498+
* Guard against over-fixing. {@code ClarinResourceMappingCLicenseLinkRepository} must stay unguarded:
499+
* its parent {@code ClarinLicenseResourceMappingRestRepository.findOne} is {@code permitAll()} and the
500+
* Angular licence agreement page follows this rel anonymously, so adding {@code @PreAuthorize} here in a
501+
* later "security cleanup" would break the anonymous download flow.
502+
*/
503+
@Test
504+
public void anonymousResourceMappingClarinLicenseRelStaysPublic() throws Exception {
505+
ClarinLicenseResourceMapping mapping = resourceMappingWithLicence();
506+
507+
getClient().perform(get("/api/" + ClarinLicenseResourceMappingRest.CATEGORY + "/"
508+
+ ClarinLicenseResourceMappingRest.PLURAL_NAME + "/" + mapping.getID() + "/"
509+
+ ClarinLicenseResourceMappingRest.CLARIN_LICENSE))
510+
.andExpect(status().isOk())
511+
.andExpect(jsonPath("$.id", is(mapping.getLicense().getID())));
512+
}
513+
514+
/**
515+
* Builds a bitstream with a CLARIN licence attached, i.e. the resource mapping the licence agreement page
516+
* reads anonymously.
517+
*
518+
* @return the resource mapping created by attaching the licence
519+
*/
520+
private ClarinLicenseResourceMapping resourceMappingWithLicence() throws Exception {
521+
context.turnOffAuthorisationSystem();
522+
parentCommunity = CommunityBuilder.createCommunity(context)
523+
.withName("Parent Community")
524+
.build();
525+
Collection collection = CollectionBuilder.createCollection(context, parentCommunity)
526+
.withName("Collection 1")
527+
.build();
528+
Item item = ItemBuilder.createItem(context, collection)
529+
.withTitle("Item with a licensed bitstream")
530+
.withIssueDate("2026-09-10")
531+
.build();
532+
Bitstream bitstream;
533+
try (InputStream is = new ByteArrayInputStream("public".getBytes(StandardCharsets.UTF_8))) {
534+
bitstream = BitstreamBuilder.createBitstream(context, item, is)
535+
.withName("public.txt")
536+
.withMimeType("text/plain")
537+
.build();
538+
}
539+
540+
ClarinLicenseLabel label = ClarinLicenseLabelBuilder.createClarinLicenseLabel(context).build();
541+
label.setLabel("PUB");
542+
label.setTitle("Public rel label");
543+
label.setExtended(false);
544+
clarinLicenseLabelService.update(context, label);
545+
546+
ClarinLicense licence = ClarinLicenseBuilder.createClarinLicense(context).build();
547+
licence.setName("Public rel licence");
548+
licence.setDefinition("http://example.com/licence");
549+
licence.setRequiredInfo("NAME");
550+
licence.setConfirmation(ClarinLicense.Confirmation.NOT_REQUIRED);
551+
HashSet<ClarinLicenseLabel> labels = new HashSet<>();
552+
labels.add(label);
553+
licence.setLicenseLabels(labels);
554+
clarinLicenseService.update(context, licence);
555+
556+
clarinLicenseResourceMappingService.attachLicense(context, licence, bitstream);
557+
List<ClarinLicenseResourceMapping> mappings =
558+
clarinLicenseResourceMappingService.findByBitstreamUUID(context, bitstream.getID());
559+
assertEquals("The licence fixture did not attach exactly one resource mapping", 1, mappings.size());
560+
publicResourceMapping = mappings.get(0);
561+
context.restoreAuthSystemState();
562+
return publicResourceMapping;
563+
}
353564
}

0 commit comments

Comments
 (0)