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}