From 426523f9618768baab38c6df2c842dfd17501d25 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 17:52:28 +0000 Subject: [PATCH 1/2] fix(backend): secure deployment defaults - docker-compose.yml: do not publish the database port, bind the backend to 127.0.0.1:8080 and activate the prod profile. The development override publishes the database on 127.0.0.1:5432 and activates the dev profile. - Jib image: set SPRING_PROFILES_ACTIVE=prod by default. - application.yml now holds the production defaults (Swagger and API docs off, production CORS origins, no database password fallback). The new dev profile enables them for local development, ./gradlew bootRun activates it. - Replace ForwardedHeaderFilter, which trusted X-Forwarded-* from any client, with Tomcat's RemoteIpValve (server.forward-headers-strategy=native) that only trusts loopback and private network proxies. - Document that a TLS reverse proxy is required and update the threat model. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01N7axs4a1fVatUhpWytJ3vr --- THREAT_MODEL.md | 16 +++++-- backend/README.md | 44 ++++++++++++------- backend/build.gradle.kts | 10 ++++- backend/docker-compose.override.yml | 4 ++ backend/docker-compose.yml | 10 +++-- .../org/tormap/config/SecurityConfig.kt | 4 -- .../src/main/resources/application-dev.yml | 13 ++++++ .../src/main/resources/application-prod.yml | 14 +----- backend/src/main/resources/application.yml | 18 +++++--- .../org/tormap/config/SecurityConfigTest.kt | 10 ++++- .../org/tormap/config/SecurityRulesTest.kt | 9 ++-- .../config/SecuritySwaggerEnabledTest.kt | 8 ++-- .../controller/HttpCachingConfigTest.kt | 4 +- .../src/test/resources/application-test.yml | 1 + 14 files changed, 108 insertions(+), 57 deletions(-) create mode 100644 backend/src/main/resources/application-dev.yml diff --git a/THREAT_MODEL.md b/THREAT_MODEL.md index 30378bca..9b258916 100644 --- a/THREAT_MODEL.md +++ b/THREAT_MODEL.md @@ -42,7 +42,7 @@ Relay data is public by design, so confidentiality of relay data is not an asset | 1 | Public API `/relay/**`, `/` | Path params `id`, `day`; JSON arrays of IDs (≤ 50 000 / 5 000); headers (direct) | Memory/CPU/DB exhaustion, cache thrash, error leakage | `adapter/controller/`, `config/AppConfig.kt`, `config/CacheConfig.kt` | | 2 | HTTP Basic authentication | `Authorization` header on `/actuator/**` (direct) | Brute force, password-hashing CPU cost, cleartext creds without TLS | `config/SecurityConfig.kt` | | 3 | Admin plane `/actuator/**` | Requests from whoever holds the admin password | Info disclosure (log file, thread dumps, exchanges), cache eviction | `application.yml` `management.*` | -| 4 | Reverse proxy headers | `Forwarded`, `X-Forwarded-*` (direct if the backend port is exposed) | Spoofed client IP/scheme/host in logs, redirects, exchanges | `ForwardedHeaderFilter` in `SecurityConfig.kt` | +| 4 | Reverse proxy headers | `X-Forwarded-*` (direct if the backend port is exposed) | Spoofed client IP/scheme/host in logs, redirects, exchanges | `server.forward-headers-strategy`, `server.tomcat.remoteip.*` in `application.yml` | | 5 | Relay-published descriptor data | Nickname, contact, platform, family entries, address (indirect: any relay operator) | Parser edge cases, expensive family computation, stored XSS | `service/Descriptor*`, `util/RelayFamilyUtil.kt`, vendored `org.torproject.descriptor` | | 6 | Collector download | `index.json` (`path`, file names, sizes, mtimes), tar/xz/bz2 archives (trusted, HTTPS) | SSRF via index URLs, path traversal, decompression or disk bombs, hung downloads | `descriptor/index/*`, `impl/DescriptorReaderImpl.java` | | 7 | DNS | PTR records and forward lookups for relay IPs (indirect: whoever controls the IP's reverse zone) | Slow lookups that tie up request threads, spoofed host names | `service/ReverseDnsLookupService.kt` | @@ -56,8 +56,12 @@ Relay data is public by design, so confidentiality of relay data is not an asset - 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. 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. +- The defaults in `application.yml` are the production settings: Swagger and API docs off, CSP header on, production + CORS origins, no database password fallback. The `prod` profile (set in the Docker image and `docker-compose.yml`) + only adds the public cache headers. Only the `dev` profile (`./gradlew bootRun`, `docker-compose.override.yml`) + enables Swagger, allows `http://localhost:3000` and falls back to the database password `postgres`. +- The reference `docker-compose.yml` publishes no database port and binds the backend to `127.0.0.1:8080` only, because + Docker's port publishing bypasses host firewalls such as ufw. - Tor Collector and its TLS are trusted in normal operation. Collector compromise is in scope only for "bounded damage" (no code execution, no writes outside `/tormap-data`, no unbounded resource use). - Relay operators are untrusted. Running a relay is cheap and its descriptors end up in the DB and permanently in the @@ -74,6 +78,9 @@ Relay data is public by design, so confidentiality of relay data is not an asset unsafe actuator methods. `SecurityRulesTest` pins these rules. - Admin password is read only from the environment (`TORMAP_ADMIN_PASSWORD`) and only kept BCrypt-hashed in memory. Never stored in config files. +- Forwarded headers: Tomcat's `RemoteIpValve` (`server.forward-headers-strategy: native`) applies `X-Forwarded-For`, + `-Proto` and `-Host` only for connections from `server.tomcat.remoteip.internal-proxies` (loopback and private + networks). There is no application filter that trusts these headers from any client. - 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`. Request @@ -146,4 +153,5 @@ Relay data is public by design, so confidentiality of relay data is not an asset - 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`). -- Deployment default change: keep the DB unexposed, the `prod` profile active and the actuator off without a password. +- Deployment default change: keep the DB unexposed, the backend on localhost behind TLS, secure defaults in + `application.yml` (development settings only in the `dev` profile) and the actuator off without a password. diff --git a/backend/README.md b/backend/README.md index 6462cf12..c78e7e1d 100644 --- a/backend/README.md +++ b/backend/README.md @@ -44,8 +44,10 @@ less reliable. ## Run Development Server 1. Go into `backend` directory -2. Start PostgreSQL database: `docker compose up -d database` -3. Run `./gradlew bootRun` or on Windows `gradlew.bat bootRun` +2. Start PostgreSQL database: `docker compose up -d database` (the development override publishes it on + `127.0.0.1:5432`) +3. Run `./gradlew bootRun` or on Windows `gradlew.bat bootRun`. It activates the `dev` Spring profile unless + `SPRING_PROFILES_ACTIVE` is set. 4. Backend should be available at http://localhost:8080 Run `./gradlew test`, to only build & tests. @@ -56,9 +58,9 @@ on your bandwidth and hardware, but can be expected to take at least several hou ### Swagger UI -For development purposes, an interactive Swagger UI is available under http://localhost:8080/swagger and the -specification can also be viewed in raw JSON -under http://localhost:8080/openapi. In the production environment, Swagger UI and API docs are disabled by default. +In the `dev` profile, an interactive Swagger UI is available under http://localhost:8080/swagger and the +specification can also be viewed in raw JSON under http://localhost:8080/openapi. Without the `dev` profile, Swagger UI +and API docs are disabled. ### Admin access @@ -92,19 +94,22 @@ Recommended (production): ## Config -- `backend/src/main/resources/application.yml` - main backend config -- `backend/src/main/resources/application-prod.yml` - production specific overwrites +- `backend/src/main/resources/application.yml` - main backend config with secure (production) defaults +- `backend/src/main/resources/application-prod.yml` - production specific overwrites (public cache headers) +- `backend/src/main/resources/application-dev.yml` - local development: Swagger UI, CORS for `http://localhost:3000`, + database password fallback `postgres`. Never activate it in production. - `backend/src/main/resources/logback-spring.xml` - logging options - `backend/build.gradle.kts` - dependencies are managed with Gradle - `docker-compose.yml` - example docker compose file for production deployment -- `docker-compose.override.yml` - overrides `docker-compose.yml` for local development +- `docker-compose.override.yml` - overrides `docker-compose.yml` for local development (`dev` profile, database on + `127.0.0.1:5432`) - `.env.example` can be copied to `.env` - control docker compose environment variables for local development ### Production environment variables It is recommended to set following production environment variables via a secret manager: -- `TORMAP_DATABASE_PASSWORD` - Overrides the database password +- `TORMAP_DATABASE_PASSWORD` - The database password (required, there is no default outside the `dev` profile) - `TORMAP_ADMIN_PASSWORD` - Optionally set admin password for accessing actuator endpoints - `NEW_RELIC_INGEST_KEY` - Export metrics to New Relic (EU region) via OTLP, see `management.otlp.metrics.export` in `application.yml` @@ -140,7 +145,8 @@ Build fat JAR: 1. Go to `backend` directory where file `gradlew` is located 2. Run command: `./gradlew bootJar` 3. A fat jar containing all packages should now be located in `backend/build/libs/`. -4. Run command `java -jar ` +4. Run command `java -jar ` with `SPRING_DATASOURCE_PASSWORD` set (add `--spring.profiles.active=dev` + for local development) 5. Backend should be available at http://localhost:8080 Build docker image: @@ -148,12 +154,14 @@ Build docker image: 1. Go to `backend` directory where file `gradlew` is located 2. Make sure [docker](https://docs.docker.com/get-docker/) is installed and the docker daemon is running 3. Run command: `./gradlew bootBuildImage` -4. Run new image in a container with: `docker run -p 8080:8080 tormap/backend` +4. Run new image in a container with: `docker run -p 127.0.0.1:8080:8080 tormap/backend`. The image activates the + `prod` profile, set `SPRING_PROFILES_ACTIVE=dev` for local development. 5. Backend should be available at http://localhost:8080 Prebuild docker images are available at https://hub.docker.com/r/tormap/backend. An example docker compose file for production deployment is available at `docker-compose.yml`. To execute it without development overrides, run -`docker compose -f docker-compose.yml up -d`. +`docker compose -f docker-compose.yml up -d`. It activates the `prod` profile, does not publish the database port and +binds the backend to `127.0.0.1:8080`, so a reverse proxy on the same host is required (see below). #### Deployment notes @@ -164,11 +172,17 @@ to `/tmp/logs` (set `LOG_DIR` to change). #### Reverse proxy -Run the backend behind a TLS-terminating reverse proxy or CDN. Besides TLS and rate limits, limit the request body size +The backend serves plain HTTP. Run it behind a TLS-terminating reverse proxy (required: admin credentials are sent with +HTTP Basic) and do not publish port 8080 on a public interface. Docker publishes ports past host firewalls such as ufw, +so always bind to `127.0.0.1` as in `docker-compose.yml`. 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. +`X-Forwarded-For`, `X-Forwarded-Proto` and `X-Forwarded-Host` are only applied for connections from trusted proxies +(`server.tomcat.remoteip.internal-proxies`, default: loopback and private networks such as Docker networks). If your +proxy or CDN connects from another address, add it there, e.g. via `SERVER_TOMCAT_REMOTEIP_INTERNAL_PROXIES`. + ### Hardware / VM Requirements - 50 GB of free disk space (for downloaded archives) @@ -182,8 +196,8 @@ there as well, e.g. `client_max_body_size 1m;` for nginx. The backend itself ans The backend enables CORS so specific frontend browser origins can call the public API. Configuration: -- Dev (default): `app.security.cors.allowed-origins: http://localhost:3000` -- Prod: `application-prod.yml` overrides to `https://tormap.org,https://www.tormap.org,https://tormap-*.web.app` +- Default: `app.security.cors.allowed-origins: https://tormap.org,https://www.tormap.org,https://tormap-*.web.app` +- Dev: `application-dev.yml` overrides to `http://localhost:3000` #### HTTP caching diff --git a/backend/build.gradle.kts b/backend/build.gradle.kts index d9993b1e..c0323cef 100644 --- a/backend/build.gradle.kts +++ b/backend/build.gradle.kts @@ -152,6 +152,13 @@ tasks.withType { useJUnitPlatform() } +// The local development server uses the dev profile unless SPRING_PROFILES_ACTIVE is set +tasks.bootRun { + if (providers.environmentVariable("SPRING_PROFILES_ACTIVE").orNull == null) { + systemProperty("spring.profiles.active", "dev") + } +} + // Security scans with the Snyk CLI (needs `snyk` on PATH and a prior `snyk auth` or SNYK_TOKEN). // Extra CLI arguments can be passed with e.g. -PsnykArgs="--json". fun registerSnykTask(taskName: String, vararg snykCommand: String) = tasks.register(taskName) { @@ -187,7 +194,8 @@ jib { // Run as non-root. Logs go to /tmp since the root filesystem is not writable for this user. user = "1000:1000" workingDirectory = "/" - environment = mapOf("LOG_DIR" to "/tmp/logs") + // The prod profile is the image default. Development opts in with SPRING_PROFILES_ACTIVE=dev. + environment = mapOf("LOG_DIR" to "/tmp/logs", "SPRING_PROFILES_ACTIVE" to "prod") volumes = listOf("/tormap-data") } extraDirectories { diff --git a/backend/docker-compose.override.yml b/backend/docker-compose.override.yml index 34fb8d98..1a18a779 100644 --- a/backend/docker-compose.override.yml +++ b/backend/docker-compose.override.yml @@ -3,8 +3,12 @@ name: tormap services: backend: environment: + - SPRING_PROFILES_ACTIVE=dev - SPRING_DATASOURCE_PASSWORD=${TORMAP_DATABASE_PASSWORD:-postgres} database: environment: - POSTGRES_PASSWORD=${TORMAP_DATABASE_PASSWORD:-postgres} + ports: + # For a backend started with ./gradlew bootRun. Localhost only. + - "127.0.0.1:5432:5432" diff --git a/backend/docker-compose.yml b/backend/docker-compose.yml index 0b5962bc..c8bd3a9a 100644 --- a/backend/docker-compose.yml +++ b/backend/docker-compose.yml @@ -1,4 +1,5 @@ -# Example Docker compose file for production deployment +# Example Docker compose file for production deployment. +# The backend only listens on localhost: run it behind a TLS-terminating reverse proxy on the same host (see README). name: tormap services: backend: @@ -8,6 +9,7 @@ services: depends_on: - database environment: + - SPRING_PROFILES_ACTIVE=prod - SPRING_DATASOURCE_URL=jdbc:postgresql://database:5432/postgres - SPRING_DATASOURCE_USERNAME=postgres - SPRING_DATASOURCE_PASSWORD=${TORMAP_DATABASE_PASSWORD:?Set TORMAP_DATABASE_PASSWORD to a strong generated password} @@ -17,15 +19,15 @@ services: # - TORMAP_ADMIN_PASSWORD=${TORMAP_ADMIN_PASSWORD:?Set TORMAP_ADMIN_PASSWORD to a strong generated password} # and explicitly enable/ expose the actuator endpoints you need via management.* properties ports: - - "8080:8080" + # Plain HTTP on localhost only. Docker publishes ports past host firewalls such as ufw, so never bind to all interfaces. + - "127.0.0.1:8080:8080" database: image: postgres:18-alpine@sha256:d3e1620b530c944afa6e887d22eb899824da68e19c52024bf98f5220c88a65b2 environment: - POSTGRES_USER=postgres - POSTGRES_PASSWORD=${TORMAP_DATABASE_PASSWORD:?Set TORMAP_DATABASE_PASSWORD to a strong generated password} - ports: - - "5432:5432" + # No published port: only the backend reaches the database over the compose network frontend: image: tormap/frontend:${TORMAP_VERSION:-3} diff --git a/backend/src/main/kotlin/org/tormap/config/SecurityConfig.kt b/backend/src/main/kotlin/org/tormap/config/SecurityConfig.kt index 1d6edb4c..3174b829 100644 --- a/backend/src/main/kotlin/org/tormap/config/SecurityConfig.kt +++ b/backend/src/main/kotlin/org/tormap/config/SecurityConfig.kt @@ -21,7 +21,6 @@ import org.springframework.security.web.header.writers.XXssProtectionHeaderWrite import org.springframework.web.cors.CorsConfiguration import org.springframework.web.cors.CorsConfigurationSource import org.springframework.web.cors.UrlBasedCorsConfigurationSource -import org.springframework.web.filter.ForwardedHeaderFilter import org.tormap.util.logger @Configuration @@ -94,9 +93,6 @@ class SecurityConfig( return UrlBasedCorsConfigurationSource().also { it.registerCorsConfiguration("/**", cors) } } - @Bean - fun forwardedHeaderFilter(): ForwardedHeaderFilter = ForwardedHeaderFilter() - /** Admin actuator: the only chain that authenticates (HTTP Basic). */ @Bean @Order(1) diff --git a/backend/src/main/resources/application-dev.yml b/backend/src/main/resources/application-dev.yml new file mode 100644 index 00000000..cdae3827 --- /dev/null +++ b/backend/src/main/resources/application-dev.yml @@ -0,0 +1,13 @@ +# Local development only (./gradlew bootRun, docker-compose.override.yml). Never activate in production! +app: + security: + cors: + allowed-origins: http://localhost:3000 +spring: + datasource: + password: ${SPRING_DATASOURCE_PASSWORD:${TORMAP_DATABASE_PASSWORD:postgres}} +springdoc: + api-docs: + enabled: true + swagger-ui: + enabled: true diff --git a/backend/src/main/resources/application-prod.yml b/backend/src/main/resources/application-prod.yml index 5bd4d86f..3d464a80 100644 --- a/backend/src/main/resources/application-prod.yml +++ b/backend/src/main/resources/application-prod.yml @@ -1,17 +1,7 @@ +# Production overrides. The secure defaults (Swagger off, CSP on, no database password fallback, tormap.org CORS +# origins) are already set in application.yml. app: http: cache: public: max-age-seconds: 300 - security: - cors: - allowed-origins: https://tormap.org,https://www.tormap.org,https://tormap-*.web.app -springdoc: - api-docs: - enabled: false - swagger-ui: - enabled: false -spring: - datasource: - # Required in production environment - password: ${SPRING_DATASOURCE_PASSWORD:${TORMAP_DATABASE_PASSWORD}} diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index 383bec10..fc22dcf8 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -8,7 +8,8 @@ app: max-body-bytes: 1048576 # 1 MiB security: cors: - allowed-origins: http://localhost:3000 + # Development uses http://localhost:3000 (application-dev.yml) + allowed-origins: https://tormap.org,https://www.tormap.org,https://tormap-*.web.app descriptor: apiBaseURL: https://collector.torproject.org localDownloadDirectory: tormap-data/descriptors @@ -82,20 +83,26 @@ logging: path: ${LOG_DIR:./logs}/spring-boot-logger.log server: port: 8080 + # X-Forwarded-For/-Proto/-Host are only applied when the connection comes from server.tomcat.remoteip.internal-proxies + forward-headers-strategy: native http2: enabled: true compression: enabled: true min-response-size: 1024 tomcat: + remoteip: + # Reverse proxies trusted to set X-Forwarded-* headers: loopback and private networks (e.g. Docker networks). + # Add the proxy or CDN addresses if they are outside these ranges. + internal-proxies: 127.0.0.0/8, ::1/128, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, fc00::/7 max-http-form-post-size: 1048576 # 1 MiB max-swallow-size: 1048576 # 1 MiB spring: datasource: url: ${SPRING_DATASOURCE_URL:jdbc:postgresql://localhost:5432/postgres} username: ${SPRING_DATASOURCE_USERNAME:postgres} - # You should override the default password in production environment via environment variable - password: ${SPRING_DATASOURCE_PASSWORD:${TORMAP_DATABASE_PASSWORD:postgres}} + # Required, there is no default outside the dev profile + password: ${SPRING_DATASOURCE_PASSWORD:${TORMAP_DATABASE_PASSWORD}} flyway: baselineOnMigrate: true locations: classpath:database/migration @@ -127,10 +134,11 @@ spring: multipart: max-file-size: 1MB max-request-size: 1MB +# Disabled by default, the dev profile enables them springdoc: api-docs: path: /openapi - enabled: true + enabled: false swagger-ui: path: /swagger - enabled: true + enabled: false diff --git a/backend/src/test/kotlin/org/tormap/config/SecurityConfigTest.kt b/backend/src/test/kotlin/org/tormap/config/SecurityConfigTest.kt index 13e90119..a4e9acf1 100644 --- a/backend/src/test/kotlin/org/tormap/config/SecurityConfigTest.kt +++ b/backend/src/test/kotlin/org/tormap/config/SecurityConfigTest.kt @@ -6,6 +6,7 @@ import io.kotest.matchers.shouldNotBe import org.springframework.boot.test.context.SpringBootTest import org.springframework.boot.web.servlet.FilterRegistrationBean import org.springframework.context.ApplicationContext +import org.springframework.core.env.Environment import org.springframework.security.core.userdetails.UserDetailsService import org.springframework.security.crypto.password.PasswordEncoder import org.springframework.security.web.SecurityFilterChain @@ -21,7 +22,7 @@ class SecurityConfigTest( private val passwordEncoder: PasswordEncoder, private val userDetailsService: UserDetailsService, private val corsConfigurationSource: CorsConfigurationSource, - private val forwardedHeaderFilter: ForwardedHeaderFilter, + private val environment: Environment, private val shallowEtagHeaderFilterRegistration: FilterRegistrationBean, ) : StringSpec({ @@ -30,10 +31,15 @@ class SecurityConfigTest( passwordEncoder shouldNotBe null userDetailsService shouldNotBe null corsConfigurationSource shouldNotBe null - forwardedHeaderFilter shouldNotBe null applicationContext.getBeansOfType(SecurityFilterChain::class.java).size shouldBe 2 } + "forwarded headers are only applied by Tomcat for trusted proxies" { + applicationContext.getBeansOfType(ForwardedHeaderFilter::class.java) shouldBe emptyMap() + environment.getProperty("server.forward-headers-strategy") shouldBe "native" + environment.getProperty("server.tomcat.remoteip.internal-proxies") shouldNotBe null + } + "shallow ETag filter is not registered for large relay location responses" { shallowEtagHeaderFilterRegistration.urlPatterns shouldBe setOf( "/relay/location/days", diff --git a/backend/src/test/kotlin/org/tormap/config/SecurityRulesTest.kt b/backend/src/test/kotlin/org/tormap/config/SecurityRulesTest.kt index 320cc81f..ff8c81d4 100644 --- a/backend/src/test/kotlin/org/tormap/config/SecurityRulesTest.kt +++ b/backend/src/test/kotlin/org/tormap/config/SecurityRulesTest.kt @@ -9,7 +9,6 @@ import org.springframework.beans.factory.annotation.Value import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc import org.springframework.boot.test.context.SpringBootTest import org.springframework.test.context.ActiveProfiles -import org.springframework.test.context.TestPropertySource import org.springframework.test.web.servlet.MockMvc import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.options @@ -22,8 +21,8 @@ import java.util.Base64 */ @SpringBootTest @AutoConfigureMockMvc +// No dev or prod profile, so these rules check the defaults from application.yml @ActiveProfiles("test") -@TestPropertySource(properties = ["springdoc.swagger-ui.enabled=false", "springdoc.api-docs.enabled=false"]) class SecurityRulesTest( private val mockMvc: MockMvc, @param:Value("\${app.security.cors.allowed-origins}") private val allowedOrigins: String, @@ -79,7 +78,11 @@ class SecurityRulesTest( statusOf("/not-a-public-path") shouldBe 403 } - "swagger and openapi are not public when disabled" { + "content security policy is sent by default" { + mockMvc.perform(get("/")).andReturn().response.getHeader("Content-Security-Policy") shouldStartWith "default-src 'none'" + } + + "swagger and openapi are not public by default" { listOf("/swagger-ui.html", "/swagger-ui/index.html", "/v3/api-docs", "/openapi/api.yaml").forEach { path -> statusOf(path) shouldBe 403 } diff --git a/backend/src/test/kotlin/org/tormap/config/SecuritySwaggerEnabledTest.kt b/backend/src/test/kotlin/org/tormap/config/SecuritySwaggerEnabledTest.kt index 69d375a7..ed8a8e6b 100644 --- a/backend/src/test/kotlin/org/tormap/config/SecuritySwaggerEnabledTest.kt +++ b/backend/src/test/kotlin/org/tormap/config/SecuritySwaggerEnabledTest.kt @@ -5,20 +5,18 @@ import io.kotest.matchers.shouldNotBe import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc import org.springframework.boot.test.context.SpringBootTest import org.springframework.test.context.ActiveProfiles -import org.springframework.test.context.TestPropertySource import org.springframework.test.web.servlet.MockMvc import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get -/** Swagger and OpenAPI paths are only public when explicitly enabled. */ +/** Swagger and OpenAPI paths are only public when enabled, which the dev profile does. */ @SpringBootTest @AutoConfigureMockMvc -@ActiveProfiles("test") -@TestPropertySource(properties = ["springdoc.swagger-ui.enabled=true", "springdoc.api-docs.enabled=true"]) +@ActiveProfiles("test", "dev") class SecuritySwaggerEnabledTest( private val mockMvc: MockMvc, ) : StringSpec({ - "swagger and openapi are public when enabled" { + "swagger and openapi are public in the dev profile" { listOf("/openapi", "/swagger", "/swagger-ui/index.html").forEach { path -> mockMvc.perform(get(path)).andReturn().response.status shouldNotBe 401 } diff --git a/backend/src/test/kotlin/org/tormap/controller/HttpCachingConfigTest.kt b/backend/src/test/kotlin/org/tormap/controller/HttpCachingConfigTest.kt index c3cfc3ff..3e3c4d26 100644 --- a/backend/src/test/kotlin/org/tormap/controller/HttpCachingConfigTest.kt +++ b/backend/src/test/kotlin/org/tormap/controller/HttpCachingConfigTest.kt @@ -12,7 +12,7 @@ import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get import org.springframework.test.web.servlet.result.MockMvcResultMatchers.header import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status -@SpringBootTest(properties = ["app.http.cache.public.max-age-seconds=300"]) +@SpringBootTest(properties = ["app.http.cache.public.max-age-seconds=300", "springdoc.api-docs.enabled=true", "springdoc.swagger-ui.enabled=true"]) @AutoConfigureMockMvc @ActiveProfiles("test") class HttpCachingConfigTest( @@ -64,7 +64,7 @@ class HttpCachingConfigTest( } "GET /openapi does not get caching headers from interceptor" { - // openapi may be disabled in prod, but is enabled in test/application.yml. + // Swagger and openapi are disabled by default and enabled for this test. mockMvc.perform(get("/openapi")) .andExpect(status().isOk) .andExpect(header().doesNotExist("Cache-Control")) diff --git a/backend/src/test/resources/application-test.yml b/backend/src/test/resources/application-test.yml index 727b60d9..74e356ca 100644 --- a/backend/src/test/resources/application-test.yml +++ b/backend/src/test/resources/application-test.yml @@ -1,6 +1,7 @@ spring: datasource: url: jdbc:tc:postgresql:18-alpine:///postgres + password: postgres tormap: scheduling: enabled: false From a740df579d86aa109c6e130763a58aa454897120 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 22:23:40 +0000 Subject: [PATCH 2/2] refactor(backend): move remaining prod settings into application.yml application.yml already holds the production defaults, so the public cache header (max-age 300) moves there as well and application-prod.yml is removed. The dev profile disables the cache header. Production no longer needs a Spring profile, so the image and docker-compose.yml do not set one. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01N7axs4a1fVatUhpWytJ3vr --- THREAT_MODEL.md | 8 ++++---- backend/README.md | 20 +++++++++---------- backend/build.gradle.kts | 3 +-- backend/docker-compose.yml | 1 - .../src/main/resources/application-dev.yml | 4 ++++ .../src/main/resources/application-prod.yml | 7 ------- backend/src/main/resources/application.yml | 2 +- .../org/tormap/config/SecurityRulesTest.kt | 2 +- 8 files changed, 21 insertions(+), 26 deletions(-) delete mode 100644 backend/src/main/resources/application-prod.yml diff --git a/THREAT_MODEL.md b/THREAT_MODEL.md index 9b258916..5417dd3c 100644 --- a/THREAT_MODEL.md +++ b/THREAT_MODEL.md @@ -57,9 +57,9 @@ Relay data is public by design, so confidentiality of relay data is not an asset 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 defaults in `application.yml` are the production settings: Swagger and API docs off, CSP header on, production - CORS origins, no database password fallback. The `prod` profile (set in the Docker image and `docker-compose.yml`) - only adds the public cache headers. Only the `dev` profile (`./gradlew bootRun`, `docker-compose.override.yml`) - enables Swagger, allows `http://localhost:3000` and falls back to the database password `postgres`. + CORS origins, public cache headers, no database password fallback. Production needs no Spring profile. Only the + `dev` profile (`./gradlew bootRun`, `docker-compose.override.yml`) enables Swagger, allows `http://localhost:3000` + and falls back to the database password `postgres`. - The reference `docker-compose.yml` publishes no database port and binds the backend to `127.0.0.1:8080` only, because Docker's port publishing bypasses host firewalls such as ufw. - Tor Collector and its TLS are trusted in normal operation. Collector compromise is in scope only for "bounded damage" @@ -125,7 +125,7 @@ Relay data is public by design, so confidentiality of relay data is not an asset 4. **Admin credential attacker.** Brute-forces HTTP Basic, sniffs it on a deployment without TLS, or finds it in shell history or compose files. Goal: log file, thread dumps, HTTP exchanges, cache eviction. 5. **Misconfiguration opportunist.** Scans for deployments with a reachable PostgreSQL port, the backend on plain - HTTP, or the `prod` profile not active (Swagger and docs exposed). + HTTP, or the `dev` profile active in production (Swagger and docs exposed). 6. **Supply-chain attacker.** Publishes a malicious version of a dependency or action, or plants instructions in a changelog that an AI reviewer acting on Renovate PRs follows. diff --git a/backend/README.md b/backend/README.md index c78e7e1d..23826678 100644 --- a/backend/README.md +++ b/backend/README.md @@ -94,10 +94,9 @@ Recommended (production): ## Config -- `backend/src/main/resources/application.yml` - main backend config with secure (production) defaults -- `backend/src/main/resources/application-prod.yml` - production specific overwrites (public cache headers) +- `backend/src/main/resources/application.yml` - main backend config, its defaults are the production settings - `backend/src/main/resources/application-dev.yml` - local development: Swagger UI, CORS for `http://localhost:3000`, - database password fallback `postgres`. Never activate it in production. + no cache headers, database password fallback `postgres`. Never activate it in production. - `backend/src/main/resources/logback-spring.xml` - logging options - `backend/build.gradle.kts` - dependencies are managed with Gradle - `docker-compose.yml` - example docker compose file for production deployment @@ -154,14 +153,15 @@ Build docker image: 1. Go to `backend` directory where file `gradlew` is located 2. Make sure [docker](https://docs.docker.com/get-docker/) is installed and the docker daemon is running 3. Run command: `./gradlew bootBuildImage` -4. Run new image in a container with: `docker run -p 127.0.0.1:8080:8080 tormap/backend`. The image activates the - `prod` profile, set `SPRING_PROFILES_ACTIVE=dev` for local development. +4. Run new image in a container with: `docker run -p 127.0.0.1:8080:8080 tormap/backend`. Set + `SPRING_PROFILES_ACTIVE=dev` for local development. 5. Backend should be available at http://localhost:8080 Prebuild docker images are available at https://hub.docker.com/r/tormap/backend. An example docker compose file for production deployment is available at `docker-compose.yml`. To execute it without development overrides, run -`docker compose -f docker-compose.yml up -d`. It activates the `prod` profile, does not publish the database port and -binds the backend to `127.0.0.1:8080`, so a reverse proxy on the same host is required (see below). +`docker compose -f docker-compose.yml up -d`. It does not publish the database port and binds the backend to +`127.0.0.1:8080`, so a reverse proxy on the same host is required (see below). No Spring profile is needed in +production. A previously set `SPRING_PROFILES_ACTIVE=prod` has no effect and can be removed. #### Deployment notes @@ -203,7 +203,7 @@ The backend enables CORS so specific frontend browser origins can call the publi Public `GET` endpoints under `/relay/**` can return `Cache-Control` headers to allow browser and CDN/proxy caching. -- Dev/default: caching headers are **disabled** (`app.http.cache.public.max-age-seconds` defaults to `0`). - Responses are typically not cached unless a controller explicitly sets caching headers. -- Prod: `application-prod.yml` sets `app.http.cache.public.max-age-seconds: 300`, so `/relay/**` responses include +- Default: `app.http.cache.public.max-age-seconds: 300`, so `/relay/**` responses include `Cache-Control: public, max-age=300`. +- Dev: `application-dev.yml` sets it to `0`, which disables the caching headers. Responses are then typically not + cached unless a controller explicitly sets caching headers. diff --git a/backend/build.gradle.kts b/backend/build.gradle.kts index c0323cef..a614bae9 100644 --- a/backend/build.gradle.kts +++ b/backend/build.gradle.kts @@ -194,8 +194,7 @@ jib { // Run as non-root. Logs go to /tmp since the root filesystem is not writable for this user. user = "1000:1000" workingDirectory = "/" - // The prod profile is the image default. Development opts in with SPRING_PROFILES_ACTIVE=dev. - environment = mapOf("LOG_DIR" to "/tmp/logs", "SPRING_PROFILES_ACTIVE" to "prod") + environment = mapOf("LOG_DIR" to "/tmp/logs") volumes = listOf("/tormap-data") } extraDirectories { diff --git a/backend/docker-compose.yml b/backend/docker-compose.yml index c8bd3a9a..15ea9529 100644 --- a/backend/docker-compose.yml +++ b/backend/docker-compose.yml @@ -9,7 +9,6 @@ services: depends_on: - database environment: - - SPRING_PROFILES_ACTIVE=prod - SPRING_DATASOURCE_URL=jdbc:postgresql://database:5432/postgres - SPRING_DATASOURCE_USERNAME=postgres - SPRING_DATASOURCE_PASSWORD=${TORMAP_DATABASE_PASSWORD:?Set TORMAP_DATABASE_PASSWORD to a strong generated password} diff --git a/backend/src/main/resources/application-dev.yml b/backend/src/main/resources/application-dev.yml index cdae3827..01211b7e 100644 --- a/backend/src/main/resources/application-dev.yml +++ b/backend/src/main/resources/application-dev.yml @@ -1,5 +1,9 @@ # Local development only (./gradlew bootRun, docker-compose.override.yml). Never activate in production! app: + http: + cache: + public: + max-age-seconds: 0 security: cors: allowed-origins: http://localhost:3000 diff --git a/backend/src/main/resources/application-prod.yml b/backend/src/main/resources/application-prod.yml deleted file mode 100644 index 3d464a80..00000000 --- a/backend/src/main/resources/application-prod.yml +++ /dev/null @@ -1,7 +0,0 @@ -# Production overrides. The secure defaults (Swagger off, CSP on, no database password fallback, tormap.org CORS -# origins) are already set in application.yml. -app: - http: - cache: - public: - max-age-seconds: 300 diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index fc22dcf8..52a1713c 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -2,7 +2,7 @@ app: http: cache: public: - max-age-seconds: 0 + max-age-seconds: 300 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 diff --git a/backend/src/test/kotlin/org/tormap/config/SecurityRulesTest.kt b/backend/src/test/kotlin/org/tormap/config/SecurityRulesTest.kt index ff8c81d4..bba9a2a1 100644 --- a/backend/src/test/kotlin/org/tormap/config/SecurityRulesTest.kt +++ b/backend/src/test/kotlin/org/tormap/config/SecurityRulesTest.kt @@ -21,7 +21,7 @@ import java.util.Base64 */ @SpringBootTest @AutoConfigureMockMvc -// No dev or prod profile, so these rules check the defaults from application.yml +// No dev profile, so these rules check the defaults from application.yml @ActiveProfiles("test") class SecurityRulesTest( private val mockMvc: MockMvc,