From 9184d5e9fe7cd0f9d31f097fa982fa898af7150e Mon Sep 17 00:00:00 2001 From: pm47 Date: Fri, 4 Sep 2026 14:56:08 +0200 Subject: [PATCH] loupe: reject api access from browsers See: - https://github.com/project-loupe/audit-eclair/issues/223 - https://github.com/project-loupe/audit-eclair/issues/224 --- docs/API.md | 4 ++ docs/release-notes/eclair-vnext.md | 10 +++- .../api/directives/DefaultHeaders.scala | 7 ++- .../api/directives/EclairDirectives.scala | 7 +-- .../api/directives/OriginDirective.scala | 45 ++++++++++++++++++ .../fr/acinq/eclair/api/ApiServiceSpec.scala | 46 +++++++++++++++++++ 6 files changed, 111 insertions(+), 8 deletions(-) create mode 100644 eclair-node/src/main/scala/fr/acinq/eclair/api/directives/OriginDirective.scala diff --git a/docs/API.md b/docs/API.md index cd3484d69a..1256d1b39b 100644 --- a/docs/API.md +++ b/docs/API.md @@ -9,6 +9,10 @@ eclair.api.password=changeit :rotating_light: **Attention:** Eclair's API should NOT be accessible from the outside world (similarly to Bitcoin Core API). +The API cannot be used from a web browser: requests that set the `Origin` header (which browsers always do) are +rejected. This protects against cross-site request forgery, since browsers attach cached basic auth credentials to +cross-site requests. Command-line tools and other back-ends never set that header and are unaffected. + ## Payment notification Eclair accepts websocket connection on `ws://localhost:/ws`, and emits a message containing the payment hash of a payment when receiving a payment. diff --git a/docs/release-notes/eclair-vnext.md b/docs/release-notes/eclair-vnext.md index e9471fdb4a..42224863d4 100644 --- a/docs/release-notes/eclair-vnext.md +++ b/docs/release-notes/eclair-vnext.md @@ -12,7 +12,15 @@ ### API changes - +The API now rejects requests that carry an `Origin` header, which means it cannot be used from a web browser anymore. + +Our API relies on HTTP basic authentication, which web browsers attach to cross-site requests once it has been cached: +any web page the node operator visits could then forge authenticated API calls, and since our endpoints accept +form-encoded parameters, a plain HTML form is enough (the attacker cannot read the response, but the API call has +already been made). Browsers set the `Origin` header on those requests, while `curl` and `eclair-cli` never do. + +Command-line usage is unaffected. If you were serving a web front-end for the API, you now need to put a back-end of +your own in front of it. ### Miscellaneous improvements and bug fixes diff --git a/eclair-node/src/main/scala/fr/acinq/eclair/api/directives/DefaultHeaders.scala b/eclair-node/src/main/scala/fr/acinq/eclair/api/directives/DefaultHeaders.scala index 5acf9380f0..482f222ee1 100644 --- a/eclair-node/src/main/scala/fr/acinq/eclair/api/directives/DefaultHeaders.scala +++ b/eclair-node/src/main/scala/fr/acinq/eclair/api/directives/DefaultHeaders.scala @@ -16,7 +16,6 @@ package fr.acinq.eclair.api.directives -import akka.http.scaladsl.model.HttpMethods.POST import akka.http.scaladsl.model.headers.CacheDirectives.{`max-age`, `no-store`, public} import akka.http.scaladsl.model.headers._ import akka.http.scaladsl.server.Directive0 @@ -29,7 +28,7 @@ trait DefaultHeaders { */ def eclairHeaders: Directive0 = respondWithDefaultHeaders(customHeaders) - private val customHeaders = `Access-Control-Allow-Headers`("Content-Type, Authorization") :: - `Access-Control-Allow-Methods`(POST) :: - `Cache-Control`(public, `no-store`, `max-age`(0)) :: Nil + // NB: we deliberately don't send any CORS header. This API cannot be used from a web browser (see `OriginDirective`), + // so there is no cross-origin access to grant: advertising `Access-Control-Allow-*` would only suggest otherwise. + private val customHeaders = `Cache-Control`(public, `no-store`, `max-age`(0)) :: Nil } diff --git a/eclair-node/src/main/scala/fr/acinq/eclair/api/directives/EclairDirectives.scala b/eclair-node/src/main/scala/fr/acinq/eclair/api/directives/EclairDirectives.scala index f16ae889aa..5cf27fef94 100644 --- a/eclair-node/src/main/scala/fr/acinq/eclair/api/directives/EclairDirectives.scala +++ b/eclair-node/src/main/scala/fr/acinq/eclair/api/directives/EclairDirectives.scala @@ -22,14 +22,15 @@ import fr.acinq.eclair.api.Service import scala.concurrent.duration.DurationInt -trait EclairDirectives extends Directives with TimeoutDirective with ErrorDirective with AuthDirective with DefaultHeaders with ExtraDirectives { +trait EclairDirectives extends Directives with TimeoutDirective with ErrorDirective with OriginDirective with AuthDirective with DefaultHeaders with ExtraDirectives { this: Service => /** - * Prepares inner routes to be exposed as public API with default headers, basic authentication and error handling. + * Prepares inner routes to be exposed as public API with default headers, origin check, basic authentication and + * error handling. * Must be applied *after* aggregating all the inner routes. */ - def securedHandler: Directive0 = toStrictEntity(5 seconds) & eclairHeaders & handled & authenticated + def securedHandler: Directive0 = toStrictEntity(5 seconds) & eclairHeaders & handled & originChecked & authenticated /** * Provides a Timeout to the inner route either from request param or the default. diff --git a/eclair-node/src/main/scala/fr/acinq/eclair/api/directives/OriginDirective.scala b/eclair-node/src/main/scala/fr/acinq/eclair/api/directives/OriginDirective.scala new file mode 100644 index 0000000000..ed4649bcae --- /dev/null +++ b/eclair-node/src/main/scala/fr/acinq/eclair/api/directives/OriginDirective.scala @@ -0,0 +1,45 @@ +/* + * Copyright 2019 ACINQ SAS + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package fr.acinq.eclair.api.directives + +import akka.http.scaladsl.server.Directive0 +import fr.acinq.eclair.api.Service + +trait OriginDirective { + this: Service with EclairDirectives => + + /** + * A directive0 that rejects requests made by a web browser. + * + * Our API relies on HTTP basic authentication: once a browser has cached those credentials, it will attach them to + * cross-site requests as well, which lets any web page the node operator visits forge authenticated API calls (see + * https://owasp.org/www-community/attacks/csrf). Since our endpoints take form-encoded parameters, such a request + * can be made with a plain HTML form and thus doesn't require CORS approval: the attacker cannot read the response, + * but the side effects (sending funds on-chain, closing channels) have already happened. + * + * Browsers set the `Origin` header on every cross-site request and on every same-site POST, while the clients this + * API is meant for (curl, eclair-cli, other back-ends) never set it: rejecting requests that carry an origin is thus + * enough to close that attack vector. + */ + def originChecked: Directive0 = optionalHeaderValueByName("Origin").tflatMap { + case Tuple1(None) => pass // not a browser request + case Tuple1(Some(origin)) => + logger.warn(s"rejecting API request from origin=$origin: this API cannot be used from a web browser") + authorize(false) + } + +} diff --git a/eclair-node/src/test/scala/fr/acinq/eclair/api/ApiServiceSpec.scala b/eclair-node/src/test/scala/fr/acinq/eclair/api/ApiServiceSpec.scala index 39299323ce..57f2f33acb 100644 --- a/eclair-node/src/test/scala/fr/acinq/eclair/api/ApiServiceSpec.scala +++ b/eclair-node/src/test/scala/fr/acinq/eclair/api/ApiServiceSpec.scala @@ -192,6 +192,52 @@ class ApiServiceSpec extends AnyFunSuite with ScalatestRouteTest with IdiomaticM } } + test("API returns forbidden for requests made from a browser") { + Post("/plugin-test") ~> + addHeader("Origin", "http://evil.com") ~> + addCredentials(BasicHttpCredentials("", mockApi().password)) ~> + Route.seal(mockApi().route) ~> + check { + assert(handled) + assert(status == Forbidden) + } + } + + test("API returns forbidden for requests made from a browser, even without credentials") { + Post("/plugin-test") ~> + addHeader("Origin", "http://evil.com") ~> + Route.seal(mockApi().route) ~> + check { + assert(handled) + assert(status == Forbidden) + } + } + + test("the websocket rejects requests made from a browser") { + val wsClient = WSProbe() + WS("/ws", wsClient.flow) ~> + addHeader("Origin", "http://evil.com") ~> + addCredentials(BasicHttpCredentials("", mockApi().password)) ~> + Route.seal(mockApi().route) ~> + check { + assert(handled) + assert(status == Forbidden) + } + } + + test("API returns forbidden for a sandboxed browser context (Origin: null)") { + // Sandboxed iframes and documents loaded from `data:` or `file:` URLs send the opaque origin `null`. That is still + // a browser request and must be rejected, so we check for the presence of the header rather than its value. + Post("/plugin-test") ~> + addHeader("Origin", "null") ~> + addCredentials(BasicHttpCredentials("", mockApi().password)) ~> + Route.seal(mockApi().route) ~> + check { + assert(handled) + assert(status == Forbidden) + } + } + test("plugin injects its own route") { Post("/plugin-test") ~> addCredentials(BasicHttpCredentials("", mockApi().password)) ~>