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
4 changes: 4 additions & 0 deletions docs/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
10 changes: 9 additions & 1 deletion docs/release-notes/eclair-vnext.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
}
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -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)) ~>
Expand Down
Loading