diff --git a/src/main/scala/data/DataService.scala b/src/main/scala/data/DataService.scala index 82a6489..7d2b019 100644 --- a/src/main/scala/data/DataService.scala +++ b/src/main/scala/data/DataService.scala @@ -81,7 +81,16 @@ class DataService(log: Logger[IO]) { Executors.newFixedThreadPool(8) // limit concurrency ) + private val MAX_BINARY_CHUNK_BYTES = 64 * 1024 * 1024 + def getBinaryChunk(offset: Int, length: Int, fileName: String): IO[Array[Byte]] = { + if (length > MAX_BINARY_CHUNK_BYTES || length < 0) + IO.raiseError(new IllegalArgumentException(s"Requested length $length is invalid (max $MAX_BINARY_CHUNK_BYTES bytes)")) + else + getBinaryChunkUnchecked(offset, length, fileName) + } + + private def getBinaryChunkUnchecked(offset: Int, length: Int, fileName: String): IO[Array[Byte]] = { val fileResource = Resource.make( IO.blocking(new RandomAccessFile(s"$GRIB_FOLDER/$fileName", "r")) )(file => IO.blocking(file.close())) diff --git a/src/main/scala/server/Server.scala b/src/main/scala/server/Server.scala index 44ac37d..a253c87 100644 --- a/src/main/scala/server/Server.scala +++ b/src/main/scala/server/Server.scala @@ -10,7 +10,7 @@ import fetch.csv.FileNameService import fetch.lvgmc.{FetchService, WaterTemperatureService} import fetch.warnings.WarningService import fs2.io.file.{Files, Path} -import server.ValidateRoutes.{AggFieldList, AggKey, CityList, DateTimeRange, Granularity, ValidateDate, ValidateDateTime, ValidateInt, ValidateMonths, ValidateZonedDateTime} +import server.ValidateRoutes.{AggFieldList, AggKey, CityList, DateTimeRange, Granularity, ValidateDate, ValidateDateTime, ValidateFileName, ValidateInt, ValidateMonths, ValidateZonedDateTime} import org.http4s._ import org.http4s.dsl.io._ import org.http4s.implicits._ @@ -64,24 +64,31 @@ class Server(postgresService: PostgresService, dataService: DataService, fetch: } // http://0.0.0.0:8080/api/show/lvgmc-forecast/Latvija_LTV_pilsetas_tekosa_dn.csv - case GET -> Root / "show" / "lvgmc-forecast" / fileName => + case GET -> Root / "show" / "lvgmc-forecast" / ValidateFileName(fileName) => fetch.fetchFile(fileName).flatMap(bytes => Ok(bytes).map(_.withContentType(`Content-Type`(MediaType.text.csv))) ) // http://0.0.0.0:8080/api/fetch/lvgmc/stations + // Gated behind the same flag as the scheduled FTP task: this route would + // otherwise let anyone unauthenticated trigger a real LVGMC FTP login on + // demand, bypassing ENABLE_LVGMC_FTP_JOBS entirely. case GET -> Root / "fetch" / "lvgmc" / "stations" => - ( - for { - fileName <- new FileNameService().generateCurrentHour - stationDataStr <- fetch.fetchWeatherStations() - _ <- postgresService.save(fileName, stationDataStr) - } yield stationDataStr - ) - .flatMap(content => Ok(content)) - .handleErrorWith(error => - InternalServerError(s"Failed to fetch stations: ${error.getMessage}") + if (sys.env.get("ENABLE_LVGMC_FTP_JOBS").exists(_.equalsIgnoreCase("true"))) + ( + for { + fileName <- new FileNameService().generateCurrentHour + stationDataStr <- fetch.fetchWeatherStations() + _ <- postgresService.save(fileName, stationDataStr) + } yield stationDataStr ) + .flatMap(content => Ok(content)) + .handleErrorWith(error => + log.error(error)("Failed to fetch LVGMC stations") *> + InternalServerError("Failed to fetch stations") + ) + else + ServiceUnavailable("LVGMC FTP fetching is currently disabled") // http://0.0.0.0:8080/api/show/grib-all-structure case GET -> Root / "show" / "grib-all-structure" => @@ -92,10 +99,10 @@ class Server(postgresService: PostgresService, dataService: DataService, fetch: dataService.getFileList().flatMap(fileList => Ok(fileList.asJson)) // http://0.0.0.0:8080/api/show/grib-name/harmonie_2025-02-01T1500Z_2025-02-01T180000Z.grib - case GET -> Root / "show" / "grib" / fileName => + case GET -> Root / "show" / "grib" / ValidateFileName(fileName) => dataService.getGribStucture(fileName).flatMap(response => Ok(response.asJson)) - case GET -> Root / "grib" / "binary-chunk" / ValidateInt(binaryOffset) / ValidateInt(binaryLength) / fileName => + case GET -> Root / "grib" / "binary-chunk" / ValidateInt(binaryOffset) / ValidateInt(binaryLength) / ValidateFileName(fileName) => dataService.getBinaryChunk(binaryOffset, binaryLength, fileName).flatMap(buffer => Ok(buffer)) // http://0.0.0.0:8080/api/grib/delete-old-forecasts @@ -117,7 +124,7 @@ class Server(postgresService: PostgresService, dataService: DataService, fetch: DebugUtils.getFolderStructure.flatMap(response => Ok(response.asJson)) // http://0.0.0.0:8080/api/debug/file/error_2025-03-16_152201.txt - case GET -> Root / "debug" / "file" / fileName => + case GET -> Root / "debug" / "file" / ValidateFileName(fileName) => val filePath = Path(s"data/tmp/$fileName") Files[IO].exists(filePath).flatMap { @@ -193,6 +200,12 @@ class Server(postgresService: PostgresService, dataService: DataService, fetch: // http://0.0.0.0:8080/api/show/datetime/20230423_1300 case GET -> Root / "show" / "datetime" / ValidateDateTime(datetime) => postgresService.getDateTimeEntries(datetime).flatMap(content => Ok(content.asJson)) + + // Explicit catch-all: guarantees every /api/* request resolves inside + // this route set (never falls through to the SPA fallback below and + // gets served index.html as a false 200). + case _ => + NotFound() } private val corsConfig = CORSConfig.default diff --git a/src/main/scala/server/ValidateRoutes.scala b/src/main/scala/server/ValidateRoutes.scala index 7c86650..a8c87c9 100644 --- a/src/main/scala/server/ValidateRoutes.scala +++ b/src/main/scala/server/ValidateRoutes.scala @@ -92,7 +92,19 @@ object ValidateRoutes { object ValidateInt { def unapply(str: String): Option[Int] = { - Option(str.toInt) + Try(str.toInt).toOption.filter(_ >= 0) + } + } + + // Rejects path traversal and directory separators (including URL-decoded + // %2f, which arrives here as a literal '/' after http4s decodes the path + // segment) by allowlisting a safe filename character set, rather than + // trying to blocklist every encoding of "..". Used anywhere a path + // segment gets concatenated directly into a filesystem Path. + object ValidateFileName { + private val safeFileName = "^[A-Za-z0-9._-]+$".r + def unapply(str: String): Option[String] = { + if (str.nonEmpty && !str.contains("..") && safeFileName.matches(str)) Some(str) else None } } }