diff --git a/THREAT_MODEL.md b/THREAT_MODEL.md index fabc8121..a4984f52 100644 --- a/THREAT_MODEL.md +++ b/THREAT_MODEL.md @@ -54,7 +54,8 @@ Relay data is public by design, so confidentiality of relay data is not an asset ## Assumptions - The backend runs as a single instance behind a TLS-terminating reverse proxy or CDN. The app itself serves plain HTTP - on 8080 and does not enforce HTTPS or rate limits; both are expected from the edge. + on 8080 and does not enforce HTTPS or rate limits; both are expected from the edge. The edge should also limit request + body sizes; the app enforces its own limit regardless. - The `prod` Spring profile is active in production. It is what disables Swagger, enables the CSP header and sets the production CORS origins and cache headers. Without it the default (development) profile applies. - Tor Collector and its TLS are trusted in normal operation. Collector compromise is in scope only for "bounded damage" @@ -75,7 +76,11 @@ Relay data is public by design, so confidentiality of relay data is not an asset Never stored in config files. - Security headers: `nosniff`, `X-Frame-Options: DENY`, `Referrer-Policy: no-referrer`, CSP `default-src 'none'` when Swagger is off. -- Input validation: `@Size` limits on POST ID lists, typed `Long` path variables, strict `LocalDate.parse`. +- Input validation: `@Size` limits on POST ID lists, typed `Long` path variables, strict `LocalDate.parse`. Request + bodies are limited to `app.http.request.max-body-bytes` (1 MiB, `RequestBodyLimitConfig`: `413` on a larger + `Content-Length`, chunked bodies cut off at the same size). Jackson limits the document length to the same size and + the token count to 60 000 (`spring.jackson.factory.constraints.read.*`), so oversized ID lists fail before they are + fully deserialized. - Persistence uses Spring Data derived queries and bound parameters only, no string-built SQL/JPQL. Flyway migrations with `ddl-auto: validate`. - Caches with bounded entry counts (Caffeine `maximumSize`, unknown cache names rejected). Shallow ETags only on small endpoints. `CoalesceService` @@ -134,8 +139,8 @@ Relay data is public by design, so confidentiality of relay data is not an asset ## Review checklist for changes -- New endpoint: add it to `SecurityConfig` explicitly, extend `SecurityRulesTest`, bound input sizes (including the raw - request body), and keep heavy work behind a cache with a bounded key space. +- New endpoint: add it to `SecurityConfig` explicitly, extend `SecurityRulesTest`, bound input sizes (the raw request body + is limited globally, keep new JSON bodies within `app.http.request.max-body-bytes` and the Jackson token limit), and keep heavy work behind a cache with a bounded key space. - New outbound call: set connect and read timeouts, cap response size, restrict schemes and hosts. - New relay-derived field: truncate before storing, render as text only in the frontend (no Leaflet string tooltips or popups, no `dangerouslySetInnerHTML`). diff --git a/backend/README.md b/backend/README.md index 2bd91a89..6462cf12 100644 --- a/backend/README.md +++ b/backend/README.md @@ -162,6 +162,13 @@ to `/tmp/logs` (set `LOG_DIR` to change). - **Bind mount or volume created by an older (root) version:** run once `chown -R 1000:1000 `. +#### Reverse proxy + +Run the backend behind a TLS-terminating reverse proxy or CDN. Besides TLS and rate limits, limit the request body size +there as well, e.g. `client_max_body_size 1m;` for nginx. The backend itself answers `413` for request bodies above +`app.http.request.max-body-bytes` (default 1 MiB). The largest legitimate request, a list of 50 000 relay IDs, is about +0.5 MiB. + ### Hardware / VM Requirements - 50 GB of free disk space (for downloaded archives) diff --git a/backend/src/main/kotlin/org/tormap/config/RequestBodyLimitConfig.kt b/backend/src/main/kotlin/org/tormap/config/RequestBodyLimitConfig.kt new file mode 100644 index 00000000..25f3464f --- /dev/null +++ b/backend/src/main/kotlin/org/tormap/config/RequestBodyLimitConfig.kt @@ -0,0 +1,85 @@ +package org.tormap.config + +import jakarta.servlet.FilterChain +import jakarta.servlet.ReadListener +import jakarta.servlet.ServletInputStream +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletRequestWrapper +import jakarta.servlet.http.HttpServletResponse +import org.springframework.beans.factory.annotation.Value +import org.springframework.boot.web.servlet.FilterRegistrationBean +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.web.filter.OncePerRequestFilter +import org.springframework.web.server.ContentTooLargeException +import java.io.BufferedReader +import java.io.InputStreamReader +import java.nio.charset.Charset + +/** + * Limits the size of request bodies. + * + * Tomcat only limits form and multipart bodies (`server.tomcat.max-http-form-post-size`, `spring.servlet.multipart.*`), + * so JSON bodies are limited here, independent of any limit at the reverse proxy. Jackson additionally limits the + * length and token count of the documents it reads (`spring.jackson.factory.constraints.read.*`). + */ +@Configuration +class RequestBodyLimitConfig( + @param:Value("\${app.http.request.max-body-bytes:1048576}") + private val maxBodyBytes: Long, +) { + + @Bean + fun requestBodyLimitFilterRegistration(): FilterRegistrationBean = + FilterRegistrationBean(RequestBodyLimitFilter(maxBodyBytes)).apply { + addUrlPatterns("/*") + } +} + +/** + * Rejects requests with a declared `Content-Length` above [maxBodyBytes] with 413, and stops reading bodies without one + * (chunked transfer encoding) once they exceed it. + */ +class RequestBodyLimitFilter(private val maxBodyBytes: Long) : OncePerRequestFilter() { + + override fun doFilterInternal(request: HttpServletRequest, response: HttpServletResponse, filterChain: FilterChain) { + if (request.contentLengthLong > maxBodyBytes) { + response.sendError(HttpServletResponse.SC_REQUEST_ENTITY_TOO_LARGE) + return + } + filterChain.doFilter(LimitedBodyRequest(request, maxBodyBytes), response) + } + + private class LimitedBodyRequest(request: HttpServletRequest, private val maxBodyBytes: Long) : + HttpServletRequestWrapper(request) { + private val inputStream by lazy { LimitedServletInputStream(super.getInputStream(), maxBodyBytes) } + + override fun getInputStream(): ServletInputStream = inputStream + + override fun getReader(): BufferedReader { + val charset = characterEncoding?.let { Charset.forName(it) } ?: Charsets.ISO_8859_1 + return BufferedReader(InputStreamReader(inputStream, charset)) + } + } + + private class LimitedServletInputStream(private val delegate: ServletInputStream, private val maxBytes: Long) : + ServletInputStream() { + private var bytesRead = 0L + + override fun read(): Int = delegate.read().also { if (it != -1) count(1) } + + override fun read(b: ByteArray, off: Int, len: Int): Int = delegate.read(b, off, len).also { if (it > 0) count(it) } + + override fun isFinished() = delegate.isFinished + + override fun isReady() = delegate.isReady + + override fun setReadListener(readListener: ReadListener) = delegate.setReadListener(readListener) + + // Resolved to 413 by Spring MVC (ResponseStatusException) + private fun count(bytes: Int) { + bytesRead += bytes + if (bytesRead > maxBytes) throw ContentTooLargeException(null) + } + } +} diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index 807b4556..383bec10 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -3,6 +3,9 @@ app: cache: public: max-age-seconds: 0 + request: + # Applies to all request bodies, including JSON (see RequestBodyLimitConfig). 50 000 relay IDs are about 0.5 MiB. + max-body-bytes: 1048576 # 1 MiB security: cors: allowed-origins: http://localhost:3000 @@ -96,6 +99,13 @@ spring: flyway: baselineOnMigrate: true locations: classpath:database/migration + jackson: + factory: + constraints: + read: + max-document-length: ${app.http.request.max-body-bytes} + # The largest JSON request is a list of at most 50 000 relay IDs (see RelayDetailsController) + max-token-count: 60000 jpa: hibernate: ddl-auto: validate diff --git a/backend/src/test/kotlin/org/tormap/config/RequestBodyLimitTest.kt b/backend/src/test/kotlin/org/tormap/config/RequestBodyLimitTest.kt new file mode 100644 index 00000000..400d08a2 --- /dev/null +++ b/backend/src/test/kotlin/org/tormap/config/RequestBodyLimitTest.kt @@ -0,0 +1,87 @@ +package org.tormap.config + +import io.kotest.core.spec.style.StringSpec +import io.kotest.matchers.shouldBe +import org.hamcrest.Matchers.not +import org.hamcrest.Matchers.containsString +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.boot.test.web.server.LocalServerPort +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc +import org.springframework.http.MediaType +import org.springframework.test.context.ActiveProfiles +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.content +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status +import org.tormap.adapter.controller.RelayDetailsController +import java.io.ByteArrayInputStream +import java.net.URI +import java.net.http.HttpClient +import java.net.http.HttpRequest +import java.net.http.HttpResponse + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@AutoConfigureMockMvc +@ActiveProfiles("test") +class RequestBodyLimitTest( + private val mockMvc: MockMvc, + @LocalServerPort private val port: Int, +) : StringSpec({ + val maxBodyBytes = 1024 * 1024 + + fun idList(count: Int, firstId: Long = 1L) = (firstId until firstId + count).joinToString(",", "[", "]") + + "body with a Content-Length above the limit returns 413" { + val body = idList(250_000, firstId = 1_000_000L) + (body.length > maxBodyBytes) shouldBe true + + mockMvc.perform( + post("/relay/details/relay/identifiers") + .contentType(MediaType.APPLICATION_JSON) + .content(body) + ) + .andExpect(status().isContentTooLarge) + } + + "chunked body above the limit returns 413" { + // Whitespace keeps the JSON token count low, so only the body size limit applies. Stays less than + // server.tomcat.max-swallow-size above the limit, so Tomcat reads the rest and answers cleanly. + val body = ("[1" + " ".repeat(maxBodyBytes + 256 * 1024) + "]").toByteArray() + (body.size > maxBodyBytes) shouldBe true + + val request = HttpRequest.newBuilder(URI.create("http://localhost:$port/relay/details/relay/identifiers")) + .header("Content-Type", MediaType.APPLICATION_JSON_VALUE) + // No known length, so the client uses chunked transfer encoding + .POST(HttpRequest.BodyPublishers.ofInputStream { ByteArrayInputStream(body) }) + .build() + val response = HttpClient.newBuilder().version(HttpClient.Version.HTTP_1_1).build() + .use { it.send(request, HttpResponse.BodyHandlers.discarding()) } + + response.statusCode() shouldBe 413 + } + + "body within the size limit but with too many JSON tokens is rejected before validation" { + val body = List(RelayDetailsController.MAXIMUM_EXPECTED_RELAYS_PER_DAY * 2) { 0 }.joinToString(",", "[", "]") + (body.length < maxBodyBytes) shouldBe true + + mockMvc.perform( + post("/relay/details/relay/identifiers") + .contentType(MediaType.APPLICATION_JSON) + .content(body) + ) + .andExpect(status().isBadRequest) + .andExpect(content().string(not(containsString("size must be between")))) + } + + "largest accepted relay ID list is within the limits" { + val body = idList(RelayDetailsController.MAXIMUM_EXPECTED_RELAYS_PER_DAY, firstId = 1_000_000_000L) + (body.length < maxBodyBytes) shouldBe true + + mockMvc.perform( + post("/relay/details/relay/identifiers") + .contentType(MediaType.APPLICATION_JSON) + .content(body) + ) + .andExpect(status().isOk) + } +})