001/* 002 * Licensed to DuraSpace under one or more contributor license agreements. 003 * See the NOTICE file distributed with this work for additional information 004 * regarding copyright ownership. 005 * 006 * DuraSpace licenses this file to you under the Apache License, 007 * Version 2.0 (the "License"); you may not use this file except in 008 * compliance with the License. You may obtain a copy of the License at 009 * 010 * http://www.apache.org/licenses/LICENSE-2.0 011 * 012 * Unless required by applicable law or agreed to in writing, software 013 * distributed under the License is distributed on an "AS IS" BASIS, 014 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 015 * See the License for the specific language governing permissions and 016 * limitations under the License. 017 */ 018package org.fcrepo.integration.http.api; 019 020import static java.nio.file.StandardOpenOption.APPEND; 021import static javax.ws.rs.core.HttpHeaders.CONTENT_LOCATION; 022import static javax.ws.rs.core.HttpHeaders.CONTENT_TYPE; 023import static javax.ws.rs.core.HttpHeaders.LINK; 024import static org.apache.http.HttpStatus.SC_CREATED; 025import static org.apache.http.HttpStatus.SC_OK; 026import static org.apache.http.HttpStatus.SC_BAD_REQUEST; 027import static org.fcrepo.kernel.api.RdfLexicon.NON_RDF_SOURCE; 028import static org.junit.Assert.assertEquals; 029import static org.slf4j.LoggerFactory.getLogger; 030 031import java.io.BufferedWriter; 032import java.io.File; 033import java.net.ConnectException; 034import java.nio.file.Files; 035import java.nio.file.Path; 036import java.util.Arrays; 037import java.util.List; 038 039import org.apache.commons.io.FileUtils; 040import org.apache.commons.io.IOUtils; 041import org.apache.http.NoHttpResponseException; 042import org.apache.http.client.methods.CloseableHttpResponse; 043import org.apache.http.client.methods.HttpGet; 044import org.apache.http.client.methods.HttpPost; 045import org.apache.http.client.methods.HttpPut; 046import org.apache.http.entity.StringEntity; 047import org.junit.Before; 048import org.junit.Test; 049import org.junit.runner.RunWith; 050import org.slf4j.Logger; 051import org.springframework.test.annotation.DirtiesContext; 052import org.springframework.test.annotation.DirtiesContext.ClassMode; 053import org.springframework.test.context.TestExecutionListeners; 054import org.springframework.test.context.TestExecutionListeners.MergeMode; 055import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; 056import org.springframework.test.context.support.DependencyInjectionTestExecutionListener; 057 058/** 059 * @author bbpennel 060 */ 061@RunWith(SpringJUnit4ClassRunner.class) 062@DirtiesContext(classMode = ClassMode.BEFORE_CLASS) 063@TestExecutionListeners(listeners = { 064 DependencyInjectionTestExecutionListener.class, 065 TestIsolationExecutionListener.class, 066 DirtyContextBeforeAndAfterClassTestExecutionListener.class 067}, mergeMode = MergeMode.MERGE_WITH_DEFAULTS) 068public class ExternalContentPathValidatorIT extends AbstractResourceIT { 069 070 private static final Logger LOGGER = getLogger(ExternalContentPathValidatorIT.class); 071 072 private static final String NON_RDF_SOURCE_LINK_HEADER = "<" + NON_RDF_SOURCE.getURI() + ">;rel=\"type\""; 073 074 private static File disallowedDir; 075 private static File allowedDir; 076 077 static { 078 try { 079 final File allowedFile = File.createTempFile("allowed", ".txt"); 080 allowedFile.deleteOnExit(); 081 addAllowedPath(allowedFile, serverAddress); 082 083 final Path disallowedPath = Files.createTempDirectory("disallowed"); 084 disallowedPath.toFile().deleteOnExit(); 085 disallowedDir = disallowedPath.toFile(); 086 allowedDir = Files.createTempDirectory(disallowedPath, "data").toFile(); 087 addAllowedPath(allowedFile, allowedDir.toURI().toString()); 088 089 System.setProperty("fcrepo.external.content.allowed", allowedFile.getAbsolutePath()); 090 LOGGER.warn("fcrepo.external.content.allowed = {}", allowedFile.getAbsolutePath()); 091 } catch (final Exception e) { 092 LOGGER.error("Failed to setup allowed configuration file", e); 093 } 094 } 095 096 private static void addAllowedPath(final File allowedFile, final String allowed) throws Exception { 097 try (final BufferedWriter writer = Files.newBufferedWriter(allowedFile.toPath(), APPEND)) { 098 writer.write(allowed + System.lineSeparator()); 099 } 100 } 101 102 @Before 103 public void init() throws Exception { 104 // Because of the dirtied context, need to wait for fedora to restart before testing 105 int triesRemaining = 50; 106 while (true) { 107 final HttpGet get = new HttpGet(serverAddress); 108 try (final CloseableHttpResponse response = execute(get)) { 109 assertEquals(SC_OK, getStatus(response)); 110 break; 111 } catch (final NoHttpResponseException | ConnectException e) { 112 if (triesRemaining-- > 0) { 113 LOGGER.debug("Waiting for fedora to become available"); 114 Thread.sleep(50); 115 } else { 116 throw new Exception("Fedora instance did not become available in allowed time"); 117 } 118 } 119 } 120 // Now that fedora has started, clear the property so it won't impact other tests 121 System.clearProperty("fcrepo.external.content.allowed"); 122 } 123 124 @Test 125 public void testAllowedPath() throws Exception { 126 final HttpPost method = postObjMethod(); 127 method.addHeader(CONTENT_TYPE, "text/plain"); 128 method.addHeader(LINK, NON_RDF_SOURCE_LINK_HEADER); 129 method.setEntity(new StringEntity("xyz")); 130 final String externalLocation; 131 132 // Make an external remote URI. 133 try (final CloseableHttpResponse response = execute(method)) { 134 assertEquals(SC_CREATED, getStatus(response)); 135 externalLocation = getLocation(response); 136 } 137 138 final String id = getRandomUniqueId(); 139 140 final HttpPut put = putObjMethod(id); 141 put.addHeader(LINK, getExternalContentLinkHeader(externalLocation, "proxy", null)); 142 try (final CloseableHttpResponse response = execute(put)) { 143 assertEquals(SC_CREATED, getStatus(response)); 144 } 145 // Get the external content proxy resource. 146 try (final CloseableHttpResponse response = execute(getObjMethod(id))) { 147 assertEquals(SC_OK, getStatus(response)); 148 assertEquals("text/plain", response.getFirstHeader(CONTENT_TYPE).getValue()); 149 assertEquals(externalLocation, response.getFirstHeader(CONTENT_LOCATION).getValue()); 150 } 151 } 152 153 @Test 154 public void testDisallowedPath() throws Exception { 155 final String externalLocation = "http://example.com/"; 156 157 final String id = getRandomUniqueId(); 158 159 final HttpPut put = putObjMethod(id); 160 put.addHeader(LINK, getExternalContentLinkHeader(externalLocation, "proxy", null)); 161 try (final CloseableHttpResponse response = execute(put)) { 162 assertEquals(SC_BAD_REQUEST, getStatus(response)); 163 } 164 } 165 166 @Test 167 public void testAllowedFilePath() throws Exception { 168 final String fileContent = "content"; 169 final File permittedFile = new File(allowedDir, "test.txt"); 170 FileUtils.writeStringToFile(permittedFile, fileContent, "UTF-8"); 171 final String fileUri = permittedFile.toURI().toString(); 172 173 final String id = getRandomUniqueId(); 174 final HttpPut put = putObjMethod(id); 175 put.addHeader(LINK, getExternalContentLinkHeader(fileUri, "proxy", "text/plain")); 176 try (final CloseableHttpResponse response = execute(put)) { 177 assertEquals(SC_CREATED, getStatus(response)); 178 } 179 // Get the external content proxy resource. 180 try (final CloseableHttpResponse response = execute(getObjMethod(id))) { 181 assertEquals(SC_OK, getStatus(response)); 182 assertEquals("text/plain", response.getFirstHeader(CONTENT_TYPE).getValue()); 183 assertEquals(fileUri, response.getFirstHeader(CONTENT_LOCATION).getValue()); 184 assertEquals(fileContent, IOUtils.toString(response.getEntity().getContent(), "UTF-8")); 185 } 186 } 187 188 @Test 189 public void testAllowedCaseSensitiveFilePath() throws Exception { 190 final String fileContent = "content"; 191 final File permittedFile = new File(allowedDir, "TEST.txt"); 192 FileUtils.writeStringToFile(permittedFile, fileContent, "UTF-8"); 193 final String fileUri = permittedFile.toURI().toString(); 194 195 final String id = getRandomUniqueId(); 196 final HttpPut put = putObjMethod(id); 197 put.addHeader(LINK, getExternalContentLinkHeader(fileUri, "proxy", "text/plain")); 198 try (final CloseableHttpResponse response = execute(put)) { 199 assertEquals(SC_CREATED, getStatus(response)); 200 } 201 // Get the external content proxy resource. 202 try (final CloseableHttpResponse response = execute(getObjMethod(id))) { 203 assertEquals(SC_OK, getStatus(response)); 204 assertEquals("text/plain", response.getFirstHeader(CONTENT_TYPE).getValue()); 205 assertEquals(fileUri, response.getFirstHeader(CONTENT_LOCATION).getValue()); 206 assertEquals(fileContent, IOUtils.toString(response.getEntity().getContent(), "UTF-8")); 207 } 208 } 209 210 @Test 211 public void testDisallowedFilePath() throws Exception { 212 final String fileContent = "content"; 213 final File disallowedFile = new File(disallowedDir, "test.txt"); 214 FileUtils.writeStringToFile(disallowedFile, fileContent, "UTF-8"); 215 final String fileUri = disallowedFile.toURI().toString(); 216 217 final String id = getRandomUniqueId(); 218 final HttpPut put = putObjMethod(id); 219 put.addHeader(LINK, getExternalContentLinkHeader(fileUri, "proxy", "text/plain")); 220 try (final CloseableHttpResponse response = execute(put)) { 221 assertEquals(SC_BAD_REQUEST, getStatus(response)); 222 } 223 } 224 225 @Test 226 public void testPathModifiers() throws Exception { 227 // Creating file in disallowed path 228 final String fileContent = "content"; 229 final File disallowedFile = new File(disallowedDir, "test.txt"); 230 FileUtils.writeStringToFile(disallowedFile, fileContent, "UTF-8"); 231 232 // Variations of path modifiers that should be rejected or fail to find file. 233 final List<String> modifiers = Arrays.asList("../", "%2e%2e%2f", "%2e%2e/", "..%2f", 234 "%252e%252e%255c", "%2e%2e%5c", "%2e%2e%5c%2f", "..%c0%af"); 235 236 for (final String modifier : modifiers) { 237 // Attempt to address file with escaped uri modifiers 238 final String externalLocation = allowedDir.toURI().toString() + modifier + "test.txt"; 239 240 final String id = getRandomUniqueId(); 241 final HttpPut put = putObjMethod(id); 242 put.addHeader(LINK, getExternalContentLinkHeader(externalLocation, "proxy", "text/plain")); 243 try (final CloseableHttpResponse response = execute(put)) { 244 assertEquals("Path " + externalLocation + " must be rejected", SC_BAD_REQUEST, getStatus(response)); 245 } 246 } 247 } 248}