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
18 changes: 13 additions & 5 deletions THREAT_MODEL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand All @@ -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, 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"
(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
Expand All @@ -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
Expand Down Expand Up @@ -118,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.

Expand Down Expand Up @@ -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.
50 changes: 32 additions & 18 deletions backend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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

Expand Down Expand Up @@ -92,19 +94,21 @@ 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, its defaults are the production settings
- `backend/src/main/resources/application-dev.yml` - local development: Swagger UI, CORS for `http://localhost:3000`,
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
- `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`

Expand Down Expand Up @@ -140,20 +144,24 @@ 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 <backend jar file>`
4. Run command `java -jar <backend jar file>` 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:

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`. 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 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

Expand All @@ -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)
Expand All @@ -182,14 +196,14 @@ 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

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.
7 changes: 7 additions & 0 deletions backend/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,13 @@ tasks.withType<Test> {
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<Exec>(taskName) {
Expand Down
4 changes: 4 additions & 0 deletions backend/docker-compose.override.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
9 changes: 5 additions & 4 deletions backend/docker-compose.yml
Original file line number Diff line number Diff line change
@@ -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:
Expand All @@ -17,15 +18,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}
Expand Down
4 changes: 0 additions & 4 deletions backend/src/main/kotlin/org/tormap/config/SecurityConfig.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
17 changes: 17 additions & 0 deletions backend/src/main/resources/application-dev.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# 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
spring:
datasource:
password: ${SPRING_DATASOURCE_PASSWORD:${TORMAP_DATABASE_PASSWORD:postgres}}
springdoc:
api-docs:
enabled: true
swagger-ui:
enabled: true
17 changes: 0 additions & 17 deletions backend/src/main/resources/application-prod.yml

This file was deleted.

20 changes: 14 additions & 6 deletions backend/src/main/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,14 @@ 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
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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Loading
Loading