diff --git a/geowebcache/core/src/main/java/org/geowebcache/filter/request/FileRasterFilter.java b/geowebcache/core/src/main/java/org/geowebcache/filter/request/FileRasterFilter.java index 66013efceb..6d826029c5 100644 --- a/geowebcache/core/src/main/java/org/geowebcache/filter/request/FileRasterFilter.java +++ b/geowebcache/core/src/main/java/org/geowebcache/filter/request/FileRasterFilter.java @@ -14,12 +14,24 @@ package org.geowebcache.filter.request; import java.awt.image.BufferedImage; +import java.io.ByteArrayInputStream; +import java.io.EOFException; import java.io.File; -import java.io.FileOutputStream; import java.io.IOException; +import java.io.InputStream; import java.io.Serial; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.Locale; +import java.util.Objects; +import java.util.Set; +import javax.imageio.IIOException; import javax.imageio.ImageIO; +import javax.imageio.ImageReader; +import javax.imageio.stream.ImageInputStream; import org.geowebcache.GeoWebCacheException; +import org.geowebcache.grid.GridSubset; import org.geowebcache.layer.TileLayer; public class FileRasterFilter extends RasterFilter { @@ -27,6 +39,9 @@ public class FileRasterFilter extends RasterFilter { @Serial private static final long serialVersionUID = -6950985531575208956L; + /** Extensions of the raster formats a matrix can be stored in, all readable by the ImageIO readers. */ + private static final Set RASTER_EXTENSIONS = Set.of("png", "gif", "jpg", "jpeg", "tif", "tiff", "bmp"); + private String storagePath; private String fileExtension; @@ -54,52 +69,87 @@ void setFileExtension(String fileExtension) { @Override protected BufferedImage loadMatrix(TileLayer layer, String gridSetId, int zoomLevel) throws IOException, GeoWebCacheException { - File fh = new File(createFilePath(gridSetId, zoomLevel)); + File fh = getMatrixPath(gridSetId, zoomLevel).toFile(); if (!fh.exists() || !fh.canRead()) { throw new GeoWebCacheException(fh.getAbsolutePath() + " does not exist or is not readable"); } - BufferedImage img = ImageIO.read(fh); - - int[] widthHeight = calculateWidthHeight(layer.getGridSubset(gridSetId), zoomLevel); - - if (img.getWidth() != widthHeight[0] || img.getHeight() != widthHeight[1]) { - String msg = fh.getAbsolutePath() - + " has dimensions " - + img.getWidth() - + "," - + img.getHeight() - + ", expected " - + widthHeight[0] - + "," - + widthHeight[1]; - throw new GeoWebCacheException(msg); - } - - return img; + return readMatrix(fh, layer.getGridSubset(gridSetId), zoomLevel, fh.getAbsolutePath()); } - private String createFilePath(String gridSetId, int zoomLevel) { - String path = - storagePath + File.separator + this.getName() + "_" + gridSetId + "_" + zoomLevel + "." + fileExtension; + /** Decodes the matrix from a {@link File} or {@link InputStream}, checking format and size before decoding. */ + private BufferedImage readMatrix(Object input, GridSubset subset, int zoomLevel, String source) + throws IOException, GeoWebCacheException { + int[] widthHeight = calculateWidthHeight(subset, zoomLevel); + ImageReader reader = getReader(); + try (ImageInputStream iis = ImageIO.createImageInputStream(input)) { + // magic bytes check, some readers (e.g. TIFF) only warn on a foreign header + if (!reader.getOriginatingProvider().canDecodeInput(iis)) { + throw new GeoWebCacheException(source + " is not a " + fileExtension + " image"); + } + reader.setInput(iis, true, true); + // header only, a huge image must fail before decoding it + int width = reader.getWidth(0); + int height = reader.getHeight(0); + if (width != widthHeight[0] || height != widthHeight[1]) { + throw new GeoWebCacheException(source + " has dimensions " + width + "," + height + ", expected " + + widthHeight[0] + "," + widthHeight[1]); + } + return reader.read(0); + } finally { + reader.dispose(); + } + } - return path; + /** Returns a new reader for the configured extension, the caller must dispose it. */ + private ImageReader getReader() throws IOException { + // lower case, the JDK readers register lower case suffixes only + String extension = Objects.toString(fileExtension, "").toLowerCase(Locale.ENGLISH); + // allowlist, uploaded bytes must not reach whatever reader plugin is on the classpath + if (!RASTER_EXTENSIONS.contains(extension)) { + throw new IOException("Unsupported raster file extension: " + fileExtension); + } + return ImageIO.getImageReadersBySuffix(extension).next(); } - public void saveMatrix(byte[] data, TileLayer layer, String gridSetId, int zoomLevel) throws IOException { - // Persist - File fh = new File(createFilePath(gridSetId, zoomLevel)); - try (FileOutputStream fos = new FileOutputStream(fh)) { - fos.write(data); + private Path getMatrixPath(String gridSetId, int zoomLevel) throws IOException { + Path dir = Path.of(storagePath).normalize(); + String fileName = getName() + "_" + gridSetId + "_" + zoomLevel + "." + fileExtension; + Path file = dir.resolve(fileName).normalize(); + if (!dir.equals(file.getParent())) { + throw new IOException("Invalid matrix file: " + fileName); } + return file; } + /** + * {@inheritDoc} + * + * @throws IllegalArgumentException if the grid set is not configured for the layer, z is out of its range, or the + * data is not an image of the configured format and expected size + */ @Override public void update(byte[] filterData, TileLayer layer, String gridSetId, int z) throws GeoWebCacheException { + GridSubset subset = layer.getGridSubset(gridSetId); + if (subset == null) { + throw new IllegalArgumentException( + "Unknown grid set " + gridSetId + " for layer " + layer.getName() + ": Typo?"); + } + // the matrices are cached only up to zoomStop + if (z > getZoomStop() || subset.getCoverage(z) == null) { + throw new IllegalArgumentException("Zoom level " + z + " is out of range for grid set " + gridSetId); + } try { - saveMatrix(filterData, layer, gridSetId, z); - + // validate before writing, a bad upload must not replace a readable matrix + readMatrix(new ByteArrayInputStream(filterData), subset, z, "Uploaded matrix"); + writeMatrix(getMatrixPath(gridSetId, z), filterData); + + } catch (GeoWebCacheException e) { + throw new IllegalArgumentException(e.getMessage(), e); + } catch (IIOException | EOFException e) { + // decoding failure or data too short, a plain IOException is a storage or configuration problem instead + throw new IllegalArgumentException("Uploaded matrix is not a readable " + fileExtension + " image", e); } catch (IOException e) { throw new GeoWebCacheException( this.getName() + " encountered an error while persisting matrix, " + e.getMessage()); @@ -113,6 +163,18 @@ public void update(byte[] filterData, TileLayer layer, String gridSetId, int z) } } + /** Replaces the matrix file atomically, a concurrent load never sees it half written. */ + private void writeMatrix(Path target, byte[] data) throws IOException { + // same folder, so the move is a rename; the .tmp suffix never matches a matrix file name + Path tmp = Files.createTempFile(Path.of(storagePath), null, ".tmp"); + try { + Files.write(tmp, data); + Files.move(tmp, target, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE); + } finally { + Files.deleteIfExists(tmp); + } + } + @Override public void update(TileLayer layer, String gridSetId, int zoomStart, int zoomStop) throws GeoWebCacheException { throw new GeoWebCacheException( diff --git a/geowebcache/core/src/test/java/org/geowebcache/filter/request/FileRasterFilterTest.java b/geowebcache/core/src/test/java/org/geowebcache/filter/request/FileRasterFilterTest.java new file mode 100644 index 0000000000..77c0820b18 --- /dev/null +++ b/geowebcache/core/src/test/java/org/geowebcache/filter/request/FileRasterFilterTest.java @@ -0,0 +1,150 @@ +/** + * This program is free software: you can redistribute it and/or modify it under the terms of the GNU Lesser General + * Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any + * later version. + * + *

This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied + * warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. + * + *

You should have received a copy of the GNU Lesser General Public License along with this program. If not, see + * . + * + *

Copyright 2026 + */ +package org.geowebcache.filter.request; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.arrayContaining; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.emptyArray; +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.awt.image.BufferedImage; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; +import javax.imageio.ImageIO; +import org.geowebcache.GeoWebCacheException; +import org.geowebcache.grid.GridSubset; +import org.geowebcache.layer.TileLayer; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.function.ThrowingRunnable; +import org.junit.rules.TemporaryFolder; + +public class FileRasterFilterTest { + + // no colon, Windows paths reject it + private static final String GRID_SET = "grid"; + + @Rule + public TemporaryFolder temp = new TemporaryFolder(); + + private TileLayer layer; + + @Before + public void setup() { + // a 2x2 tiles coverage at level 0 + GridSubset subset = mock(GridSubset.class); + when(subset.getCoverage(0)).thenReturn(new long[] {0, 0, 1, 1, 0}); + layer = mock(TileLayer.class); + when(layer.getGridSubset(GRID_SET)).thenReturn(subset); + } + + @Test + public void testUnsupportedExtension() { + assertUpdateFails(newFilter("shp"), new byte[] {0}, GRID_SET, 0, "Unsupported raster file extension: shp"); + } + + @Test + public void testMissingExtension() { + assertUpdateFails(newFilter(null), new byte[] {0}, GRID_SET, 0, "Unsupported raster file extension: null"); + } + + @Test + public void testUnknownGridSet() throws Exception { + // typo, the layer only has "grid" + assertRejected(newFilter("png"), png(2, 2), "gird", 0, "Unknown grid set gird"); + } + + @Test + public void testZoomOutOfRange() throws Exception { + assertRejected(newFilter("png"), png(2, 2), GRID_SET, 3, "Zoom level 3 is out of range"); + } + + @Test + public void testNotARaster() { + assertRejected(newFilter("png"), "".getBytes(UTF_8), GRID_SET, 0, "is not a png image"); + } + + @Test + public void testFormatMismatch() throws Exception { + assertRejected(newFilter("tiff"), png(2, 2), GRID_SET, 0, "is not a tiff image"); + } + + @Test + public void testWrongSize() throws Exception { + assertRejected(newFilter("png"), png(3, 3), GRID_SET, 0, "has dimensions 3,3, expected 2,2"); + } + + @Test + public void testTruncatedImage() throws Exception { + // valid PNG signature, header cut short + byte[] truncated = Arrays.copyOf(png(2, 2), 20); + assertRejected(newFilter("png"), truncated, GRID_SET, 0, "is not a readable png image"); + } + + @Test + public void testInvalidUploadKeepsMatrix() throws Exception { + FileRasterFilter filter = newFilter("png"); + byte[] valid = png(2, 2); + filter.update(valid, layer, GRID_SET, 0); + + assertThrows(IllegalArgumentException.class, () -> filter.update(png(3, 3), layer, GRID_SET, 0)); + Path matrix = temp.getRoot().toPath().resolve("test_grid_0.png"); + assertArrayEquals(valid, Files.readAllBytes(matrix)); + // no temp file left behind + assertThat(temp.getRoot().list(), arrayContaining("test_grid_0.png")); + assertEquals(2, filter.matrices.get(GRID_SET)[0].getWidth()); + } + + /** Checks the update rejects the request as a client error, see {@link #assertFails}. */ + private void assertRejected(FileRasterFilter filter, byte[] data, String gridSetId, int z, String message) { + assertFails(IllegalArgumentException.class, () -> filter.update(data, layer, gridSetId, z), message); + } + + /** Checks the update fails as a server error, see {@link #assertFails}. */ + private void assertUpdateFails(FileRasterFilter filter, byte[] data, String gridSetId, int z, String message) { + assertFails(GeoWebCacheException.class, () -> filter.update(data, layer, gridSetId, z), message); + } + + /** Checks the update fails with the given message and leaves the storage folder empty. */ + private void assertFails(Class type, ThrowingRunnable update, String message) { + Exception e = assertThrows(type, update); + assertThat(e.getMessage(), containsString(message)); + assertThat(temp.getRoot().list(), emptyArray()); + } + + private static byte[] png(int width, int height) throws IOException { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + ImageIO.write(new BufferedImage(width, height, BufferedImage.TYPE_BYTE_GRAY), "png", out); + return out.toByteArray(); + } + + private FileRasterFilter newFilter(String extension) { + FileRasterFilter filter = new FileRasterFilter(); + filter.setName("test"); + filter.setStoragePath(temp.getRoot().getPath()); + filter.setFileExtension(extension); + filter.setZoomStop(2); + return filter; + } +} diff --git a/geowebcache/rest/src/main/java/org/geowebcache/rest/filter/ZipFilterUpdate.java b/geowebcache/rest/src/main/java/org/geowebcache/rest/filter/ZipFilterUpdate.java index 16bb528c32..1294463320 100644 --- a/geowebcache/rest/src/main/java/org/geowebcache/rest/filter/ZipFilterUpdate.java +++ b/geowebcache/rest/src/main/java/org/geowebcache/rest/filter/ZipFilterUpdate.java @@ -17,6 +17,8 @@ import java.io.IOException; import java.io.InputStream; import java.util.logging.Logger; +import java.util.regex.Matcher; +import java.util.regex.Pattern; import java.util.zip.ZipEntry; import java.util.zip.ZipInputStream; import org.geotools.util.logging.Logging; @@ -48,13 +50,16 @@ public void runUpdate(RequestFilter filter, TileLayer tl) throws RestException { throw new RestException("Zip file cannot contain directories.", HttpStatus.BAD_REQUEST); } - String[] parsedName = parseName(ze.getName()); + String[] parsedName = parseName(ze.getName(), filter.getName()); byte[] data = ServletUtils.readStream(zis, 16 * 1024, 1500, false); try { filter.update(data, tl, parsedName[0], Integer.parseInt(parsedName[1])); + } catch (IllegalArgumentException e) { + throw new RestException( + "Error updating " + filter.getName() + ": " + e.getMessage(), HttpStatus.BAD_REQUEST); } catch (GeoWebCacheException e) { throw new RestException( "Error updating " + filter.getName() + ": " + e.getMessage(), @@ -75,15 +80,16 @@ public void runUpdate(RequestFilter filter, TileLayer tl) throws RestException { } } - String[] parseName(String fileName) throws RestException { - String[] strs = fileName.split("_"); - - // Slice away the extension, we dont have the data to test it - String[] zExt = strs[2].split("\\."); - strs[2] = zExt[0]; - - String[] gridSetIdZ = {strs[1], strs[2]}; - - return gridSetIdZ; + /** Splits a {@code __.} entry name into grid set id and zoom level. */ + String[] parseName(String fileName, String filterName) throws RestException { + // greedy grid set group, ids may contain '_'; up to 9 digits always fit an int + Matcher m = Pattern.compile(Pattern.quote(filterName) + "_(.+)_(\\d{1,9})(\\.\\w+)?") + .matcher(fileName); + if (!m.matches()) { + throw new RestException( + "Invalid file name " + fileName + ", expected " + filterName + "__.", + HttpStatus.BAD_REQUEST); + } + return new String[] {m.group(1), m.group(2)}; } } diff --git a/geowebcache/rest/src/test/java/org/geowebcache/rest/filter/FilterUpdateControllerTest.java b/geowebcache/rest/src/test/java/org/geowebcache/rest/filter/FilterUpdateControllerTest.java index 7233fc14c3..1e6b38e2a7 100644 --- a/geowebcache/rest/src/test/java/org/geowebcache/rest/filter/FilterUpdateControllerTest.java +++ b/geowebcache/rest/src/test/java/org/geowebcache/rest/filter/FilterUpdateControllerTest.java @@ -1,9 +1,19 @@ package org.geowebcache.rest.filter; +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.arrayContaining; +import static org.hamcrest.Matchers.containsString; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.InputStream; import java.util.LinkedList; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; import org.geowebcache.GeoWebCacheException; import org.geowebcache.config.MockConfigurationResourceProvider; import org.geowebcache.config.MockGridSetConfiguration; @@ -17,10 +27,12 @@ import org.geowebcache.grid.SRS; import org.geowebcache.layer.TileLayerDispatcher; import org.geowebcache.rest.controller.FilterUpdateController; +import org.geowebcache.rest.controller.RestExceptionHandler; import org.junit.Before; import org.junit.Test; import org.springframework.http.MediaType; import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.ResultActions; import org.springframework.test.web.servlet.setup.MockMvcBuilders; public class FilterUpdateControllerTest { @@ -33,6 +45,10 @@ public class FilterUpdateControllerTest { @Before public void setup() throws GeoWebCacheException { + setup("tiff"); + } + + private void setup(String fileExtension) throws GeoWebCacheException { BoundingBox extent = new BoundingBox(0, 0, 10E6, 10E6); boolean alignTopLeft = false; @@ -56,7 +72,7 @@ public void setup() throws GeoWebCacheException { GridSetBroker gridSetBroker = new GridSetBroker(MockGridSetConfiguration.withDefaults(gridSet)); - XMLConfiguration xmlConfig = loadXMLConfig(); + XMLConfiguration xmlConfig = loadXMLConfig(fileExtension); xmlConfig.setGridSetBroker(gridSetBroker); xmlConfig.afterPropertiesSet(); LinkedList configList = new LinkedList<>(); @@ -65,17 +81,22 @@ public void setup() throws GeoWebCacheException { tld = new TileLayerDispatcher(gridSetBroker, configList, null); fc = new FilterUpdateController(); fc.setTileLayerDispatcher(tld); - this.mockMvc = MockMvcBuilders.standaloneSetup(fc).build(); + this.mockMvc = MockMvcBuilders.standaloneSetup(fc) + .setControllerAdvice(new RestExceptionHandler()) + .build(); } - private XMLConfiguration loadXMLConfig() { + /** Loads the 1.2.5 test config, with the given file raster filter extension. */ + private XMLConfiguration loadXMLConfig(String fileExtension) { XMLConfiguration xmlConfig = null; - try { + try (InputStream in = XMLConfiguration.class.getResourceAsStream( + XMLConfigurationBackwardsCompatibilityTest.GWC_125_CONFIG_FILE)) { + String config = new String(in.readAllBytes(), UTF_8) + .replace("tiff<", "" + fileExtension + "<"); xmlConfig = new XMLConfiguration( null, - new MockConfigurationResourceProvider(() -> XMLConfiguration.class.getResourceAsStream( - XMLConfigurationBackwardsCompatibilityTest.GWC_125_CONFIG_FILE))); + new MockConfigurationResourceProvider(() -> new ByteArrayInputStream(config.getBytes(UTF_8)))); } catch (Exception e) { // Do nothing } @@ -101,4 +122,62 @@ public void testPost() throws Exception { .content(filterXml)) .andExpect(status().is2xxSuccessful()); } + + @Test + public void testZipUpdateUnsupportedExtension() throws Exception { + setup("shp"); + postZip("testFileRasterFilter_EPSG:4326_0.shp") + .andExpect(status().isInternalServerError()) + .andExpect(content().string(containsString("Unsupported raster file extension: shp"))); + } + + @Test + public void testZipUpdateUnknownGridSet() throws Exception { + // EPGS typo, the layer only has EPSG:4326 and EPSG:900913 + postZip("testFileRasterFilter_EPGS:4326_0.tiff") + .andExpect(status().isBadRequest()) + .andExpect(content().string(containsString("Unknown grid set EPGS:4326"))); + } + + @Test + public void testZipUpdateNotARaster() throws Exception { + // the single byte entry is shorter than the TIFF magic bytes + postZip("testFileRasterFilter_EPSG:4326_0.tiff") + .andExpect(status().isBadRequest()) + .andExpect(content().string(containsString("Uploaded matrix is not a readable tiff image"))); + } + + @Test + public void testZipUpdateInvalidName() throws Exception { + // missing zoom level, other filter, not a number, int overflow + for (String name : new String[] { + "testFileRasterFilter_EPSG:4326.tiff", + "otherFilter_EPSG:4326_0.tiff", + "testFileRasterFilter_EPSG:4326_x.tiff", + "testFileRasterFilter_EPSG:4326_9999999999.tiff" + }) { + postZip(name) + .andExpect(status().isBadRequest()) + .andExpect(content().string(containsString("Invalid file name " + name))); + } + } + + @Test + public void testParseNameUnderscoreGridSet() throws Exception { + String[] parsed = new ZipFilterUpdate(null).parseName("my_filter_Grid_512_3.png", "my_filter"); + assertThat(parsed, arrayContaining("Grid_512", "3")); + } + + /** Posts a zip update for the file raster filter, with a single entry. */ + private ResultActions postZip(String entryName) throws Exception { + ByteArrayOutputStream zip = new ByteArrayOutputStream(); + try (ZipOutputStream zipOutputStream = new ZipOutputStream(zip)) { + zipOutputStream.putNextEntry(new ZipEntry(entryName)); + zipOutputStream.write(new byte[] {0}); + zipOutputStream.closeEntry(); + } + return this.mockMvc.perform(post("/rest/filter/testFileRasterFilter/update/zip") + .contextPath("") + .content(zip.toByteArray())); + } }