Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 9 additions & 4 deletions THREAT_MODEL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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`
Expand Down Expand Up @@ -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`).
Expand Down
7 changes: 7 additions & 0 deletions backend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <host dir or volume path>`.

#### 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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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<RequestBodyLimitFilter> =
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)
}
}
}
10 changes: 10 additions & 0 deletions backend/src/main/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
87 changes: 87 additions & 0 deletions backend/src/test/kotlin/org/tormap/config/RequestBodyLimitTest.kt
Original file line number Diff line number Diff line change
@@ -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)
}
})
Loading