What changed, and why it matters
This commit closes a cross-site request forgery (CSRF) hole in Eclair's admin API. Before the change, a malicious web page visited by a node operator could silently submit authenticated API requests (for example, to send funds or close Lightning channels) because browsers reuse cached HTTP basic-auth credentials. The patch now rejects any API request that carries an Origin header, which browsers set automatically while command-line tools do not. It also removes CORS headers that wrongly advertised cross-origin access. This is a hardening/breaking-change fix, not a full authentication replacement, and the API should still only be reachable on the local machine.
Deploy this commit promptly if the node exposes the API to any network reachable by a browser. Continue to keep the API bound to loopback and protected by firewall rules, because the Origin header can be trivially omitted by non-browser attackers. Review any custom web front-ends: they now need a separate back-end proxy in front of Eclair. Monitor logs for the new warning messages about rejected Origin requests.
Security signals we found
New origin-check directive rejecting all requests with an Origin header
Removal of CORS response headers from API responses
CSRF protection described in commit message and release notes
Tests verify 403 for browser-origin, null origin, and WebSocket requests
Basic-auth credentials still cached by browsers are the underlying threat vector
Evidence from the diff
The patch adds an Akka HTTP OriginDirective that runs inside securedHandler before authentication. It uses optionalHeaderValueByName("Origin") and rejects with HTTP 403 whenever the header is present, regardless of its value (including Origin: null). This blocks browser-issued cross-site and same-site non-GET/HEAD requests while leaving curl/eclair-cli unaffected. DefaultHeaders drops Access-Control-Allow-Headers and Access-Control-Allow-Methods so the API no longer advertises CORS support. Tests cover evil-origin, no-credentials, WebSocket, and Origin: null cases. The commit message and release notes explicitly describe the CSRF risk and label the change as a breaking API change.
Changed components
Eclair HTTP API service (eclair-node API routes)Akka HTTP directive stack in EclairDirectivesDefaultHeaders CORS handlingWebSocket endpoint (/ws)API documentation and release notesInspect captured patch +111 / −8
### 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:<port>/ws`, and emits a message containing the payment hash of a payment when receiving a payment.
### docs/release-notes/eclair-vnext.md
@@ -12,7 +12,15 @@
### API changes
-<insert 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
### 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
}
### 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.
### 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)
+ }
+
+}
### 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)) ~>Why this scored 79/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.