diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..3579f63c --- /dev/null +++ b/.dockerignore @@ -0,0 +1,18 @@ +# Keep the Docker build context small and reproducible. +# (The proxy image builds from the repo root because :server depends on :shared.) +.git +.github +**/build/ +**/.gradle/ +.gradle/ +.kotlin/ +kotlin-js-store/ +node_modules/ +**/node_modules/ +.idea/ +*.iml +.DS_Store +local.properties +# iOS artefacts are irrelevant to the JVM proxy build +ios*/ +*.xcworkspace diff --git a/.github/workflows/publish-proxy-image.yml b/.github/workflows/publish-proxy-image.yml new file mode 100644 index 00000000..ba59fe5e --- /dev/null +++ b/.github/workflows/publish-proxy-image.yml @@ -0,0 +1,54 @@ +name: Publish proxy image + +# Builds the CORS proxy container and publishes it to GitHub Container Registry +# so self-hosters can run a reproducible, pre-built image instead of building +# from source. Triggered by tags like `proxy-v1.0.0` and manual runs. +on: + push: + tags: + - "proxy-v*" + workflow_dispatch: + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository_owner }}/spectacled-proxy + +jobs: + build-and-push: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Log in to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract image metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=semver,pattern={{version}},prefix=,value=${{ github.ref_name }} + type=raw,value=latest + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: . + file: server/Dockerfile + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/README.md b/README.md index 95f96ccd..f47f23ab 100644 --- a/README.md +++ b/README.md @@ -107,8 +107,8 @@ This project uses the **Gradle Wrapper** and the **Foojay toolchain resolver**. ┣ πŸ“‚ iosTasksApp/ ← iOS Xcode project for Tasks ┣ πŸ“„ spectacled.xcworkspace ← Xcode workspace combining all three iOS apps ┃ - ┣ πŸ“‚ server/ ← Ktor backend scaffold β€” currently unused template - ┃ boilerplate, not required to build or run any app + ┣ πŸ“‚ server/ ← Ktor CORS proxy for the Web build (see server/README.md). + ┃ Only the browser needs it; native apps talk CalDAV directly. ┃ β”— πŸ“‚ gradle/ β”— πŸ“„ libs.versions.toml ← β˜… All dependency versions live here @@ -177,6 +177,14 @@ Each command below works for any of the three apps β€” just swap `composeJournal Requires a recent browser (Chrome 119+, Firefox 120+, Safari 18.2+). +> **⚠️ The Web build needs the CORS proxy.** Browsers block cross-origin WebDAV requests, so the +> web app routes CalDAV traffic through the small Ktor proxy in [`server/`](server/README.md), which +> adds the required CORS headers (native apps talk to CalDAV directly and don't need it). Run it +> locally with `./gradlew :server:run` and point **Settings β†’ Proxy server** at +> `http://localhost:8088`. For hosting, **self-host your own** instance (a shared proxy can see your +> credentials in transit) β€” see [`server/README.md`](server/README.md) for Docker/Fly.io setup and +> the trust caveats. + ### 🍎 iOS Requires macOS + Xcode 16+. @@ -199,6 +207,8 @@ DEVELOPMENT_TEAM=YOUR_APPLE_TEAM_ID | Command | What it does | |------------------------------|----------------------------------------------------------------| | `./gradlew :shared:allTests` | Run the shared module's test suite | +| `./gradlew :server:run` | Run the Web CORS proxy locally on `http://localhost:8088` | +| `./gradlew :server:test` | Run the proxy's test suite | | `./gradlew clean` | Delete all build outputs | | `./gradlew --stop` | Stop all Gradle daemons (useful after a bad incremental build) | diff --git a/fly.toml b/fly.toml new file mode 100644 index 00000000..8faf7526 --- /dev/null +++ b/fly.toml @@ -0,0 +1,37 @@ +# Fly.io deployment for the Spectacled CalDAV CORS proxy. +# +# This is a TEMPLATE β€” copy it and set your own values before deploying: +# 1. Change `app` to a unique name (`fly apps create `). +# 2. Set PROXY_ALLOWED_ORIGINS to the origin(s) that serve your web build. +# 3. Optionally lock PROXY_ALLOWED_TARGET_HOSTS to the CalDAV host(s) you trust +# (strongly recommended for a shared/demo instance). +# Then: `fly deploy` +# +# See server/README.md for the full self-hosting guide and the trust caveats. + +app = "spectacled-proxy" +primary_region = "fra" + +[build] + dockerfile = "server/Dockerfile" + +[env] + # The app reads PORT; keep this in sync with internal_port below. + PORT = "8080" + # Only allow https CalDAV targets (default). Set to "false" only for local testing. + PROXY_REQUIRE_HTTPS_TARGET = "true" + # REQUIRED for a public instance: the web origin(s) allowed to use this proxy. + PROXY_ALLOWED_ORIGINS = "https://spectacled.techbee.at" + # Recommended for a demo instance: restrict which CalDAV hosts may be reached. + # PROXY_ALLOWED_TARGET_HOSTS = "example-caldav.org" + +[http_service] + internal_port = 8080 + force_https = true + auto_stop_machines = "stop" + auto_start_machines = true + min_machines_running = 0 + +[[vm]] + size = "shared-cpu-1x" + memory = "512mb" diff --git a/server/Dockerfile b/server/Dockerfile new file mode 100644 index 00000000..f8c755bc --- /dev/null +++ b/server/Dockerfile @@ -0,0 +1,32 @@ +# syntax=docker/dockerfile:1 + +# Build the Ktor proxy as a self-contained fat JAR. +# Build context is the repository ROOT (the server module depends on :shared), e.g.: +# docker build -f server/Dockerfile -t spectacled-proxy . +FROM eclipse-temurin:21-jdk AS build +WORKDIR /app + +# Warm the Gradle wrapper cache separately from sources for better layer caching. +COPY gradlew gradlew.bat settings.gradle.kts build.gradle.kts gradle.properties ./ +COPY gradle ./gradle +RUN chmod +x gradlew + +# Copy the rest of the project and build only the server fat JAR. +COPY . . +RUN ./gradlew --no-daemon :server:buildFatJar + +# Minimal JRE runtime image. +FROM eclipse-temurin:21-jre AS runtime +WORKDIR /app + +# Run as a non-root user. +RUN useradd --system --uid 10001 --create-home appuser +USER appuser + +COPY --from=build /app/server/build/libs/server-all.jar /app/server.jar + +# PaaS platforms (Fly.io, Render, …) inject PORT; the app honours it, defaulting to 8088. +ENV PORT=8080 +EXPOSE 8080 + +ENTRYPOINT ["java", "-jar", "/app/server.jar"] diff --git a/server/README.md b/server/README.md new file mode 100644 index 00000000..293c06c8 --- /dev/null +++ b/server/README.md @@ -0,0 +1,99 @@ +# Spectacled CORS Proxy + +A tiny [Ktor](https://ktor.io/) reverse proxy that lets the **web build** of Spectacled talk +to CalDAV servers. + +## Why this exists + +Spectacled talks to CalDAV servers using WebDAV HTTP methods (`PROPFIND`, `REPORT`, `MKCOL`, …). +On Android, iOS, and Desktop the app contacts your CalDAV server directly. **In the browser it +can't:** browsers enforce [CORS](https://developer.mozilla.org/docs/Web/HTTP/CORS), and CalDAV +servers (Nextcloud, Radicale, …) generally don't send the CORS headers a browser requires for +cross-origin WebDAV. Since Spectacled works against *any* server you point it at, we can't rely on +each of those servers being reconfigured. + +This proxy sits between the web app and your CalDAV server: the browser calls the proxy (same +origin policy satisfied by CORS headers the proxy adds), and the proxy forwards the request to the +real server named in the `X-Target-Url` header. CORS is a **browser-only** restriction β€” the native +apps don't use this proxy at all. + +> ### ⚠️ Trust: run your own +> The proxy terminates TLS, so it sees the `Authorization` header (your CalDAV credentials) in +> transit. **Whoever runs the proxy could read those credentials.** For that reason: +> - **Self-host your own instance** whenever you can β€” then you are the only one in the path. +> - Any shared/public instance (including a project demo) should be treated as **evaluation only β€” +> do not use real credentials** against a proxy you don't control. + +## How it works + +- Reads the destination from the `X-Target-Url` request header (or a `?target=` query parameter). +- Validates the target (scheme, host allow-list, private-address block β€” see below), then forwards + the method, headers, and body, streaming the response back with permissive CORS headers. +- `GET /` is a health/info endpoint. + +## Configuration (environment variables) + +| Variable | Default | Purpose | +|------------------------------|----------|-----------------------------------------------------------------------------------------------| +| `PORT` | `8088` | Port to bind. PaaS hosts (Fly.io, Render, …) inject this automatically. | +| `PROXY_ALLOWED_ORIGINS` | *(any)* | Comma-separated web origins allowed by CORS, e.g. `https://spectacled.techbee.at`. Unset = reflect any origin (**dev only**, logged as a warning). Set this in production. | +| `PROXY_ALLOWED_TARGET_HOSTS` | *(any)* | Comma-separated allow-list of destination hostnames. Unset = any host. Strongly recommended for a shared/demo instance so it can't be abused as an open relay. | +| `PROXY_REQUIRE_HTTPS_TARGET` | `true` | Reject non-`https` target URLs. | +| `PROXY_ALLOW_PRIVATE_TARGETS`| `false` | When `false`, targets that resolve to loopback/link-local/private/unique-local addresses (e.g. `169.254.169.254`, `127.0.0.1`) are rejected. This is the SSRF guard β€” leave it off in production. | + +## Run locally + +```bash +# From the repository root: +./gradlew :server:run +# Proxy on http://localhost:8088 (allows any origin/target β€” dev defaults) + +# In the web app's Settings β†’ Proxy server, use: http://localhost:8088 +``` + +## Build a container + +The image builds from the **repository root** (the module depends on `:shared`): + +```bash +docker build -f server/Dockerfile -t spectacled-proxy . +docker run --rm -p 8088:8080 \ + -e PROXY_ALLOWED_ORIGINS=https://spectacled.techbee.at \ + -e PROXY_ALLOWED_TARGET_HOSTS=your-caldav.example \ + spectacled-proxy +``` + +A pre-built image is published to GitHub Container Registry on `proxy-v*` tags +(see `.github/workflows/publish-proxy-image.yml`): + +```bash +docker run --rm -p 8088:8080 \ + -e PROXY_ALLOWED_ORIGINS=https://your-web-app.example \ + ghcr.io/techbeeat/spectacled-proxy:latest +``` + +## Deploy to Fly.io (recommended) + +[`fly.toml`](../fly.toml) in the repo root is a template. From the repository root: + +```bash +fly launch --copy-config --no-deploy # or: fly apps create +# Edit fly.toml: set a unique `app` name and your PROXY_ALLOWED_ORIGINS +fly deploy +``` + +Fly builds `server/Dockerfile`, injects `PORT`, and terminates TLS for you. The template scales to +zero (`auto_stop_machines`) to keep idle cost near nothing; expect a brief cold start on the first +request after idle. A `shared-cpu-1x` / 512 MB machine is enough for this JVM app. + +Other container hosts (Render, Koyeb, Railway, …) work the same way β€” point them at +`server/Dockerfile`, set `PROXY_ALLOWED_ORIGINS`, and let the platform provide `PORT`. + +## Tests + +```bash +./gradlew :server:test +``` + +Covers the health endpoint, missing/rejected targets (host allow-list, https-only, private-address +SSRF guard), CORS preflight, and a full proxied round-trip. diff --git a/server/src/main/kotlin/Application.kt b/server/src/main/kotlin/Application.kt index 50ca1797..45e6c364 100644 --- a/server/src/main/kotlin/Application.kt +++ b/server/src/main/kotlin/Application.kt @@ -1,4 +1,6 @@ -ο»Ώimport io.ktor.client.HttpClient +package at.techbee.spectacled + +import io.ktor.client.HttpClient import io.ktor.client.engine.cio.CIO import io.ktor.client.request.request import io.ktor.client.request.setBody @@ -26,22 +28,91 @@ import io.ktor.server.routing.route import io.ktor.server.routing.routing import io.ktor.utils.io.ByteWriteChannel import io.ktor.utils.io.copyTo +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.slf4j.LoggerFactory +import java.net.Inet6Address +import java.net.InetAddress + +private val logger = LoggerFactory.getLogger("SpectacledProxy") + +const val DEFAULT_SERVER_PORT = 8088 + +/** + * Runtime configuration for the proxy, read from environment variables so the same + * image can be self-hosted or run as a demo instance without code changes. + * + * @param port TCP port to bind. `PORT` (PaaS hosts inject this). + * @param allowedOrigins CORS allow-list of web origins (e.g. `https://spectacled.techbee.at`). + * Empty = reflect any origin (development only β€” logged as a warning). + * @param allowedTargetHosts Allow-list of destination hostnames. Empty = any host permitted + * (private addresses are still blocked unless [allowPrivateTargets]). + * @param requireHttpsTarget Reject non-https target URLs. Defaults to true. + * @param allowPrivateTargets Permit targets that resolve to loopback/link-local/private ranges. + * Defaults to false; the SSRF guard that blocks them is the main reason + * this proxy is safe to expose publicly. + */ +data class ProxyConfig( + val port: Int, + val allowedOrigins: List, + val allowedTargetHosts: List, + val requireHttpsTarget: Boolean, + val allowPrivateTargets: Boolean, +) { + companion object { + fun fromEnv(): ProxyConfig { + fun csv(name: String): List = + System.getenv(name)?.split(",")?.map { it.trim() }?.filter { it.isNotEmpty() } ?: emptyList() -const val SERVER_PORT = 8088 + fun bool(name: String, default: Boolean): Boolean = + System.getenv(name)?.trim()?.toBooleanStrictOrNull() ?: default + + return ProxyConfig( + port = System.getenv("PORT")?.trim()?.toIntOrNull() ?: DEFAULT_SERVER_PORT, + allowedOrigins = csv("PROXY_ALLOWED_ORIGINS"), + allowedTargetHosts = csv("PROXY_ALLOWED_TARGET_HOSTS"), + requireHttpsTarget = bool("PROXY_REQUIRE_HTTPS_TARGET", default = true), + allowPrivateTargets = bool("PROXY_ALLOW_PRIVATE_TARGETS", default = false), + ) + } + } +} fun main() { - embeddedServer(Netty, port = SERVER_PORT, host = "0.0.0.0", module = Application::module) - .start(wait = true) + val config = ProxyConfig.fromEnv() + logger.info( + "Starting Spectacled proxy on port {} (allowedOrigins={}, allowedTargetHosts={}, requireHttps={}, allowPrivate={})", + config.port, + config.allowedOrigins.ifEmpty { "" }, + config.allowedTargetHosts.ifEmpty { "" }, + config.requireHttpsTarget, + config.allowPrivateTargets, + ) + embeddedServer(Netty, port = config.port, host = "0.0.0.0") { + module(config) + }.start(wait = true) } -fun Application.module() { +fun Application.module(config: ProxyConfig = ProxyConfig.fromEnv()) { val client = HttpClient(CIO) { followRedirects = false } install(CORS) { - anyHost() // Allow any host for the proxy - + if (config.allowedOrigins.isEmpty()) { + logger.warn( + "PROXY_ALLOWED_ORIGINS is not set β€” reflecting ANY origin. " + + "This is fine for local development but must be set when hosting publicly." + ) + anyHost() + } else { + config.allowedOrigins.forEach { origin -> + val scheme = origin.substringBefore("://", missingDelimiterValue = "https") + val hostWithPort = origin.substringAfter("://") + allowHost(hostWithPort, schemes = listOf(scheme)) + } + } + allowHeader("X-Target-Url") allowHeader(HttpHeaders.Authorization) allowHeader(HttpHeaders.ContentType) @@ -80,7 +151,10 @@ fun Application.module() { routing { get("/") { - call.respondText("Spectacled Proxy Server is running. Usage: Set 'X-Target-Url' header to the destination URL.") + call.respondText( + "Spectacled Proxy Server is running. Usage: set the 'X-Target-Url' header to the " + + "destination CalDAV URL. See https://github.com/TechbeeAT/spectacled/tree/main/server" + ) } // Catch-all route for proxying @@ -88,33 +162,36 @@ fun Application.module() { handle { val targetUrlString = call.request.headers["X-Target-Url"] ?: call.request.queryParameters["target"] - ?: return@handle call.respond(HttpStatusCode.BadRequest, "Missing 'X-Target-Url' header or 'target' query parameter") + ?: return@handle call.respond( + HttpStatusCode.BadRequest, + "Missing 'X-Target-Url' header or 'target' query parameter" + ) - val targetUrl = try { - Url(targetUrlString) - } catch (e: Exception) { - return@handle call.respond(HttpStatusCode.BadRequest, "Invalid target URL: $targetUrlString") + val targetUrl = when (val validation = validateTarget(targetUrlString, config)) { + is TargetValidation.Ok -> validation.url + is TargetValidation.Rejected -> { + logger.warn("Rejected proxy target '{}': {}", targetUrlString, validation.reason) + return@handle call.respond(HttpStatusCode.Forbidden, "Target rejected: ${validation.reason}") + } } - println("Proxying ${call.request.httpMethod.value} to: $targetUrl") + logger.info("Proxying {} to host {}", call.request.httpMethod.value, targetUrl.host) try { val response = client.request(targetUrl) { method = call.request.httpMethod - // Copy request headers, excluding hop-by-hop ones + // Copy request headers, excluding hop-by-hop and browser-added ones. call.request.headers.forEach { name, values -> - if (!isHopByHopHeader(name) && name != "X-Target-Url") { + if (!isHopByHopHeader(name) && !isStrippedRequestHeader(name)) { headers.appendAll(name, values) } } - // Forward body for methods that typically include one - if (call.request.httpMethod in listOf( - HttpMethod.Post, HttpMethod.Put, HttpMethod.Patch, - HttpMethod("PROPFIND"), HttpMethod("REPORT"), HttpMethod("PROPPATCH") - ) - ) { + // Forward the body whenever the incoming request carries one. Keying off the + // presence of a body (not a method allow-list) means WebDAV LOCK β€” which sends + // an XML lock-info body β€” is forwarded correctly. + if (requestHasBody(call.request.headers)) { setBody(call.receiveChannel()) } } @@ -125,10 +202,12 @@ fun Application.module() { override val contentLength: Long? = response.contentLength() override val headers: Headers = Headers.build { response.headers.forEach { name, values -> - // Skip headers that Ktor will set automatically or that shouldn't be forwarded + // Skip headers Ktor sets automatically, and any CORS headers the + // upstream sent β€” the CORS plugin here is the single source of truth. if (!isHopByHopHeader(name) && name != HttpHeaders.ContentType && - name != HttpHeaders.ContentLength + name != HttpHeaders.ContentLength && + !isStrippedResponseHeader(name) ) { appendAll(name, values) } @@ -140,14 +219,80 @@ fun Application.module() { } }) } catch (e: Exception) { - println("Proxy error: ${e.message}") - call.respond(HttpStatusCode.InternalServerError, "Proxy error: ${e.message}") + logger.warn("Proxy error talking to {}: {}", targetUrl.host, e.message) + call.respond(HttpStatusCode.BadGateway, "Proxy error: ${e.message}") } } } } } +sealed class TargetValidation { + data class Ok(val url: Url) : TargetValidation() + data class Rejected(val reason: String) : TargetValidation() +} + +/** + * Validates a caller-supplied target URL before the proxy forwards to it. This is the core + * defense that stops the proxy from being an open relay / SSRF pivot: it enforces the scheme, + * an optional host allow-list, and (unless explicitly disabled) blocks any target that resolves + * to a loopback/link-local/private/unique-local address such as cloud metadata (169.254.169.254). + */ +suspend fun validateTarget(raw: String, config: ProxyConfig): TargetValidation { + val url = try { + Url(raw) + } catch (e: Exception) { + return TargetValidation.Rejected("invalid URL") + } + + if (url.host.isBlank()) return TargetValidation.Rejected("missing host") + + val scheme = url.protocol.name.lowercase() + if (scheme != "http" && scheme != "https") return TargetValidation.Rejected("unsupported scheme") + if (config.requireHttpsTarget && scheme != "https") return TargetValidation.Rejected("https target required") + + if (config.allowedTargetHosts.isNotEmpty() && + config.allowedTargetHosts.none { it.equals(url.host, ignoreCase = true) } + ) { + return TargetValidation.Rejected("target host not on allow-list") + } + + if (!config.allowPrivateTargets) { + val addresses = try { + withContext(Dispatchers.IO) { InetAddress.getAllByName(url.host) } + } catch (e: Exception) { + return TargetValidation.Rejected("could not resolve target host") + } + if (addresses.any { it.isPrivateOrLocal() }) { + return TargetValidation.Rejected("target resolves to a private or local address") + } + } + + return TargetValidation.Ok(url) +} + +/** + * True for addresses that must never be reachable through a public proxy: loopback, wildcard, + * link-local (incl. IPv4 169.254/16 and IPv6 fe80::/10), site-local/private IPv4 ranges, + * multicast, and IPv6 unique-local (fc00::/7). + */ +private fun InetAddress.isPrivateOrLocal(): Boolean { + if (isLoopbackAddress || isAnyLocalAddress || isLinkLocalAddress || isSiteLocalAddress || isMulticastAddress) { + return true + } + if (this is Inet6Address) { + val firstByte = address.firstOrNull()?.toInt()?.and(0xfe) ?: return false + if (firstByte == 0xfc) return true // fc00::/7 unique local + } + return false +} + +private fun requestHasBody(headers: Headers): Boolean { + val contentLength = headers[HttpHeaders.ContentLength]?.toLongOrNull() + if (contentLength != null) return contentLength > 0 + return headers[HttpHeaders.TransferEncoding]?.contains("chunked", ignoreCase = true) == true +} + /** * Determines if a header is "hop-by-hop" and should not be forwarded. */ @@ -155,9 +300,26 @@ private fun isHopByHopHeader(name: String): Boolean = name.equals(HttpHeaders.Host, ignoreCase = true) || name.equals(HttpHeaders.TransferEncoding, ignoreCase = true) || name.equals(HttpHeaders.Connection, ignoreCase = true) || - //name.equals(HttpHeaders.KeepAlive, ignoreCase = true) || name.equals(HttpHeaders.ProxyAuthenticate, ignoreCase = true) || name.equals(HttpHeaders.ProxyAuthorization, ignoreCase = true) || name.equals(HttpHeaders.TE, ignoreCase = true) || - //name.equals(HttpHeaders.Trailers, ignoreCase = true) || - name.equals(HttpHeaders.Upgrade, ignoreCase = true) \ No newline at end of file + name.equals(HttpHeaders.Upgrade, ignoreCase = true) + +/** + * Request headers we deliberately don't forward to the target: the routing header itself, + * the incoming Content-Length (the outbound body is re-framed by the client), and browser + * context headers that would confuse or leak information to the upstream server. + */ +private fun isStrippedRequestHeader(name: String): Boolean = + name.equals("X-Target-Url", ignoreCase = true) || + name.equals(HttpHeaders.ContentLength, ignoreCase = true) || + name.equals(HttpHeaders.Origin, ignoreCase = true) || + name.equals("Referer", ignoreCase = true) || + name.equals(HttpHeaders.Cookie, ignoreCase = true) + +/** + * Response headers we drop so the CORS plugin remains the single source of truth for + * cross-origin headers (avoids duplicated/conflicting Access-Control-* on the response). + */ +private fun isStrippedResponseHeader(name: String): Boolean = + name.startsWith("Access-Control-", ignoreCase = true) diff --git a/server/src/main/resources/logback.xml b/server/src/main/resources/logback.xml index 3e11d781..c91a53af 100644 --- a/server/src/main/resources/logback.xml +++ b/server/src/main/resources/logback.xml @@ -4,7 +4,7 @@ %d{YYYY-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n - + diff --git a/server/src/test/kotlin/at/techbee/spectacled/ApplicationTest.kt b/server/src/test/kotlin/at/techbee/spectacled/ApplicationTest.kt index ca0ad920..6d90752d 100644 --- a/server/src/test/kotlin/at/techbee/spectacled/ApplicationTest.kt +++ b/server/src/test/kotlin/at/techbee/spectacled/ApplicationTest.kt @@ -1,20 +1,106 @@ -ο»Ώpackage at.techbee.spectacled +package at.techbee.spectacled -import io.ktor.client.request.* -import io.ktor.client.statement.* -import io.ktor.http.* -import io.ktor.server.testing.* -import kotlin.test.* +import io.ktor.client.request.get +import io.ktor.client.request.header +import io.ktor.client.request.options +import io.ktor.client.statement.bodyAsText +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpStatusCode +import io.ktor.server.engine.embeddedServer +import io.ktor.server.netty.Netty +import io.ktor.server.response.respondText +import io.ktor.server.routing.get +import io.ktor.server.routing.routing +import io.ktor.server.testing.testApplication +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue class ApplicationTest { + private fun config( + allowedOrigins: List = listOf("https://app.example"), + allowedTargetHosts: List = emptyList(), + requireHttpsTarget: Boolean = false, + allowPrivateTargets: Boolean = true, + ) = ProxyConfig( + port = 0, + allowedOrigins = allowedOrigins, + allowedTargetHosts = allowedTargetHosts, + requireHttpsTarget = requireHttpsTarget, + allowPrivateTargets = allowPrivateTargets, + ) + @Test - fun testRoot() = testApplication { - application { - module() - } + fun rootReturnsBanner() = testApplication { + application { module(config()) } val response = client.get("/") assertEquals(HttpStatusCode.OK, response.status) - assertEquals("Ktor: ${Greeting().greet()}", response.bodyAsText()) + assertTrue(response.bodyAsText().contains("Spectacled Proxy Server")) + } + + @Test + fun missingTargetIsBadRequest() = testApplication { + application { module(config()) } + val response = client.get("/anything") + assertEquals(HttpStatusCode.BadRequest, response.status) + } + + @Test + fun targetHostNotOnAllowListIsForbidden() = testApplication { + application { module(config(allowedTargetHosts = listOf("allowed.example"))) } + val response = client.get("/dav") { + header("X-Target-Url", "https://blocked.example/dav") + } + assertEquals(HttpStatusCode.Forbidden, response.status) + } + + @Test + fun nonHttpsTargetIsForbiddenWhenHttpsRequired() = testApplication { + application { module(config(requireHttpsTarget = true)) } + val response = client.get("/dav") { + header("X-Target-Url", "http://allowed.example/dav") + } + assertEquals(HttpStatusCode.Forbidden, response.status) + } + + @Test + fun privateTargetIsForbidden() = testApplication { + application { module(config(allowPrivateTargets = false)) } + val response = client.get("/dav") { + header("X-Target-Url", "http://127.0.0.1/dav") + } + assertEquals(HttpStatusCode.Forbidden, response.status) + } + + @Test + fun corsPreflightEchoesAllowedOrigin() = testApplication { + application { module(config(allowedOrigins = listOf("https://app.example"))) } + val response = client.options("/dav") { + header(HttpHeaders.Origin, "https://app.example") + header(HttpHeaders.AccessControlRequestMethod, "PROPFIND") + } + assertEquals("https://app.example", response.headers[HttpHeaders.AccessControlAllowOrigin]) + } + + @Test + fun proxiesRequestToAllowedTarget() = testApplication { + // A real upstream server the proxy forwards to over a loopback socket. + val upstream = embeddedServer(Netty, port = 0) { + routing { get("/echo") { call.respondText("hello-from-upstream") } } + } + upstream.start(wait = false) + try { + val upstreamPort = upstream.engine.resolvedConnectors().first().port + application { module(config()) } + + val response = client.get("/echo") { + header("X-Target-Url", "http://127.0.0.1:$upstreamPort/echo") + } + assertEquals(HttpStatusCode.OK, response.status) + assertEquals("hello-from-upstream", response.bodyAsText()) + } finally { + upstream.stop(gracePeriodMillis = 0, timeoutMillis = 500) + } } -} \ No newline at end of file +} diff --git a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/account/presentation/components/SettingsBottomSheet.kt b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/account/presentation/components/SettingsBottomSheet.kt index 5d6da681..cb51dac9 100644 --- a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/account/presentation/components/SettingsBottomSheet.kt +++ b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/account/presentation/components/SettingsBottomSheet.kt @@ -337,7 +337,12 @@ fun SettingsBottomSheet( color = MaterialTheme.colorScheme.error ) } - Text("TODO: Info about proxy server") + Text( + "Web only. Browsers block cross-origin CalDAV (WebDAV) requests, so the " + + "web app routes them through this proxy, which adds the required CORS " + + "headers. The proxy can see your credentials in transit β€” prefer one you " + + "host yourself. Setup: github.com/TechbeeAT/spectacled/tree/main/server" + ) } }, label = { Text("Proxy server") }, @@ -355,11 +360,11 @@ fun SettingsBottomSheet( text = { Column { Text("Development test") - Text("http://0.0.0.0:8088") + Text("http://localhost:8088") } }, onClick = { - userAppPreferencesStore.userProxyServer = "http://0.0.0.0:8088" + userAppPreferencesStore.userProxyServer = "http://localhost:8088" userProxyServerDropdownExpanded = false } ) diff --git a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/data/HttpClientFactory.kt b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/data/HttpClientFactory.kt index 65c9c8b6..1144bcbd 100644 --- a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/data/HttpClientFactory.kt +++ b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/data/HttpClientFactory.kt @@ -19,17 +19,26 @@ import kotlinx.serialization.json.Json object HttpClientFactory { + /** Proxy the web (WASM) build falls back to when the user hasn't configured one. */ + const val DEFAULT_WEB_PROXY_URL = "http://localhost:8088" + + /** The proxy URL to use on the current platform when no user setting is present. */ + fun defaultProxyUrl(): String? = + if (getPlatform().platform == Platforms.WASM) DEFAULT_WEB_PROXY_URL else null + fun create( engine: HttpClientEngine, jsonContentNegotiation: Boolean = true, - proxyUrl: String? = if(getPlatform().platform == Platforms.WASM) "http://localhost:8088" else null + // Resolved per request so the in-app "Proxy server" setting takes effect without an app restart. + proxyUrlProvider: () -> String? = { defaultProxyUrl() } ): HttpClient { return HttpClient(engine) { followRedirects = false - if (proxyUrl != null) { - install("ProxyInterceptor") { - requestPipeline.intercept(HttpRequestPipeline.Transform) { + install("ProxyInterceptor") { + requestPipeline.intercept(HttpRequestPipeline.Transform) { + val proxyUrl = proxyUrlProvider()?.takeIf { it.isNotBlank() } + if (proxyUrl != null) { val originalUrl = context.url.buildString() // Only proxy external requests, not the proxy itself if (originalUrl.startsWith("http") && !originalUrl.startsWith(proxyUrl)) { diff --git a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/koin/Modules.kt b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/koin/Modules.kt index 8c865ec6..a1035809 100644 --- a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/koin/Modules.kt +++ b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/koin/Modules.kt @@ -3,6 +3,7 @@ package at.techbee.spectacled.screens.core.koin import at.techbee.spectacled.screens.about.presentation.AboutViewModel import at.techbee.spectacled.screens.account.presentation.AccountListViewModel import at.techbee.spectacled.screens.core.data.HttpClientFactory +import at.techbee.spectacled.screens.core.data.UserAppPreferencesStore import at.techbee.spectacled.screens.core.data.getPlatformEngine import at.techbee.spectacled.screens.core.data.repository.CalendarRepositoryImpl import at.techbee.spectacled.screens.core.data.repository.IcalEntryRepositoryImpl @@ -17,7 +18,14 @@ import org.koin.core.module.dsl.viewModelOf import org.koin.dsl.module val sharedModule = module { - single { HttpClientFactory.create(getPlatformEngine()) } + single { + val preferences = get() + HttpClientFactory.create( + engine = getPlatformEngine(), + // Prefer the user-configured proxy, falling back to the platform default (web only). + proxyUrlProvider = { preferences.userProxyServer ?: HttpClientFactory.defaultProxyUrl() } + ) + } singleOf(::CalendarRepositoryImpl) { bind() } singleOf(::IcalEntryRepositoryImpl) { bind() }