├── .git-blame-ignore-revs ├── .github └── workflows │ └── ci.yaml ├── .gitignore ├── .scalafmt.conf ├── CHANGELOG.md ├── LICENSE ├── README.md ├── RELEASE.md ├── akka-http-cors-bench-jmh └── src │ └── main │ └── scala │ └── ch │ └── megard │ └── akka │ └── http │ └── cors │ └── CorsBenchmark.scala ├── akka-http-cors-example └── src │ └── main │ ├── java │ └── ch │ │ └── megard │ │ └── akka │ │ └── http │ │ └── cors │ │ └── javadsl │ │ └── CorsServer.java │ ├── resources │ └── application.conf │ └── scala │ └── ch │ └── megard │ └── akka │ └── http │ └── cors │ └── scaladsl │ └── CorsServer.scala ├── akka-http-cors └── src │ ├── main │ ├── java │ │ └── ch │ │ │ └── megard │ │ │ └── akka │ │ │ └── http │ │ │ └── cors │ │ │ └── javadsl │ │ │ └── model │ │ │ ├── HttpHeaderRange.java │ │ │ ├── HttpHeaderRanges.java │ │ │ └── HttpOriginMatcher.java │ ├── resources │ │ └── reference.conf │ └── scala │ │ └── ch │ │ └── megard │ │ └── akka │ │ └── http │ │ └── cors │ │ ├── javadsl │ │ ├── CorsDirectives.scala │ │ ├── CorsRejection.scala │ │ └── settings │ │ │ └── CorsSettings.scala │ │ └── scaladsl │ │ ├── CorsDirectives.scala │ │ ├── CorsRejection.scala │ │ ├── model │ │ ├── HttpHeaderRange.scala │ │ └── HttpOriginMatcher.scala │ │ └── settings │ │ ├── CorsSettings.scala │ │ └── CorsSettingsImpl.scala │ └── test │ └── scala │ └── ch │ └── megard │ └── akka │ └── http │ └── cors │ ├── CorsDirectivesSpec.scala │ └── scaladsl │ ├── model │ └── HttpOriginMatcherSpec.scala │ └── settings │ └── CorsSettingsSpec.scala ├── build.sbt └── project ├── build.properties └── plugins.sbt /.git-blame-ignore-revs: -------------------------------------------------------------------------------- 1 | # Scala Steward: Reformat with scalafmt 3.7.11 2 | 22f24a9dc966c86340e65b48a03329b8355d6bea 3 | -------------------------------------------------------------------------------- /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | push: 4 | pull_request: 5 | jobs: 6 | test: 7 | strategy: 8 | fail-fast: false 9 | matrix: 10 | java: [ '8', '11', '17' ] 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Checkout 14 | uses: actions/checkout@v2 15 | - name: Setup Java 16 | uses: actions/setup-java@v2 17 | with: 18 | java-version: ${{ matrix.java }} 19 | distribution: 'temurin' 20 | - name: Build and Test 21 | run: sbt -v headerCheckAll scalafmtCheckAll +test 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.class 2 | *.log 3 | 4 | .cache 5 | .history 6 | .lib/ 7 | dist/* 8 | target/ 9 | lib_managed/ 10 | src_managed/ 11 | project/boot/ 12 | project/plugins/project/ 13 | .bsp/ 14 | 15 | .scala_dependencies 16 | .worksheet 17 | 18 | .idea/ 19 | -------------------------------------------------------------------------------- /.scalafmt.conf: -------------------------------------------------------------------------------- 1 | version = 3.7.15 2 | runner.dialect = scala213 3 | align.preset = more 4 | maxColumn = 120 5 | rewrite.rules = [AsciiSortImports,AvoidInfix,SortModifiers] 6 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 1.2.0 (2023-03-04) 4 | 5 | Scala 3 minor version 3.2.x. 6 | 7 | This release still targets akka with the Apache 2 license. 8 | 9 | - Update akka-http to 10.2.10 10 | - Update akka to 2.6.20 11 | - Update Scala to 2.13.10, 2.12.17 and 3.2.2 12 | 13 | ## 1.1.3 (2022-01-30) 14 | 15 | Cross-build against Scala 3, still using the 2.13 akka dependencies. 16 | 17 | - Update akka-http to 10.2.7. 18 | - Update akka to 2.6.18. 19 | - Update Scala to 2.13.8, 2.12.15 and 3.1.1. 20 | 21 | ## 1.1.2 (2021-08-01) 22 | 23 | - Update akka-http to 10.2.5. 24 | - Update akka to 2.6.15. 25 | - Update Scala to 2.13.6 and 2.12.14. 26 | 27 | ## 1.1.1 (2020-12-12) 28 | 29 | - Update akka-http to 10.2.2. 30 | - Update akka to 2.6.10. 31 | 32 | ## 1.1.0 (2020-08-06) 33 | 34 | - Update akka-http to 10.2.0. 35 | - Update akka to 2.6.8. 36 | - Update Scala to 2.13.3 and 2.12.12. 37 | 38 | ## 1.0.0 (2020-05-25) 39 | 40 | This release changes how settings are passed to the `cors` directive, 41 | extracting them from the Actor System configuration if not provided (see #72). 42 | 43 | Application code may use the new `CorsSettings.apply` functions instead of 44 | the `CorsSettings.defaultSettings` to have more control over the source of the 45 | configuration. 46 | 47 | - Load settings from the current Actor System (#37, #72). 48 | - Update akka-http to 10.1.12. 49 | - Update Scala to 2.13.2. 50 | - Use sbt-sonatype plugin for release. 51 | 52 | ## 0.4.3 (2020-04-18) 53 | 54 | - Drop support for Scala 2.11. 55 | - Update akka to 2.6.4 (#73). 56 | - Update akka-http to 10.1.11. 57 | - Update Scala to 2.12.11. 58 | 59 | ## 0.4.2 (2019-11-17) 60 | 61 | - Rejection handler handles all CorsRejection (#53). 62 | - Update Scala to 2.12.10 and 2.13.1. 63 | - Update akka-http to 10.1.10. 64 | - Use Scalafmt for formatting. 65 | 66 | ## 0.4.1 (2019-06-12) 67 | 68 | - Cross compile with Scala 2.13.0. 69 | - Update akka-http to 10.1.8. 70 | 71 | ## 0.4.0 (2019-03-09) 72 | 73 | - Support subdomain wildcard matcher in allowed origins (#25). 74 | - Remove directive `corsDecorate()` (#38). 75 | 76 | ### Migrate from 0.3 to 0.4 77 | 78 | - Use `HttpOriginMatcher` instead of `HttpOriginRange` in the settings. 79 | 80 | ## 0.3.4 (2019-01-17) 81 | 82 | - Cross compile with Scala 2.13.0-M5 (#40). 83 | - Update akka-http to 10.1.7. 84 | 85 | ## 0.3.3 (2018-12-17) 86 | 87 | - Fix: Java 1.8 support broken in 0.3.2 (#44). 88 | 89 | ## 0.3.2 (2018-12-16) 90 | 91 | - Support `Origin: null` in preflight requests (#43). 92 | - Update akka-http to 10.1.5. 93 | - Update Scala to 2.12.8 94 | 95 | ## 0.3.1 (2018-09-29) 96 | 97 | - Java 9: add `Automatic-Module-Name: ch.megard.akka.http.cors` in the `MANIFEST.MF` (#35). 98 | - Deprecate method `corsDecorate()` (#38). 99 | - Cache response headers (#39). 100 | - Update akka-http to 10.1.3. 101 | - Update Scala to 2.12.7. 102 | 103 | ## 0.3.0 (2018-03-24) 104 | 105 | This release breaks source compatibility, planning for the 1.0 release. 106 | 107 | - Settings are read from a configuration file (#13). 108 | - Directives now clean existing CORS-related headers when responding to an actual request (#28). 109 | - Support `Origin: null` in simple/actual requests (#31). 110 | - The `CorsRejection` class has been refactored to be cleaner. 111 | - Update akka-http to 10.1.0, removal of Akka 2.4 support (#34). 112 | - Update Scala to 2.12.5 and 2.11.12. 113 | - Add cross-compilation with Scala 2.13.0-M3. 114 | 115 | ### Migrate from 0.2 to 0.3 116 | 117 | - Now that it is possible, prefer overriding settings in a `application.conf` file. 118 | - To update programmatically the settings, use the new `with...()` methods instead of the `copy()` method. 119 | - Custom rejection handlers must be updated to reflect the new `CorsRejection` class. 120 | 121 | ## 0.2.2 (2017-09-25) 122 | 123 | - Update Scala to 2.12.3 and 2.11.11. 124 | - Update akka-http to 10.0.10. 125 | - Update sbt to 1.0.x major release. 126 | 127 | ## 0.2.1 (2017-04-03) 128 | 129 | - Add Java API (#8) 130 | - Update akka-http to 10.0.5. 131 | 132 | ### Migrate from 0.1 to 0.2 133 | The API remains the same, but classes have moved in new packages to accommodate the Java API. 134 | 135 | - Directives are now in `ch.megard.akka.http.cors.scaladsl`; 136 | - Models are now in `ch.megard.akka.http.cors.scaladsl.model`; 137 | - Settings are now in `ch.megard.akka.http.cors.scaladsl.settings`. 138 | 139 | ## 0.1.11 (2017-01-31) 140 | 141 | - Update Scala to 2.12.1. 142 | - Update akka-http to 10.0.3. 143 | 144 | ## 0.1.10 (2016-11-23) 145 | 146 | - Update akka-http to 10.0.0. 147 | 148 | ## 0.1.9 (2016-11-10) 149 | 150 | - Cross compile with Scala 2.12.0. 151 | - Update akka-http to 10.0.0-RC2. 152 | 153 | ## 0.1.8 (2016-10-30) 154 | 155 | - Cross compile with Scala 2.12.0-RC2. 156 | 157 | ## 0.1.7 (2016-10-02) 158 | 159 | - Cross compile with Scala 2.12.0-RC1. 160 | - Update Akka to 2.4.11. 161 | 162 | ## 0.1.6 (2016-09-10) 163 | 164 | - Update Akka to 2.4.10. 165 | 166 | ## 0.1.5 (2016-08-24) 167 | 168 | - Update Akka to 2.4.9. 169 | - Update sbt to 0.13.12. 170 | 171 | ## 0.1.4 (2016-07-08) 172 | 173 | - Update Akka to 2.4.8. 174 | 175 | ## 0.1.3 (2016-07-08) 176 | 177 | - Update Akka to 2.4.7. 178 | 179 | ## 0.1.2 (2016-05-11) 180 | 181 | - Update Akka to 2.4.4. 182 | - Add benchmarks with results in README. 183 | 184 | ## 0.1.1 (2016-04-07) 185 | 186 | - Update Akka to 2.4.3. 187 | 188 | ## 0.1.0 (2016-03-20) 189 | 190 | Initial release. 191 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "{}" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright {yyyy} {name of copyright owner} 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # akka-http-cors 2 | 3 | [![Software License](https://img.shields.io/badge/license-Apache%202-brightgreen.svg?style=flat)](LICENSE) 4 | [![Scala Steward badge](https://img.shields.io/badge/Scala_Steward-helping-blue.svg?style=flat&logo=data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAA4AAAAQCAMAAAARSr4IAAAAVFBMVEUAAACHjojlOy5NWlrKzcYRKjGFjIbp293YycuLa3pYY2LSqql4f3pCUFTgSjNodYRmcXUsPD/NTTbjRS+2jomhgnzNc223cGvZS0HaSD0XLjbaSjElhIr+AAAAAXRSTlMAQObYZgAAAHlJREFUCNdNyosOwyAIhWHAQS1Vt7a77/3fcxxdmv0xwmckutAR1nkm4ggbyEcg/wWmlGLDAA3oL50xi6fk5ffZ3E2E3QfZDCcCN2YtbEWZt+Drc6u6rlqv7Uk0LdKqqr5rk2UCRXOk0vmQKGfc94nOJyQjouF9H/wCc9gECEYfONoAAAAASUVORK5CYII=)](https://scala-steward.org) 5 | 6 | CORS (Cross Origin Resource Sharing) is a mechanism to enable cross origin requests. 7 | 8 | This is a Scala/Java implementation for the server-side targeting the [akka-http](https://github.com/akka/akka-http) library. 9 | 10 | A version supporting the [Apache Pekko](https://pekko.apache.org) fork is also available in [pekko-http-cors](https://github.com/lomigmegard/pekko-http-cors). 11 | 12 | ## Versions 13 | 14 | | Version | Release date | Akka Http version | Scala versions | 15 | |---------|--------------|-------------------|-------------------------------| 16 | | `1.2.0` | 2023-03-04 | `10.2.10` | `2.12.17`, `2.13.10`, `3.2.2` | 17 | | `1.1.3` | 2022-01-30 | `10.2.7` | `2.12.15`, `2.13.8`, `3.1.1` | 18 | | `1.0.0` | 2020-05-25 | `10.1.12` | `2.12.11`, `2.13.2` | 19 | | `0.1.0` | 2016-03-20 | `2.4.2` | `2.11.8` | 20 | 21 | Some less interesting versions are not listed in the above table. The complete list can be found in the [CHANGELOG](CHANGELOG.md) file. 22 | 23 | ## Getting Akka Http Cors 24 | akka-http-cors is deployed to Maven Central. Add it to your `build.sbt` or `Build.scala`: 25 | ```scala 26 | libraryDependencies += "ch.megard" %% "akka-http-cors" % "1.2.0" 27 | ``` 28 | 29 | ## Quick Start 30 | The simplest way to enable CORS in your application is to use the `cors` directive. 31 | Settings are passed as a parameter to the directive, with your overrides loaded from the `application.conf`. 32 | 33 | ```scala 34 | import ch.megard.akka.http.cors.scaladsl.CorsDirectives._ 35 | 36 | val route: Route = cors() { 37 | complete(...) 38 | } 39 | ``` 40 | 41 | The settings can be updated programmatically too. 42 | ```scala 43 | val settings = CorsSettings(...).withAllowGenericHttpRequests(false) 44 | val strictRoute: Route = cors(settings) { 45 | complete(...) 46 | } 47 | ``` 48 | 49 | A [full example](akka-http-cors-example/src/main/scala/ch/megard/akka/http/cors/scaladsl/CorsServer.scala), with proper exception and rejection handling, is available in the `akka-http-cors-example` sub-project. 50 | 51 | ## Rejection 52 | The CORS directives can reject requests using the `CorsRejection` class. Requests can be either malformed or not allowed to access the resource. 53 | 54 | A rejection handler is provided by the library to return meaningful HTTP responses. Read the [akka documentation](http://doc.akka.io/docs/akka-http/current/scala/http/routing-dsl/rejections.html) to learn more about rejections, or if you need to write your own handler. 55 | ```scala 56 | import akka.http.scaladsl.server.directives.ExecutionDirectives._ 57 | import ch.megard.akka.http.cors.scaladsl.CorsDirectives._ 58 | 59 | val route: Route = handleRejections(corsRejectionHandler) { 60 | cors() { 61 | complete(...) 62 | } 63 | } 64 | ``` 65 | 66 | ## Java support 67 | 68 | Starting from version `0.2.1` Java is supported, mirroring the Scala API. For usage, look at the full [Java CorsServer example](akka-http-cors-example/src/main/java/ch/megard/akka/http/cors/javadsl/CorsServer.java). 69 | 70 | ## Configuration 71 | 72 | [Reference configuration](akka-http-cors/src/main/resources/reference.conf). 73 | 74 | #### allowGenericHttpRequests 75 | `Boolean` with default value `true`. 76 | 77 | If `true`, allow generic requests (that are outside the scope of the specification) to pass through the directive. Else, strict CORS filtering is applied and any invalid request will be rejected. 78 | 79 | #### allowCredentials 80 | `Boolean` with default value `true`. 81 | 82 | Indicates whether the resource supports user credentials. If `true`, the header `Access-Control-Allow-Credentials` is set in the response, indicating the actual request can include user credentials. 83 | 84 | Examples of user credentials are: cookies, HTTP authentication or client-side certificates. 85 | 86 | #### allowedOrigins 87 | `HttpOriginMatcher` with default value `HttpOriginMatcher.*`. 88 | 89 | List of origins that the CORS filter must allow. Can also be set to `*` to allow access to the resource from any origin. Controls the content of the `Access-Control-Allow-Origin` response header: 90 | * if parameter is `*` **and** credentials are not allowed, a `*` is set in `Access-Control-Allow-Origin`. 91 | * otherwise, the origins given in the `Origin` request header are echoed. 92 | 93 | Hostname starting with `*.` will match any sub-domain. The scheme and the port are always strictly matched. 94 | 95 | The actual or preflight request is rejected if any of the origins from the request is not allowed. 96 | 97 | #### allowedHeaders 98 | `HttpHeaderRange` with default value `HttpHeaderRange.*`. 99 | 100 | List of request headers that can be used when making an actual request. Controls the content of the `Access-Control-Allow-Headers` header in a preflight response: 101 | * if parameter is `*`, the headers from `Access-Control-Request-Headers` are echoed. 102 | * otherwise the parameter list is returned as part of the header. 103 | 104 | #### allowedMethods 105 | `Seq[HttpMethod]` with default value `Seq(GET, POST, HEAD, OPTIONS)`. 106 | 107 | List of methods that can be used when making an actual request. The list is returned as part of the `Access-Control-Allow-Methods` preflight response header. 108 | 109 | The preflight request will be rejected if the `Access-Control-Request-Method` header's method is not part of the list. 110 | 111 | #### exposedHeaders 112 | `Seq[String]` with default value `Seq.empty`. 113 | 114 | List of headers (other than [simple response headers](https://www.w3.org/TR/cors/#simple-response-header)) that browsers are allowed to access. If not empty, this list is returned as part of the `Access-Control-Expose-Headers` header in the actual response. 115 | 116 | #### maxAge 117 | `Option[Long]` (in seconds) with default value `Some (30 * 60)`. 118 | 119 | When set, the amount of seconds the browser is allowed to cache the results of a preflight request. This value is returned as part of the `Access-Control-Max-Age` preflight response header. If `None`, the header is not added to the preflight response. 120 | 121 | ## Benchmarks 122 | Using the [sbt-jmh](https://github.com/ktoso/sbt-jmh) plugin, preliminary benchmarks have been performed to measure the impact of the `cors` directive on the performance. The first results are shown below. 123 | 124 | Results are not all coming from the same machine. 125 | 126 | #### v0.1.2 (Akka 2.4.4) 127 | ``` 128 | > jmh:run -i 40 -wi 30 -f2 -t1 129 | Benchmark Mode Cnt Score Error Units 130 | CorsBenchmark.baseline thrpt 80 3601.121 ± 102.274 ops/s 131 | CorsBenchmark.default_cors thrpt 80 3582.090 ± 95.304 ops/s 132 | CorsBenchmark.default_preflight thrpt 80 3482.716 ± 89.124 ops/s 133 | ``` 134 | 135 | #### v0.1.3 (Akka 2.4.7) 136 | ``` 137 | > jmh:run -i 40 -wi 30 -f2 -t1 138 | Benchmark Mode Cnt Score Error Units 139 | CorsBenchmark.baseline thrpt 80 3657.762 ± 141.409 ops/s 140 | CorsBenchmark.default_cors thrpt 80 3687.351 ± 35.176 ops/s 141 | CorsBenchmark.default_preflight thrpt 80 3645.629 ± 30.411 ops/s 142 | ``` 143 | 144 | #### v0.2.2 (Akka HTTP 10.0.6) 145 | ``` 146 | > jmh:run -i 40 -wi 30 -f2 -t1 147 | Benchmark Mode Cnt Score Error Units 148 | CorsBenchmark.baseline thrpt 80 9730.001 ± 25.281 ops/s 149 | CorsBenchmark.default_cors thrpt 80 9159.320 ± 25.459 ops/s 150 | CorsBenchmark.default_preflight thrpt 80 9172.938 ± 26.794 ops/s 151 | ``` 152 | 153 | ## References 154 | - [W3C Specification: CORS](https://www.w3.org/TR/cors/) 155 | - [RFC-6454: The Web Origin Concept](https://tools.ietf.org/html/rfc6454) 156 | 157 | ## License 158 | This code is open source software licensed under the [Apache 2.0 License](https://www.apache.org/licenses/LICENSE-2.0.html). 159 | -------------------------------------------------------------------------------- /RELEASE.md: -------------------------------------------------------------------------------- 1 | # Release 2 | 3 | Build the release with Java 8. For macOs: 4 | 5 | ```bash 6 | brew tap adoptopenjdk/openjdk 7 | brew cask install adoptopenjdk8 8 | sbt -v --java-home /Library/Java/JavaVirtualMachines/adoptopenjdk-8.jdk/Contents/Home 9 | > + test 10 | > project akka-http-cors 11 | > + publishSigned 12 | > sonatypeBundleRelease 13 | ``` 14 | -------------------------------------------------------------------------------- /akka-http-cors-bench-jmh/src/main/scala/ch/megard/akka/http/cors/CorsBenchmark.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2016 Lomig Mégard 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package ch.megard.akka.http.cors 18 | 19 | import java.util.concurrent.TimeUnit 20 | 21 | import akka.actor.ActorSystem 22 | import akka.http.scaladsl.Http 23 | import akka.http.scaladsl.Http.ServerBinding 24 | import akka.http.scaladsl.model.headers.{Origin, `Access-Control-Request-Method`} 25 | import akka.http.scaladsl.model.{HttpMethods, HttpRequest} 26 | import akka.http.scaladsl.server.Directives 27 | import akka.http.scaladsl.unmarshalling.Unmarshal 28 | import ch.megard.akka.http.cors.scaladsl.CorsDirectives 29 | import ch.megard.akka.http.cors.scaladsl.settings.CorsSettings 30 | import com.typesafe.config.ConfigFactory 31 | import org.openjdk.jmh.annotations._ 32 | 33 | import scala.concurrent.duration._ 34 | import scala.concurrent.{Await, ExecutionContext} 35 | 36 | @State(Scope.Benchmark) 37 | @OutputTimeUnit(TimeUnit.SECONDS) 38 | @BenchmarkMode(Array(Mode.Throughput)) 39 | class CorsBenchmark extends Directives with CorsDirectives { 40 | private val config = ConfigFactory.parseString("akka.loglevel = ERROR").withFallback(ConfigFactory.load()) 41 | 42 | implicit private val system: ActorSystem = ActorSystem("CorsBenchmark", config) 43 | implicit private val ec: ExecutionContext = scala.concurrent.ExecutionContext.global 44 | 45 | private val http = Http() 46 | private val corsSettings = CorsSettings.default 47 | 48 | private var binding: ServerBinding = _ 49 | private var request: HttpRequest = _ 50 | private var requestCors: HttpRequest = _ 51 | private var requestPreflight: HttpRequest = _ 52 | 53 | @Setup 54 | def setup(): Unit = { 55 | val route = { 56 | path("baseline") { 57 | get { 58 | complete("ok") 59 | } 60 | } ~ path("cors") { 61 | cors(corsSettings) { 62 | get { 63 | complete("ok") 64 | } 65 | } 66 | } 67 | } 68 | val origin = Origin("http://example.com") 69 | 70 | binding = Await.result(http.newServerAt("127.0.0.1", 0).bind(route), 1.second) 71 | val base = s"http://${binding.localAddress.getHostString}:${binding.localAddress.getPort}" 72 | 73 | request = HttpRequest(uri = base + "/baseline") 74 | requestCors = HttpRequest( 75 | method = HttpMethods.GET, 76 | uri = base + "/cors", 77 | headers = List(origin) 78 | ) 79 | requestPreflight = HttpRequest( 80 | method = HttpMethods.OPTIONS, 81 | uri = base + "/cors", 82 | headers = List(origin, `Access-Control-Request-Method`(HttpMethods.GET)) 83 | ) 84 | } 85 | 86 | @TearDown 87 | def shutdown(): Unit = { 88 | val f = for { 89 | _ <- http.shutdownAllConnectionPools() 90 | _ <- binding.terminate(1.second) 91 | _ <- system.terminate() 92 | } yield () 93 | Await.ready(f, 5.seconds) 94 | } 95 | 96 | @Benchmark 97 | def baseline(): Unit = { 98 | val f = http.singleRequest(request).flatMap(r => Unmarshal(r.entity).to[String]) 99 | assert(Await.result(f, 1.second) == "ok") 100 | } 101 | 102 | @Benchmark 103 | def default_cors(): Unit = { 104 | val f = http.singleRequest(requestCors).flatMap(r => Unmarshal(r.entity).to[String]) 105 | assert(Await.result(f, 1.second) == "ok") 106 | } 107 | 108 | @Benchmark 109 | def default_preflight(): Unit = { 110 | val f = http.singleRequest(requestPreflight).flatMap(r => Unmarshal(r.entity).to[String]) 111 | assert(Await.result(f, 1.second) == "") 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /akka-http-cors-example/src/main/java/ch/megard/akka/http/cors/javadsl/CorsServer.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2016 Lomig Mégard 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package ch.megard.akka.http.cors.javadsl; 18 | 19 | 20 | import akka.actor.typed.ActorSystem; 21 | import akka.actor.typed.javadsl.Behaviors; 22 | import akka.http.javadsl.Http; 23 | import akka.http.javadsl.ServerBinding; 24 | import akka.http.javadsl.model.StatusCodes; 25 | import akka.http.javadsl.server.ExceptionHandler; 26 | import akka.http.javadsl.server.RejectionHandler; 27 | import akka.http.javadsl.server.Route; 28 | 29 | import java.util.NoSuchElementException; 30 | import java.util.concurrent.CompletionStage; 31 | import java.util.function.Function; 32 | import java.util.function.Supplier; 33 | 34 | import static akka.http.javadsl.server.Directives.*; 35 | import static ch.megard.akka.http.cors.javadsl.CorsDirectives.cors; 36 | import static ch.megard.akka.http.cors.javadsl.CorsDirectives.corsRejectionHandler; 37 | 38 | /** 39 | * Example of a Java HTTP server using the CORS directive. 40 | */ 41 | public class CorsServer { 42 | 43 | public static void main(String[] args) throws Exception { 44 | final ActorSystem system = ActorSystem.create(Behaviors.empty(), "cors-server"); 45 | 46 | final CorsServer app = new CorsServer(); 47 | 48 | final CompletionStage futureBinding = 49 | Http.get(system).newServerAt("localhost", 8080).bind(app.createRoute()); 50 | 51 | futureBinding.whenComplete((binding, exception) -> { 52 | if (binding != null) { 53 | system.log().info("Server online at http://localhost:8080/\nPress RETURN to stop..."); 54 | } else { 55 | system.log().error("Failed to bind HTTP endpoint, terminating system", exception); 56 | system.terminate(); 57 | } 58 | }); 59 | 60 | System.in.read(); // let it run until user presses return 61 | futureBinding 62 | .thenCompose(ServerBinding::unbind) 63 | .thenAccept(unbound -> system.terminate()); 64 | } 65 | 66 | private Route createRoute() { 67 | 68 | // Your CORS settings are loaded from `application.conf` 69 | 70 | // Your rejection handler 71 | final RejectionHandler rejectionHandler = corsRejectionHandler().withFallback(RejectionHandler.defaultHandler()); 72 | 73 | // Your exception handler 74 | final ExceptionHandler exceptionHandler = ExceptionHandler.newBuilder() 75 | .match(NoSuchElementException.class, ex -> complete(StatusCodes.NOT_FOUND, ex.getMessage())) 76 | .build(); 77 | 78 | // Combining the two handlers only for convenience 79 | final Function, Route> handleErrors = inner -> allOf( 80 | s -> handleExceptions(exceptionHandler, s), 81 | s -> handleRejections(rejectionHandler, s), 82 | inner 83 | ); 84 | 85 | // Note how rejections and exceptions are handled *before* the CORS directive (in the inner route). 86 | // This is required to have the correct CORS headers in the response even when an error occurs. 87 | return handleErrors.apply(() -> cors(() -> handleErrors.apply(() -> concat( 88 | path("ping", () -> complete("pong")), 89 | path("pong", () -> failWith(new NoSuchElementException("pong not found, try with ping"))) 90 | )))); 91 | } 92 | 93 | } 94 | -------------------------------------------------------------------------------- /akka-http-cors-example/src/main/resources/application.conf: -------------------------------------------------------------------------------- 1 | akka-http-cors { 2 | 3 | allowed-origins = "http://example.com" 4 | 5 | } 6 | -------------------------------------------------------------------------------- /akka-http-cors-example/src/main/scala/ch/megard/akka/http/cors/scaladsl/CorsServer.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2016 Lomig Mégard 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package ch.megard.akka.http.cors.scaladsl 18 | 19 | import akka.actor.typed.ActorSystem 20 | import akka.actor.typed.scaladsl.Behaviors 21 | import akka.http.scaladsl.Http 22 | import akka.http.scaladsl.model.StatusCodes 23 | import akka.http.scaladsl.server.Directives._ 24 | import akka.http.scaladsl.server._ 25 | 26 | import scala.io.StdIn 27 | import scala.util.{Failure, Success} 28 | 29 | /** Example of a Scala HTTP server using the CORS directive. 30 | */ 31 | object CorsServer { 32 | def main(args: Array[String]): Unit = { 33 | implicit val system: ActorSystem[Nothing] = ActorSystem(Behaviors.empty, "cors-server") 34 | import system.executionContext 35 | 36 | val futureBinding = Http().newServerAt("localhost", 8080).bind(route) 37 | 38 | futureBinding.onComplete { 39 | case Success(_) => 40 | system.log.info("Server online at http://localhost:8080/\nPress RETURN to stop...") 41 | case Failure(exception) => 42 | system.log.error("Failed to bind HTTP endpoint, terminating system", exception) 43 | system.terminate() 44 | } 45 | 46 | StdIn.readLine() // let it run until user presses return 47 | futureBinding 48 | .flatMap(_.unbind()) 49 | .onComplete(_ => system.terminate()) 50 | } 51 | 52 | private def route: Route = { 53 | import CorsDirectives._ 54 | 55 | // Your CORS settings are loaded from `application.conf` 56 | 57 | // Your rejection handler 58 | val rejectionHandler = corsRejectionHandler.withFallback(RejectionHandler.default) 59 | 60 | // Your exception handler 61 | val exceptionHandler = ExceptionHandler { case e: NoSuchElementException => 62 | complete(StatusCodes.NotFound -> e.getMessage) 63 | } 64 | 65 | // Combining the two handlers only for convenience 66 | val handleErrors = handleRejections(rejectionHandler) & handleExceptions(exceptionHandler) 67 | 68 | // Note how rejections and exceptions are handled *before* the CORS directive (in the inner route). 69 | // This is required to have the correct CORS headers in the response even when an error occurs. 70 | // format: off 71 | handleErrors { 72 | cors() { 73 | handleErrors { 74 | path("ping") { 75 | complete("pong") 76 | } ~ 77 | path("pong") { 78 | failWith(new NoSuchElementException("pong not found, try with ping")) 79 | } 80 | } 81 | } 82 | } 83 | // format: on 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /akka-http-cors/src/main/java/ch/megard/akka/http/cors/javadsl/model/HttpHeaderRange.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2016 Lomig Mégard 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package ch.megard.akka.http.cors.javadsl.model; 18 | 19 | import akka.http.impl.util.Util; 20 | import ch.megard.akka.http.cors.scaladsl.model.HttpHeaderRange$; 21 | 22 | 23 | /** 24 | * @see HttpHeaderRanges for convenience access to often used values. 25 | */ 26 | public abstract class HttpHeaderRange { 27 | public abstract boolean matches(String header); 28 | 29 | public static HttpHeaderRange create(String... headers) { 30 | return HttpHeaderRange$.MODULE$.apply(Util.convertArray(headers)); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /akka-http-cors/src/main/java/ch/megard/akka/http/cors/javadsl/model/HttpHeaderRanges.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2016 Lomig Mégard 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package ch.megard.akka.http.cors.javadsl.model; 18 | 19 | 20 | public final class HttpHeaderRanges { 21 | private HttpHeaderRanges() { } 22 | 23 | public static final HttpHeaderRange ALL = ch.megard.akka.http.cors.scaladsl.model.HttpHeaderRange.$times$.MODULE$; 24 | } 25 | -------------------------------------------------------------------------------- /akka-http-cors/src/main/java/ch/megard/akka/http/cors/javadsl/model/HttpOriginMatcher.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2016 Lomig Mégard 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package ch.megard.akka.http.cors.javadsl.model; 18 | 19 | import akka.http.impl.util.Util; 20 | import akka.http.javadsl.model.headers.HttpOrigin; 21 | import ch.megard.akka.http.cors.scaladsl.model.HttpOriginMatcher$; 22 | 23 | public abstract class HttpOriginMatcher { 24 | 25 | public abstract boolean matches(HttpOrigin origin); 26 | 27 | public static HttpOriginMatcher ALL = ch.megard.akka.http.cors.scaladsl.model.HttpOriginMatcher.$times$.MODULE$; 28 | 29 | public static HttpOriginMatcher create(HttpOrigin... origins) { 30 | return HttpOriginMatcher$.MODULE$.apply(Util.convertArray(origins)); 31 | } 32 | 33 | public static HttpOriginMatcher strict(HttpOrigin... origins) { 34 | return HttpOriginMatcher$.MODULE$.strict(Util.convertArray(origins)); 35 | } 36 | 37 | } 38 | -------------------------------------------------------------------------------- /akka-http-cors/src/main/resources/reference.conf: -------------------------------------------------------------------------------- 1 | ######################################## 2 | # akka-http-cors Reference Config File # 3 | ######################################## 4 | 5 | # This is the reference config file that contains all the default settings. 6 | # Make your edits/overrides in your application.conf. 7 | 8 | akka-http-cors { 9 | 10 | # If enabled, allow generic requests (that are outside the scope of the specification) 11 | # to pass through the directive. Else, strict CORS filtering is applied and any 12 | # invalid request will be rejected. 13 | allow-generic-http-requests = yes 14 | 15 | # Indicates whether the resource supports user credentials. If enabled, the header 16 | # `Access-Control-Allow-Credentials` is set in the response, indicating that the 17 | # actual request can include user credentials. Examples of user credentials are: 18 | # cookies, HTTP authentication or client-side certificates. 19 | allow-credentials = yes 20 | 21 | # List of origins that the CORS filter must allow. Can also be set to `*` to allow 22 | # access to the resource from any origin. Controls the content of the 23 | # `Access-Control-Allow-Origin` response header: if parameter is `*` and credentials 24 | # are not allowed, a `*` is set in `Access-Control-Allow-Origin`. Otherwise, the 25 | # origins given in the `Origin` request header are echoed. 26 | # 27 | # Hostname starting with `*.` will match any sub-domain. 28 | # The scheme and the port are always strictly matched. 29 | # 30 | # The actual or preflight request is rejected if any of the origins from the request 31 | # is not allowed. 32 | allowed-origins = "*" 33 | 34 | # List of request headers that can be used when making an actual request. Controls 35 | # the content of the `Access-Control-Allow-Headers` header in a preflight response: 36 | # if parameter is `*`, the headers from `Access-Control-Request-Headers` are echoed. 37 | # Otherwise the parameter list is returned as part of the header. 38 | allowed-headers = "*" 39 | 40 | # List of methods that can be used when making an actual request. The list is 41 | # returned as part of the `Access-Control-Allow-Methods` preflight response header. 42 | # 43 | # The preflight request will be rejected if the `Access-Control-Request-Method` 44 | # header's method is not part of the list. 45 | allowed-methods = ["GET", "POST", "HEAD", "OPTIONS"] 46 | 47 | # List of headers (other than simple response headers) that browsers are allowed to access. 48 | # If not empty, this list is returned as part of the `Access-Control-Expose-Headers` 49 | # header in the actual response. 50 | exposed-headers = [] 51 | 52 | # When set, the amount of seconds the browser is allowed to cache the results of a preflight request. 53 | # This value is returned as part of the `Access-Control-Max-Age` preflight response header. 54 | # If `null`, the header is not added to the preflight response. 55 | max-age = 1800 seconds 56 | } 57 | -------------------------------------------------------------------------------- /akka-http-cors/src/main/scala/ch/megard/akka/http/cors/javadsl/CorsDirectives.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2016 Lomig Mégard 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package ch.megard.akka.http.cors.javadsl 18 | 19 | import java.util.function.Supplier 20 | 21 | import akka.http.javadsl.server.{RejectionHandler, Route} 22 | import akka.http.javadsl.server.directives.RouteAdapter 23 | import ch.megard.akka.http.cors.javadsl.settings.CorsSettings 24 | import ch.megard.akka.http.cors.scaladsl 25 | 26 | object CorsDirectives { 27 | 28 | def cors(inner: Supplier[Route]): Route = 29 | RouteAdapter { 30 | scaladsl.CorsDirectives.cors() { 31 | inner.get() match { 32 | case ra: RouteAdapter => ra.delegate 33 | } 34 | } 35 | } 36 | 37 | def cors(settings: CorsSettings, inner: Supplier[Route]): Route = 38 | RouteAdapter { 39 | // Currently the easiest way to go from Java models to their Scala equivalent is to cast. 40 | // See https://github.com/akka/akka-http/issues/661 for a potential opening of the JavaMapping API. 41 | val scalaSettings = settings.asInstanceOf[scaladsl.settings.CorsSettings] 42 | scaladsl.CorsDirectives.cors(scalaSettings) { 43 | inner.get() match { 44 | case ra: RouteAdapter => ra.delegate 45 | } 46 | } 47 | } 48 | 49 | def corsRejectionHandler: RejectionHandler = 50 | new RejectionHandler(scaladsl.CorsDirectives.corsRejectionHandler) 51 | } 52 | -------------------------------------------------------------------------------- /akka-http-cors/src/main/scala/ch/megard/akka/http/cors/javadsl/CorsRejection.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2016 Lomig Mégard 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package ch.megard.akka.http.cors.javadsl 18 | 19 | import akka.http.javadsl.model.HttpMethod 20 | import akka.http.javadsl.model.headers.HttpOrigin 21 | import akka.http.javadsl.server.CustomRejection 22 | 23 | /** Rejection created by the CORS directives. Signal the CORS request was rejected. The reason of the rejection is 24 | * specified in the cause. 25 | */ 26 | trait CorsRejection extends CustomRejection { 27 | def cause: CorsRejection.Cause 28 | } 29 | 30 | object CorsRejection { 31 | 32 | /** Signals the cause of the failed CORS request. 33 | */ 34 | trait Cause { 35 | 36 | /** Description of this Cause in a human-readable format. Can be used for debugging or custom Rejection handlers. 37 | */ 38 | def description: String 39 | } 40 | 41 | /** Signals the CORS request was malformed. 42 | */ 43 | trait Malformed extends Cause 44 | 45 | /** Signals the CORS request was rejected because its origin was invalid. An empty list means the Origin header was 46 | * `null`. 47 | */ 48 | trait InvalidOrigin extends Cause { 49 | def getOrigins: java.util.List[HttpOrigin] 50 | } 51 | 52 | /** Signals the CORS request was rejected because its method was invalid. 53 | */ 54 | trait InvalidMethod extends Cause { 55 | def getMethod: HttpMethod 56 | } 57 | 58 | /** Signals the CORS request was rejected because its headers were invalid. 59 | */ 60 | trait InvalidHeaders extends Cause { 61 | def getHeaders: java.util.List[String] 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /akka-http-cors/src/main/scala/ch/megard/akka/http/cors/javadsl/settings/CorsSettings.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2016 Lomig Mégard 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package ch.megard.akka.http.cors.javadsl.settings 18 | 19 | import java.util.Optional 20 | 21 | import akka.actor.ActorSystem 22 | import akka.annotation.DoNotInherit 23 | import akka.http.javadsl.model.HttpMethod 24 | import ch.megard.akka.http.cors.javadsl.model.{HttpHeaderRange, HttpOriginMatcher} 25 | import ch.megard.akka.http.cors.scaladsl 26 | import ch.megard.akka.http.cors.scaladsl.settings.CorsSettingsImpl 27 | import com.typesafe.config.Config 28 | 29 | /** Public API but not intended for subclassing 30 | */ 31 | @DoNotInherit 32 | abstract class CorsSettings { self: CorsSettingsImpl => 33 | 34 | def getAllowGenericHttpRequests: Boolean 35 | def getAllowCredentials: Boolean 36 | def getAllowedOrigins: HttpOriginMatcher 37 | def getAllowedHeaders: HttpHeaderRange 38 | def getAllowedMethods: java.lang.Iterable[HttpMethod] 39 | def getExposedHeaders: java.lang.Iterable[String] 40 | def getMaxAge: Optional[Long] 41 | 42 | def withAllowGenericHttpRequests(newValue: Boolean): CorsSettings 43 | def withAllowCredentials(newValue: Boolean): CorsSettings 44 | def withAllowedOrigins(newValue: HttpOriginMatcher): CorsSettings 45 | def withAllowedHeaders(newValue: HttpHeaderRange): CorsSettings 46 | def withAllowedMethods(newValue: java.lang.Iterable[HttpMethod]): CorsSettings 47 | def withExposedHeaders(newValue: java.lang.Iterable[String]): CorsSettings 48 | def withMaxAge(newValue: Optional[Long]): CorsSettings 49 | } 50 | 51 | object CorsSettings { 52 | 53 | /** Creates an instance of settings using the given Config. 54 | */ 55 | def create(config: Config): CorsSettings = scaladsl.settings.CorsSettings(config) 56 | 57 | /** Creates an instance of settings using the given String of config overrides to override settings set in the class 58 | * loader of this class (i.e. by application.conf or reference.conf files in the class loader of this class). 59 | */ 60 | def create(configOverrides: String): CorsSettings = scaladsl.settings.CorsSettings(configOverrides) 61 | 62 | /** Creates an instance of CorsSettings using the configuration provided by the given ActorSystem. 63 | */ 64 | def create(system: ActorSystem): CorsSettings = scaladsl.settings.CorsSettings(system) 65 | 66 | /** Settings from the default loaded configuration. Note that application code may want to use the `apply()` methods 67 | * instead to have more control over the source of the configuration. 68 | */ 69 | def defaultSettings: CorsSettings = scaladsl.settings.CorsSettings.defaultSettings 70 | } 71 | -------------------------------------------------------------------------------- /akka-http-cors/src/main/scala/ch/megard/akka/http/cors/scaladsl/CorsDirectives.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2016 Lomig Mégard 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package ch.megard.akka.http.cors.scaladsl 18 | 19 | import akka.http.scaladsl.model.HttpMethods._ 20 | import akka.http.scaladsl.model.headers._ 21 | import akka.http.scaladsl.model.{HttpMethod, HttpResponse, StatusCodes} 22 | import akka.http.scaladsl.server._ 23 | import akka.http.scaladsl.server.directives._ 24 | import ch.megard.akka.http.cors.javadsl 25 | import ch.megard.akka.http.cors.scaladsl.model.HttpOriginMatcher 26 | import ch.megard.akka.http.cors.scaladsl.settings.CorsSettings 27 | 28 | import scala.collection.immutable.Seq 29 | 30 | /** Provides directives that implement the CORS mechanism, enabling cross origin requests. 31 | * 32 | * @see 33 | * [[https://www.w3.org/TR/cors/ CORS W3C Recommendation]] 34 | * @see 35 | * [[https://www.ietf.org/rfc/rfc6454.txt RFC 6454]] 36 | */ 37 | trait CorsDirectives { 38 | import BasicDirectives._ 39 | import RouteDirectives._ 40 | 41 | /** Wraps its inner route with support for the CORS mechanism, enabling cross origin requests. 42 | * 43 | * In particular the recommendation written by the W3C in https://www.w3.org/TR/cors/ is implemented by this 44 | * directive. 45 | * 46 | * The settings are loaded from the Actor System configuration. 47 | */ 48 | def cors(): Directive0 = { 49 | extractActorSystem.flatMap { system => 50 | cors(CorsSettings(system)) 51 | } 52 | } 53 | 54 | /** Wraps its inner route with support for the CORS mechanism, enabling cross origin requests. 55 | * 56 | * In particular the recommendation written by the W3C in https://www.w3.org/TR/cors/ is implemented by this 57 | * directive. 58 | * 59 | * @param settings 60 | * the settings used by the CORS filter 61 | */ 62 | def cors(settings: CorsSettings): Directive0 = { 63 | import settings._ 64 | 65 | /** Return the invalid origins, or `Nil` if one is valid. */ 66 | def validateOrigins(origins: Seq[HttpOrigin]): List[CorsRejection.Cause] = 67 | if (allowedOrigins == HttpOriginMatcher.* || origins.exists(allowedOrigins.matches)) { 68 | Nil 69 | } else { 70 | CorsRejection.InvalidOrigin(origins) :: Nil 71 | } 72 | 73 | /** Return the method if invalid, `Nil` otherwise. */ 74 | def validateMethod(method: HttpMethod): List[CorsRejection.Cause] = 75 | if (allowedMethods.contains(method)) { 76 | Nil 77 | } else { 78 | CorsRejection.InvalidMethod(method) :: Nil 79 | } 80 | 81 | /** Return the list of invalid headers, or `Nil` if they are all valid. */ 82 | def validateHeaders(headers: Seq[String]): List[CorsRejection.Cause] = { 83 | val invalidHeaders = headers.filterNot(allowedHeaders.matches) 84 | if (invalidHeaders.isEmpty) { 85 | Nil 86 | } else { 87 | CorsRejection.InvalidHeaders(invalidHeaders) :: Nil 88 | } 89 | } 90 | 91 | extractRequest.flatMap { request => 92 | import request._ 93 | 94 | (method, header[Origin].map(_.origins.toSeq), header[`Access-Control-Request-Method`].map(_.method)) match { 95 | case (OPTIONS, Some(origins), Some(requestMethod)) if origins.lengthCompare(1) <= 0 => 96 | // Case 1: pre-flight CORS request 97 | 98 | val headers = header[`Access-Control-Request-Headers`].map(_.headers.toSeq).getOrElse(Seq.empty) 99 | 100 | validateOrigins(origins) ::: validateMethod(requestMethod) ::: validateHeaders(headers) match { 101 | case Nil => complete(HttpResponse(StatusCodes.OK, preflightResponseHeaders(origins, headers))) 102 | case causes => reject(causes.map(CorsRejection(_)): _*) 103 | } 104 | 105 | case (_, Some(origins), None) => 106 | // Case 2: simple/actual CORS request 107 | 108 | validateOrigins(origins) match { 109 | case Nil => 110 | mapResponseHeaders { oldHeaders => 111 | actualResponseHeaders(origins) ++ oldHeaders.filterNot(h => CorsDirectives.headersToClean.exists(h.is)) 112 | } 113 | case causes => 114 | reject(causes.map(CorsRejection(_)): _*) 115 | } 116 | 117 | case _ if allowGenericHttpRequests => 118 | // Case 3a: not a valid CORS request, but allowed 119 | 120 | pass 121 | 122 | case _ => 123 | // Case 3b: not a valid CORS request, forbidden 124 | 125 | reject(CorsRejection(CorsRejection.Malformed)) 126 | } 127 | } 128 | } 129 | } 130 | 131 | object CorsDirectives extends CorsDirectives { 132 | import RouteDirectives._ 133 | 134 | private val headersToClean: List[String] = List( 135 | `Access-Control-Allow-Origin`, 136 | `Access-Control-Expose-Headers`, 137 | `Access-Control-Allow-Credentials`, 138 | `Access-Control-Allow-Methods`, 139 | `Access-Control-Allow-Headers`, 140 | `Access-Control-Max-Age` 141 | ).map(_.lowercaseName) 142 | 143 | def corsRejectionHandler: RejectionHandler = 144 | RejectionHandler 145 | .newBuilder() 146 | .handleAll[javadsl.CorsRejection] { rejections => 147 | val causes = rejections.map(_.cause.description).mkString(", ") 148 | complete((StatusCodes.BadRequest, s"CORS: $causes")) 149 | } 150 | .result() 151 | } 152 | -------------------------------------------------------------------------------- /akka-http-cors/src/main/scala/ch/megard/akka/http/cors/scaladsl/CorsRejection.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2016 Lomig Mégard 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package ch.megard.akka.http.cors.scaladsl 18 | 19 | import akka.http.scaladsl.model.HttpMethod 20 | import akka.http.scaladsl.model.headers.HttpOrigin 21 | import akka.http.scaladsl.server.Rejection 22 | import ch.megard.akka.http.cors.javadsl 23 | 24 | import scala.collection.JavaConverters._ 25 | import scala.collection.immutable.Seq 26 | 27 | /** Rejection created by the CORS directives. Signal the CORS request was rejected. The reason of the rejection is 28 | * specified in the cause. 29 | */ 30 | final case class CorsRejection(cause: CorsRejection.Cause) extends javadsl.CorsRejection with Rejection 31 | 32 | object CorsRejection { 33 | 34 | /** Signals the cause of the failed CORS request. 35 | */ 36 | sealed trait Cause extends javadsl.CorsRejection.Cause 37 | 38 | /** Signals the CORS request was malformed. 39 | */ 40 | case object Malformed extends javadsl.CorsRejection.Malformed with Cause { 41 | override def description: String = "malformed request" 42 | } 43 | 44 | /** Signals the CORS request was rejected because its origin was invalid. An empty list means the Origin header was 45 | * `null`. 46 | */ 47 | final case class InvalidOrigin(origins: Seq[HttpOrigin]) extends javadsl.CorsRejection.InvalidOrigin with Cause { 48 | override def description: String = s"invalid origin '${if (origins.isEmpty) "null" else origins.mkString(" ")}'" 49 | override def getOrigins = (origins: Seq[akka.http.javadsl.model.headers.HttpOrigin]).asJava 50 | } 51 | 52 | /** Signals the CORS request was rejected because its method was invalid. 53 | */ 54 | final case class InvalidMethod(method: HttpMethod) extends javadsl.CorsRejection.InvalidMethod with Cause { 55 | override def description: String = s"invalid method '${method.value}'" 56 | override def getMethod = method 57 | } 58 | 59 | /** Signals the CORS request was rejected because its headers were invalid. 60 | */ 61 | final case class InvalidHeaders(headers: Seq[String]) extends javadsl.CorsRejection.InvalidHeaders with Cause { 62 | override def description: String = s"invalid headers '${headers.mkString(" ")}'" 63 | override def getHeaders = headers.asJava 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /akka-http-cors/src/main/scala/ch/megard/akka/http/cors/scaladsl/model/HttpHeaderRange.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2016 Lomig Mégard 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package ch.megard.akka.http.cors.scaladsl.model 18 | 19 | import java.util.Locale 20 | 21 | import ch.megard.akka.http.cors.javadsl 22 | 23 | import scala.collection.immutable.Seq 24 | 25 | abstract class HttpHeaderRange extends javadsl.model.HttpHeaderRange 26 | 27 | object HttpHeaderRange { 28 | case object `*` extends HttpHeaderRange { 29 | def matches(header: String) = true 30 | } 31 | 32 | final case class Default(headers: Seq[String]) extends HttpHeaderRange { 33 | val lowercaseHeaders: Seq[String] = headers.map(_.toLowerCase(Locale.ROOT)) 34 | def matches(header: String): Boolean = lowercaseHeaders contains header.toLowerCase(Locale.ROOT) 35 | } 36 | 37 | def apply(headers: String*): Default = Default(Seq(headers: _*)) 38 | } 39 | -------------------------------------------------------------------------------- /akka-http-cors/src/main/scala/ch/megard/akka/http/cors/scaladsl/model/HttpOriginMatcher.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2016 Lomig Mégard 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package ch.megard.akka.http.cors.scaladsl.model 18 | 19 | import akka.http.javadsl.{model => jm} 20 | import akka.http.scaladsl.model.headers.HttpOrigin 21 | import ch.megard.akka.http.cors.javadsl 22 | 23 | import scala.collection.immutable.Seq 24 | 25 | /** HttpOrigin matcher. 26 | */ 27 | abstract class HttpOriginMatcher extends javadsl.model.HttpOriginMatcher { 28 | def matches(origin: HttpOrigin): Boolean 29 | 30 | /** Java API */ 31 | def matches(origin: jm.headers.HttpOrigin): Boolean = matches(origin.asInstanceOf[HttpOrigin]) 32 | } 33 | 34 | object HttpOriginMatcher { 35 | case object `*` extends HttpOriginMatcher { 36 | def matches(origin: HttpOrigin) = true 37 | } 38 | 39 | final case class Default(origins: Seq[HttpOrigin]) extends HttpOriginMatcher { 40 | private class StrictHostMatcher(origin: HttpOrigin) extends (HttpOrigin => Boolean) { 41 | override def apply(origin: HttpOrigin): Boolean = origin == this.origin 42 | } 43 | 44 | private class WildcardHostMatcher(wildcardOrigin: HttpOrigin) extends (HttpOrigin => Boolean) { 45 | private val suffix: String = wildcardOrigin.host.host.address.stripPrefix("*") 46 | override def apply(origin: HttpOrigin): Boolean = { 47 | origin.scheme == wildcardOrigin.scheme && 48 | origin.host.port == wildcardOrigin.host.port && 49 | origin.host.host.address.endsWith(suffix) 50 | } 51 | } 52 | 53 | private val matchers: Seq[HttpOrigin => Boolean] = { 54 | origins.map { origin => 55 | if (hasWildcard(origin)) new WildcardHostMatcher(origin) else new StrictHostMatcher(origin) 56 | } 57 | } 58 | 59 | override def matches(origin: HttpOrigin): Boolean = matchers.exists(_.apply(origin)) 60 | override def toString: String = origins.mkString(" ") 61 | } 62 | 63 | final case class Strict(origins: Seq[HttpOrigin]) extends HttpOriginMatcher { 64 | override def matches(origin: HttpOrigin): Boolean = origins contains origin 65 | override def toString: String = origins.mkString(" ") 66 | } 67 | 68 | private def hasWildcard(origin: HttpOrigin): Boolean = 69 | origin.host.host.isNamedHost && origin.host.host.address.startsWith("*.") 70 | 71 | /** Build a matcher that will accept any of the given origins. Wildcard in the hostname will not be interpreted. 72 | */ 73 | def strict(origins: HttpOrigin*): HttpOriginMatcher = 74 | Strict(origins.toList) 75 | 76 | /** Build a matcher that will accept any of the given origins. Hostname starting with `*.` will match any sub-domain. 77 | * The scheme and the port are always strictly matched. 78 | */ 79 | def apply(origins: HttpOrigin*): HttpOriginMatcher = { 80 | if (origins.exists(hasWildcard)) 81 | Default(origins.toList) 82 | else 83 | Strict(origins.toList) 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /akka-http-cors/src/main/scala/ch/megard/akka/http/cors/scaladsl/settings/CorsSettings.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2016 Lomig Mégard 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package ch.megard.akka.http.cors.scaladsl.settings 18 | 19 | import java.util.Optional 20 | import java.util.concurrent.TimeUnit 21 | 22 | import akka.actor.ActorSystem 23 | import akka.annotation.DoNotInherit 24 | import akka.http.scaladsl.model.headers.HttpOrigin 25 | import akka.http.scaladsl.model.{HttpHeader, HttpMethod, HttpMethods} 26 | import ch.megard.akka.http.cors.javadsl 27 | import ch.megard.akka.http.cors.scaladsl.model.{HttpHeaderRange, HttpOriginMatcher} 28 | import com.typesafe.config.ConfigException.{Missing, WrongType} 29 | import com.typesafe.config.{Config, ConfigFactory} 30 | 31 | import scala.collection.JavaConverters._ 32 | import scala.collection.immutable.{ListMap, Seq} 33 | import scala.compat.java8.OptionConverters 34 | import scala.util.Try 35 | 36 | /** Settings used by the CORS directives. 37 | * 38 | * Public API but not intended for subclassing. 39 | */ 40 | @DoNotInherit 41 | abstract class CorsSettings private[akka] () extends javadsl.settings.CorsSettings { self: CorsSettingsImpl => 42 | 43 | /** If `true`, allow generic requests (that are outside the scope of the specification) to pass through the directive. 44 | * Else, strict CORS filtering is applied and any invalid request will be rejected. 45 | * 46 | * Default: `true` 47 | */ 48 | def allowGenericHttpRequests: Boolean 49 | 50 | /** Indicates whether the resource supports user credentials. If `true`, the header `Access-Control-Allow-Credentials` 51 | * is set in the response, indicating that the actual request can include user credentials. Examples of user 52 | * credentials are: cookies, HTTP authentication or client-side certificates. 53 | * 54 | * Default: `true` 55 | * 56 | * @see 57 | * [[https://www.w3.org/TR/cors/#access-control-allow-credentials-response-header Access-Control-Allow-Credentials]] 58 | */ 59 | def allowCredentials: Boolean 60 | 61 | /** List of origins that the CORS filter must allow. Can also be set to `*` to allow access to the resource from any 62 | * origin. Controls the content of the `Access-Control-Allow-Origin` response header: if parameter is `*` and 63 | * credentials are not allowed, a `*` is set in `Access-Control-Allow-Origin`. Otherwise, the origins given in the 64 | * `Origin` request header are echoed. 65 | * 66 | * Hostname starting with `*.` will match any sub-domain. The scheme and the port are always strictly matched. 67 | * 68 | * The actual or preflight request is rejected if any of the origins from the request is not allowed. 69 | * 70 | * Default: `HttpOriginMatcher.*` 71 | * 72 | * @see 73 | * [[https://www.w3.org/TR/cors/#access-control-allow-origin-response-header Access-Control-Allow-Origin]] 74 | */ 75 | def allowedOrigins: HttpOriginMatcher 76 | 77 | /** List of request headers that can be used when making an actual request. Controls the content of the 78 | * `Access-Control-Allow-Headers` header in a preflight response: if parameter is `*`, the headers from 79 | * `Access-Control-Request-Headers` are echoed. Otherwise the parameter list is returned as part of the header. 80 | * 81 | * Default: `HttpHeaderRange.*` 82 | * 83 | * @see 84 | * [[https://www.w3.org/TR/cors/#access-control-allow-headers-response-header Access-Control-Allow-Headers]] 85 | */ 86 | def allowedHeaders: HttpHeaderRange 87 | 88 | /** List of methods that can be used when making an actual request. The list is returned as part of the 89 | * `Access-Control-Allow-Methods` preflight response header. 90 | * 91 | * The preflight request will be rejected if the `Access-Control-Request-Method` header's method is not part of the 92 | * list. 93 | * 94 | * Default: `Seq(GET, POST, HEAD, OPTIONS)` 95 | * 96 | * @see 97 | * [[https://www.w3.org/TR/cors/#access-control-allow-methods-response-header Access-Control-Allow-Methods]] 98 | */ 99 | def allowedMethods: Seq[HttpMethod] 100 | 101 | /** List of headers (other than simple response headers) that browsers are allowed to access. If not empty, this list 102 | * is returned as part of the `Access-Control-Expose-Headers` header in the actual response. 103 | * 104 | * Default: `Seq.empty` 105 | * 106 | * @see 107 | * [[https://www.w3.org/TR/cors/#simple-response-header Simple response headers]] 108 | * @see 109 | * [[https://www.w3.org/TR/cors/#access-control-expose-headers-response-header Access-Control-Expose-Headers]] 110 | */ 111 | def exposedHeaders: Seq[String] 112 | 113 | /** When set, the amount of seconds the browser is allowed to cache the results of a preflight request. This value is 114 | * returned as part of the `Access-Control-Max-Age` preflight response header. If `None`, the header is not added to 115 | * the preflight response. 116 | * 117 | * Default: `Some(30 * 60)` 118 | * 119 | * @see 120 | * [[https://www.w3.org/TR/cors/#access-control-max-age-response-header Access-Control-Max-Age]] 121 | */ 122 | def maxAge: Option[Long] 123 | 124 | /* Java APIs */ 125 | 126 | override def getAllowGenericHttpRequests = this.allowGenericHttpRequests 127 | override def getAllowCredentials = this.allowCredentials 128 | override def getAllowedOrigins = this.allowedOrigins 129 | override def getAllowedHeaders = this.allowedHeaders 130 | override def getAllowedMethods = (this.allowedMethods: Seq[akka.http.javadsl.model.HttpMethod]).asJava 131 | override def getExposedHeaders = this.exposedHeaders.asJava 132 | override def getMaxAge = OptionConverters.toJava(this.maxAge) 133 | 134 | // Currently the easiest way to go from Java models to their Scala equivalent is to cast. 135 | // See https://github.com/akka/akka-http/issues/661 for a potential opening of the JavaMapping API. 136 | override def withAllowGenericHttpRequests(newValue: Boolean): CorsSettings = { 137 | copy(allowGenericHttpRequests = newValue) 138 | } 139 | override def withAllowCredentials(newValue: Boolean): CorsSettings = { 140 | copy(allowCredentials = newValue) 141 | } 142 | override def withAllowedOrigins(newValue: javadsl.model.HttpOriginMatcher): CorsSettings = { 143 | copy(allowedOrigins = newValue.asInstanceOf[HttpOriginMatcher]) 144 | } 145 | override def withAllowedHeaders(newValue: javadsl.model.HttpHeaderRange): CorsSettings = { 146 | copy(allowedHeaders = newValue.asInstanceOf[HttpHeaderRange]) 147 | } 148 | override def withAllowedMethods(newValue: java.lang.Iterable[akka.http.javadsl.model.HttpMethod]): CorsSettings = { 149 | copy(allowedMethods = newValue.asScala.toList.asInstanceOf[List[HttpMethod]]) 150 | } 151 | override def withExposedHeaders(newValue: java.lang.Iterable[String]): CorsSettings = { 152 | copy(exposedHeaders = newValue.asScala.toList) 153 | } 154 | override def withMaxAge(newValue: Optional[Long]): CorsSettings = { 155 | copy(maxAge = OptionConverters.toScala(newValue)) 156 | } 157 | 158 | // overloads for Scala idiomatic use 159 | def withAllowedOrigins(newValue: HttpOriginMatcher): CorsSettings = copy(allowedOrigins = newValue) 160 | def withAllowedHeaders(newValue: HttpHeaderRange): CorsSettings = copy(allowedHeaders = newValue) 161 | def withAllowedMethods(newValue: Seq[HttpMethod]): CorsSettings = copy(allowedMethods = newValue) 162 | def withExposedHeaders(newValue: Seq[String]): CorsSettings = copy(exposedHeaders = newValue) 163 | def withMaxAge(newValue: Option[Long]): CorsSettings = copy(maxAge = newValue) 164 | 165 | private[akka] def preflightResponseHeaders(origins: Seq[HttpOrigin], requestHeaders: Seq[String]): List[HttpHeader] 166 | private[akka] def actualResponseHeaders(origins: Seq[HttpOrigin]): List[HttpHeader] 167 | } 168 | 169 | object CorsSettings { 170 | private val prefix = "akka-http-cors" 171 | final private val MaxCached = 8 172 | private[this] var cache = ListMap.empty[ActorSystem, CorsSettings] 173 | 174 | /** Creates an instance of settings using the given Config. 175 | */ 176 | def apply(config: Config): CorsSettings = 177 | fromSubConfig(config.getConfig(prefix)) 178 | 179 | /** Creates an instance of settings using the given String of config overrides to override settings set in the class 180 | * loader of this class (i.e. by application.conf or reference.conf files in the class loader of this class). 181 | */ 182 | def apply(configOverrides: String): CorsSettings = 183 | apply(ConfigFactory.parseString(configOverrides).withFallback(ConfigFactory.load(getClass.getClassLoader))) 184 | 185 | /** Creates an instance of CorsSettings using the configuration provided by the given ActorSystem. 186 | */ 187 | def apply(system: ActorSystem): CorsSettings = 188 | // From private akka.http.impl.util.SettingsCompanionImpl implementation 189 | cache.getOrElse( 190 | system, { 191 | val settings = apply(system.settings.config) 192 | val c = if (cache.size < MaxCached) cache else cache.tail 193 | cache = c.updated(system, settings) 194 | settings 195 | } 196 | ) 197 | 198 | /** Creates an instance of CorsSettings using the configuration provided by the given ActorSystem. 199 | */ 200 | implicit def default(implicit system: ActorSystem): CorsSettings = apply(system) 201 | 202 | /** Settings from the default loaded configuration. Note that application code may want to use the `apply()` methods 203 | * instead to have more control over the source of the configuration. 204 | */ 205 | def defaultSettings: CorsSettings = apply(ConfigFactory.load(getClass.getClassLoader)) 206 | 207 | def fromSubConfig(config: Config): CorsSettings = { 208 | def parseStringList(path: String): List[String] = 209 | Try(config.getStringList(path).asScala.toList).recover { case _: WrongType => 210 | config.getString(path).split(" ").toList 211 | }.get 212 | 213 | def parseSeconds(path: String): Option[Long] = 214 | Try(Some(config.getLong(path))).recover { 215 | case _: WrongType => Some(config.getDuration(path, TimeUnit.SECONDS)) 216 | case _: Missing => None 217 | }.get 218 | 219 | CorsSettingsImpl( 220 | allowGenericHttpRequests = config.getBoolean("allow-generic-http-requests"), 221 | allowCredentials = config.getBoolean("allow-credentials"), 222 | allowedOrigins = parseStringList("allowed-origins") match { 223 | case List("*") => HttpOriginMatcher.* 224 | case origins => HttpOriginMatcher(origins.map(HttpOrigin(_)): _*) 225 | }, 226 | allowedHeaders = parseStringList("allowed-headers") match { 227 | case List("*") => HttpHeaderRange.* 228 | case headers => HttpHeaderRange(headers: _*) 229 | }, 230 | allowedMethods = parseStringList("allowed-methods") 231 | .map(method => HttpMethods.getForKey(method).getOrElse(HttpMethod.custom(method))), 232 | exposedHeaders = parseStringList("exposed-headers"), 233 | maxAge = parseSeconds("max-age") 234 | ) 235 | } 236 | 237 | } 238 | -------------------------------------------------------------------------------- /akka-http-cors/src/main/scala/ch/megard/akka/http/cors/scaladsl/settings/CorsSettingsImpl.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2016 Lomig Mégard 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package ch.megard.akka.http.cors.scaladsl.settings 18 | 19 | import akka.http.scaladsl.model.{HttpHeader, HttpMethod} 20 | import akka.http.scaladsl.model.headers._ 21 | import ch.megard.akka.http.cors.scaladsl.model.{HttpHeaderRange, HttpOriginMatcher} 22 | 23 | import scala.collection.immutable.Seq 24 | 25 | /** INTERNAL API */ 26 | final private[akka] case class CorsSettingsImpl( 27 | allowGenericHttpRequests: Boolean, 28 | allowCredentials: Boolean, 29 | allowedOrigins: HttpOriginMatcher, 30 | allowedHeaders: HttpHeaderRange, 31 | allowedMethods: Seq[HttpMethod], 32 | exposedHeaders: Seq[String], 33 | maxAge: Option[Long] 34 | ) extends CorsSettings { 35 | override def productPrefix = "CorsSettings" 36 | 37 | private def accessControlExposeHeaders: Option[`Access-Control-Expose-Headers`] = 38 | if (exposedHeaders.nonEmpty) 39 | Some(`Access-Control-Expose-Headers`(exposedHeaders)) 40 | else 41 | None 42 | 43 | private def accessControlAllowCredentials: Option[`Access-Control-Allow-Credentials`] = 44 | if (allowCredentials) 45 | Some(`Access-Control-Allow-Credentials`(true)) 46 | else 47 | None 48 | 49 | private def accessControlMaxAge: Option[`Access-Control-Max-Age`] = 50 | maxAge.map(`Access-Control-Max-Age`.apply) 51 | 52 | private def accessControlAllowMethods: `Access-Control-Allow-Methods` = 53 | `Access-Control-Allow-Methods`(allowedMethods) 54 | 55 | private def accessControlAllowHeaders(requestHeaders: Seq[String]): Option[`Access-Control-Allow-Headers`] = 56 | allowedHeaders match { 57 | case HttpHeaderRange.Default(headers) => Some(`Access-Control-Allow-Headers`(headers)) 58 | case HttpHeaderRange.* if requestHeaders.nonEmpty => Some(`Access-Control-Allow-Headers`(requestHeaders)) 59 | case _ => None 60 | } 61 | 62 | private def accessControlAllowOrigin(origins: Seq[HttpOrigin]): `Access-Control-Allow-Origin` = 63 | if (allowedOrigins == HttpOriginMatcher.* && !allowCredentials) 64 | `Access-Control-Allow-Origin`.* 65 | else 66 | `Access-Control-Allow-Origin`.forRange(HttpOriginRange.Default(origins)) 67 | 68 | // Cache headers that are always included in a preflight response 69 | private val basePreflightResponseHeaders: List[HttpHeader] = 70 | List(accessControlAllowMethods) ++ accessControlMaxAge ++ accessControlAllowCredentials 71 | 72 | // Cache headers that are always included in an actual response 73 | private val baseActualResponseHeaders: List[HttpHeader] = 74 | accessControlExposeHeaders.toList ++ accessControlAllowCredentials 75 | 76 | def preflightResponseHeaders(origins: Seq[HttpOrigin], requestHeaders: Seq[String]): List[HttpHeader] = 77 | accessControlAllowHeaders(requestHeaders) match { 78 | case Some(h) => h :: accessControlAllowOrigin(origins) :: basePreflightResponseHeaders 79 | case None => accessControlAllowOrigin(origins) :: basePreflightResponseHeaders 80 | } 81 | 82 | def actualResponseHeaders(origins: Seq[HttpOrigin]): List[HttpHeader] = 83 | accessControlAllowOrigin(origins) :: baseActualResponseHeaders 84 | } 85 | -------------------------------------------------------------------------------- /akka-http-cors/src/test/scala/ch/megard/akka/http/cors/CorsDirectivesSpec.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2016 Lomig Mégard 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package ch.megard.akka.http.cors 18 | 19 | import akka.http.scaladsl.model._ 20 | import akka.http.scaladsl.model.headers._ 21 | import akka.http.scaladsl.server.{Directives, Route} 22 | import akka.http.scaladsl.testkit.ScalatestRouteTest 23 | import ch.megard.akka.http.cors.scaladsl.CorsRejection 24 | import ch.megard.akka.http.cors.scaladsl.model.{HttpHeaderRange, HttpOriginMatcher} 25 | import ch.megard.akka.http.cors.scaladsl.settings.CorsSettings 26 | 27 | import scala.collection.immutable.Seq 28 | import org.scalatest.matchers.should.Matchers 29 | import org.scalatest.wordspec.AnyWordSpec 30 | 31 | class CorsDirectivesSpec extends AnyWordSpec with Matchers with Directives with ScalatestRouteTest { 32 | import HttpMethods._ 33 | import ch.megard.akka.http.cors.scaladsl.CorsDirectives._ 34 | 35 | val actual = "actual" 36 | val exampleOrigin = HttpOrigin("http://example.com") 37 | val exampleStatus = StatusCodes.Created 38 | 39 | val referenceSettings = CorsSettings("") 40 | 41 | // override config for the ActorSystem to test `cors()` 42 | override def testConfigSource: String = 43 | """ 44 | |akka-http-cors { 45 | | allow-credentials = false 46 | |} 47 | |""".stripMargin 48 | 49 | def route(settings: CorsSettings, responseHeaders: Seq[HttpHeader] = Nil): Route = 50 | cors(settings) { 51 | complete(HttpResponse(exampleStatus, responseHeaders, HttpEntity(actual))) 52 | } 53 | 54 | "The cors() directive" should { 55 | "extract its settings from the actor system" in { 56 | val route = cors() { 57 | complete(HttpResponse(exampleStatus, Nil, HttpEntity(actual))) 58 | } 59 | 60 | Get() ~> Origin(exampleOrigin) ~> route ~> check { 61 | responseAs[String] shouldBe actual 62 | response.status shouldBe exampleStatus 63 | response.headers should contain theSameElementsAs Seq( 64 | `Access-Control-Allow-Origin`.* 65 | ) 66 | } 67 | } 68 | } 69 | 70 | "The cors(settings) directive" should { 71 | "not affect actual requests when not strict" in { 72 | val settings = referenceSettings 73 | val responseHeaders = Seq(Host("my-host"), `Access-Control-Max-Age`(60)) 74 | Get() ~> { 75 | route(settings, responseHeaders) 76 | } ~> check { 77 | responseAs[String] shouldBe actual 78 | response.status shouldBe exampleStatus 79 | // response headers should be untouched, including the CORS-related ones 80 | response.headers shouldBe responseHeaders 81 | } 82 | } 83 | 84 | "reject requests without Origin header when strict" in { 85 | val settings = referenceSettings.withAllowGenericHttpRequests(false) 86 | Get() ~> { 87 | route(settings) 88 | } ~> check { 89 | rejection shouldBe CorsRejection(CorsRejection.Malformed) 90 | } 91 | } 92 | 93 | "accept actual requests with a single Origin" in { 94 | val settings = referenceSettings 95 | Get() ~> Origin(exampleOrigin) ~> { 96 | route(settings) 97 | } ~> check { 98 | responseAs[String] shouldBe actual 99 | response.status shouldBe exampleStatus 100 | response.headers should contain theSameElementsAs Seq( 101 | `Access-Control-Allow-Origin`(exampleOrigin), 102 | `Access-Control-Allow-Credentials`(true) 103 | ) 104 | } 105 | } 106 | 107 | "accept pre-flight requests with a null origin when allowed-origins = `*`" in { 108 | val settings = referenceSettings 109 | Options() ~> Origin(Seq.empty) ~> `Access-Control-Request-Method`(GET) ~> { 110 | route(settings) 111 | } ~> check { 112 | status shouldBe StatusCodes.OK 113 | response.headers should contain theSameElementsAs Seq( 114 | `Access-Control-Allow-Origin`.`null`, 115 | `Access-Control-Allow-Methods`(settings.allowedMethods), 116 | `Access-Control-Max-Age`(1800), 117 | `Access-Control-Allow-Credentials`(true) 118 | ) 119 | } 120 | } 121 | 122 | "reject pre-flight requests with a null origin when allowed-origins != `*`" in { 123 | val settings = referenceSettings.withAllowedOrigins(HttpOriginMatcher(exampleOrigin)) 124 | Options() ~> Origin(Seq.empty) ~> `Access-Control-Request-Method`(GET) ~> { 125 | route(settings) 126 | } ~> check { 127 | rejection shouldBe CorsRejection(CorsRejection.InvalidOrigin(Seq.empty)) 128 | } 129 | } 130 | 131 | "accept actual requests with a null Origin" in { 132 | val settings = referenceSettings 133 | Get() ~> Origin(Seq.empty) ~> { 134 | route(settings) 135 | } ~> check { 136 | responseAs[String] shouldBe actual 137 | response.status shouldBe exampleStatus 138 | response.headers should contain theSameElementsAs Seq( 139 | `Access-Control-Allow-Origin`.`null`, 140 | `Access-Control-Allow-Credentials`(true) 141 | ) 142 | } 143 | } 144 | 145 | "accept actual requests with an Origin matching an allowed subdomain" in { 146 | val subdomainMatcher = HttpOriginMatcher(HttpOrigin("http://*.example.com")) 147 | val subdomainOrigin = HttpOrigin("http://sub.example.com") 148 | 149 | val settings = referenceSettings.withAllowedOrigins(subdomainMatcher) 150 | Get() ~> Origin(subdomainOrigin) ~> { 151 | route(settings) 152 | } ~> check { 153 | responseAs[String] shouldBe actual 154 | response.status shouldBe exampleStatus 155 | response.headers should contain theSameElementsAs Seq( 156 | `Access-Control-Allow-Origin`(subdomainOrigin), 157 | `Access-Control-Allow-Credentials`(true) 158 | ) 159 | } 160 | } 161 | 162 | "return `Access-Control-Allow-Origin: *` to actual request only when credentials are not allowed" in { 163 | val settings = referenceSettings.withAllowCredentials(false) 164 | Get() ~> Origin(exampleOrigin) ~> { 165 | route(settings) 166 | } ~> check { 167 | responseAs[String] shouldBe actual 168 | response.status shouldBe exampleStatus 169 | response.headers shouldBe Seq( 170 | `Access-Control-Allow-Origin`.* 171 | ) 172 | } 173 | } 174 | 175 | "return `Access-Control-Expose-Headers` to actual request with all the exposed headers in the settings" in { 176 | val exposedHeaders = Seq("X-a", "X-b", "X-c") 177 | val settings = referenceSettings.withExposedHeaders(exposedHeaders) 178 | Get() ~> Origin(exampleOrigin) ~> { 179 | route(settings) 180 | } ~> check { 181 | responseAs[String] shouldBe actual 182 | response.status shouldBe exampleStatus 183 | response.headers shouldBe Seq( 184 | `Access-Control-Allow-Origin`(exampleOrigin), 185 | `Access-Control-Expose-Headers`(exposedHeaders), 186 | `Access-Control-Allow-Credentials`(true) 187 | ) 188 | } 189 | } 190 | 191 | "remove CORS-related headers from the original response before adding the new ones" in { 192 | val settings = referenceSettings.withExposedHeaders(Seq("X-good")) 193 | val responseHeaders = Seq( 194 | Host("my-host"), // untouched 195 | `Access-Control-Allow-Origin`("http://bad.com"), // replaced 196 | `Access-Control-Expose-Headers`("X-bad"), // replaced 197 | `Access-Control-Allow-Credentials`(false), // replaced 198 | `Access-Control-Allow-Methods`(HttpMethods.POST), // removed 199 | `Access-Control-Allow-Headers`("X-bad"), // removed 200 | `Access-Control-Max-Age`(60) // removed 201 | ) 202 | Get() ~> Origin(exampleOrigin) ~> { 203 | route(settings, responseHeaders) 204 | } ~> check { 205 | responseAs[String] shouldBe actual 206 | response.status shouldBe exampleStatus 207 | response.headers should contain theSameElementsAs Seq( 208 | `Access-Control-Allow-Origin`(exampleOrigin), 209 | `Access-Control-Expose-Headers`("X-good"), 210 | `Access-Control-Allow-Credentials`(true), 211 | Host("my-host") 212 | ) 213 | } 214 | } 215 | 216 | "accept valid pre-flight requests" in { 217 | val settings = referenceSettings 218 | Options() ~> Origin(exampleOrigin) ~> `Access-Control-Request-Method`(GET) ~> { 219 | route(settings) 220 | } ~> check { 221 | response.entity shouldBe HttpEntity.Empty 222 | status shouldBe StatusCodes.OK 223 | response.headers should contain theSameElementsAs Seq( 224 | `Access-Control-Allow-Origin`(exampleOrigin), 225 | `Access-Control-Allow-Methods`(settings.allowedMethods), 226 | `Access-Control-Max-Age`(1800), 227 | `Access-Control-Allow-Credentials`(true) 228 | ) 229 | } 230 | } 231 | 232 | "accept actual requests with OPTION method" in { 233 | val settings = referenceSettings 234 | Options() ~> Origin(exampleOrigin) ~> { 235 | route(settings) 236 | } ~> check { 237 | responseAs[String] shouldBe actual 238 | response.status shouldBe exampleStatus 239 | response.headers should contain theSameElementsAs Seq( 240 | `Access-Control-Allow-Origin`(exampleOrigin), 241 | `Access-Control-Allow-Credentials`(true) 242 | ) 243 | } 244 | } 245 | 246 | "reject actual requests with invalid origin" when { 247 | "the origin is null" in { 248 | val settings = referenceSettings.withAllowedOrigins(HttpOriginMatcher(exampleOrigin)) 249 | Get() ~> Origin(Seq.empty) ~> { 250 | route(settings) 251 | } ~> check { 252 | rejection shouldBe CorsRejection(CorsRejection.InvalidOrigin(Seq.empty)) 253 | } 254 | } 255 | "there is one origin" in { 256 | val settings = referenceSettings.withAllowedOrigins(HttpOriginMatcher(exampleOrigin)) 257 | val invalidOrigin = HttpOrigin("http://invalid.com") 258 | Get() ~> Origin(invalidOrigin) ~> { 259 | route(settings) 260 | } ~> check { 261 | rejection shouldBe CorsRejection(CorsRejection.InvalidOrigin(Seq(invalidOrigin))) 262 | } 263 | } 264 | } 265 | 266 | "reject pre-flight requests with invalid origin" in { 267 | val settings = referenceSettings.withAllowedOrigins(HttpOriginMatcher(exampleOrigin)) 268 | val invalidOrigin = HttpOrigin("http://invalid.com") 269 | Options() ~> Origin(invalidOrigin) ~> `Access-Control-Request-Method`(GET) ~> { 270 | route(settings) 271 | } ~> check { 272 | rejection shouldBe CorsRejection(CorsRejection.InvalidOrigin(Seq(invalidOrigin))) 273 | } 274 | } 275 | 276 | "reject pre-flight requests with invalid method" in { 277 | val settings = referenceSettings 278 | val invalidMethod = PATCH 279 | Options() ~> Origin(exampleOrigin) ~> `Access-Control-Request-Method`(invalidMethod) ~> { 280 | route(settings) 281 | } ~> check { 282 | rejection shouldBe CorsRejection(CorsRejection.InvalidMethod(invalidMethod)) 283 | } 284 | } 285 | 286 | "reject pre-flight requests with invalid header" in { 287 | val settings = referenceSettings.withAllowedHeaders(HttpHeaderRange()) 288 | val invalidHeader = "X-header" 289 | Options() ~> Origin(exampleOrigin) ~> `Access-Control-Request-Method`(GET) ~> 290 | `Access-Control-Request-Headers`(invalidHeader) ~> { 291 | route(settings) 292 | } ~> check { 293 | rejection shouldBe CorsRejection(CorsRejection.InvalidHeaders(Seq(invalidHeader))) 294 | } 295 | } 296 | 297 | "reject pre-flight requests with multiple origins" in { 298 | val settings = referenceSettings.withAllowGenericHttpRequests(false) 299 | Options() ~> Origin(exampleOrigin, exampleOrigin) ~> `Access-Control-Request-Method`(GET) ~> { 300 | route(settings) 301 | } ~> check { 302 | rejection shouldBe CorsRejection(CorsRejection.Malformed) 303 | } 304 | } 305 | } 306 | 307 | "the default rejection handler" should { 308 | val settings = referenceSettings 309 | .withAllowGenericHttpRequests(false) 310 | .withAllowedOrigins(HttpOriginMatcher(exampleOrigin)) 311 | .withAllowedHeaders(HttpHeaderRange()) 312 | val sealedRoute = handleRejections(corsRejectionHandler) { route(settings) } 313 | 314 | "handle the malformed request cause" in { 315 | Get() ~> { 316 | sealedRoute 317 | } ~> check { 318 | status shouldBe StatusCodes.BadRequest 319 | entityAs[String] shouldBe "CORS: malformed request" 320 | } 321 | } 322 | 323 | "handle a request with invalid origin" when { 324 | "the origin is null" in { 325 | Get() ~> Origin(Seq.empty) ~> { 326 | sealedRoute 327 | } ~> check { 328 | status shouldBe StatusCodes.BadRequest 329 | entityAs[String] shouldBe s"CORS: invalid origin 'null'" 330 | } 331 | } 332 | "there is one origin" in { 333 | Get() ~> Origin(HttpOrigin("http://invalid.com")) ~> { 334 | sealedRoute 335 | } ~> check { 336 | status shouldBe StatusCodes.BadRequest 337 | entityAs[String] shouldBe s"CORS: invalid origin 'http://invalid.com'" 338 | } 339 | } 340 | "there are two origins" in { 341 | Get() ~> Origin(HttpOrigin("http://invalid1.com"), HttpOrigin("http://invalid2.com")) ~> { 342 | sealedRoute 343 | } ~> check { 344 | status shouldBe StatusCodes.BadRequest 345 | entityAs[String] shouldBe s"CORS: invalid origin 'http://invalid1.com http://invalid2.com'" 346 | } 347 | } 348 | } 349 | 350 | "handle a pre-flight request with invalid method" in { 351 | Options() ~> Origin(exampleOrigin) ~> `Access-Control-Request-Method`(PATCH) ~> { 352 | sealedRoute 353 | } ~> check { 354 | status shouldBe StatusCodes.BadRequest 355 | entityAs[String] shouldBe s"CORS: invalid method 'PATCH'" 356 | } 357 | } 358 | 359 | "handle a pre-flight request with invalid headers" in { 360 | Options() ~> Origin(exampleOrigin) ~> `Access-Control-Request-Method`(GET) ~> 361 | `Access-Control-Request-Headers`("X-a", "X-b") ~> { 362 | sealedRoute 363 | } ~> check { 364 | status shouldBe StatusCodes.BadRequest 365 | entityAs[String] shouldBe s"CORS: invalid headers 'X-a X-b'" 366 | } 367 | } 368 | 369 | "handle multiple CORS rejections" in { 370 | Options() ~> Origin(HttpOrigin("http://invalid.com")) ~> `Access-Control-Request-Method`(PATCH) ~> 371 | `Access-Control-Request-Headers`("X-a", "X-b") ~> { 372 | sealedRoute 373 | } ~> check { 374 | status shouldBe StatusCodes.BadRequest 375 | entityAs[String] shouldBe 376 | s"CORS: invalid origin 'http://invalid.com', invalid method 'PATCH', invalid headers 'X-a X-b'" 377 | } 378 | } 379 | } 380 | } 381 | -------------------------------------------------------------------------------- /akka-http-cors/src/test/scala/ch/megard/akka/http/cors/scaladsl/model/HttpOriginMatcherSpec.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2016 Lomig Mégard 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package ch.megard.akka.http.cors.scaladsl.model 18 | 19 | import akka.http.scaladsl.model.headers.HttpOrigin 20 | import org.scalatest.Inspectors 21 | import org.scalatest.matchers.should.Matchers 22 | import org.scalatest.wordspec.AnyWordSpec 23 | 24 | class HttpOriginMatcherSpec extends AnyWordSpec with Matchers with Inspectors { 25 | "The `*` matcher" should { 26 | "match any Origin" in { 27 | val origins = Seq( 28 | "http://localhost", 29 | "http://192.168.1.1", 30 | "http://test.com", 31 | "http://test.com:8080", 32 | "https://test.com", 33 | "https://test.com:4433" 34 | ).map(HttpOrigin.apply) 35 | 36 | forAll(origins) { o => HttpOriginMatcher.*.matches(o) shouldBe true } 37 | } 38 | 39 | "be printed as `*`" in { 40 | HttpOriginMatcher.*.toString shouldBe "*" 41 | } 42 | } 43 | 44 | "The strict() method" should { 45 | "build a strict matcher, comparing exactly the origins" in { 46 | val positives = Seq( 47 | "http://localhost", 48 | "http://test.com", 49 | "https://test.ch:12345", 50 | "https://*.test.uk.co" 51 | ).map(HttpOrigin.apply) 52 | 53 | val negatives = Seq( 54 | "http://localhost:80", 55 | "https://localhost", 56 | "http://test.com:8080", 57 | "https://test.ch", 58 | "https://abc.test.uk.co" 59 | ).map(HttpOrigin.apply) 60 | 61 | val matcher = HttpOriginMatcher.strict(positives: _*) 62 | 63 | forAll(positives) { o => matcher.matches(o) shouldBe true } 64 | 65 | forAll(negatives) { o => matcher.matches(o) shouldBe false } 66 | } 67 | 68 | "build a matcher with a toString() method that is a valid range" in { 69 | val matcher = HttpOriginMatcher(Seq("http://test.com", "https://test.ch:12345").map(HttpOrigin.apply): _*) 70 | matcher.toString shouldBe "http://test.com https://test.ch:12345" 71 | } 72 | } 73 | 74 | "The apply() method" should { 75 | "build a matcher accepting sub-domains with wildcards" in { 76 | val matcher = HttpOriginMatcher( 77 | Seq( 78 | "http://test.com", 79 | "https://test.ch:12345", 80 | "https://*.test.uk.co", 81 | "http://*.abc.com:8080", 82 | "http://*abc.com", // Must start with `*.` 83 | "http://abc.*.middle.com" // The wildcard can't be in the middle 84 | ).map(HttpOrigin.apply): _* 85 | ) 86 | 87 | val positives = Seq( 88 | "http://test.com", 89 | "https://test.ch:12345", 90 | "https://sub.test.uk.co", 91 | "https://sub1.sub2.test.uk.co", 92 | "http://sub.abc.com:8080" 93 | ).map(HttpOrigin.apply) 94 | 95 | val negatives = Seq( 96 | "http://test.com:8080", 97 | "http://sub.test.uk.co", // must compare the scheme 98 | "http://sub.abc.com", // must compare the port 99 | "http://abc.test.com", // no wildcard 100 | "http://sub.abc.com", 101 | "http://subabc.com", 102 | "http://abc.sub.middle.com", 103 | "http://abc.middle.com" 104 | ).map(HttpOrigin.apply) 105 | 106 | forAll(positives) { o => matcher.matches(o) shouldBe true } 107 | 108 | forAll(negatives) { o => matcher.matches(o) shouldBe false } 109 | } 110 | 111 | "build a matcher with a toString() method that is a valid range" in { 112 | val matcher = HttpOriginMatcher(Seq("http://test.com", "https://*.test.ch:12345").map(HttpOrigin.apply): _*) 113 | matcher.toString shouldBe "http://test.com https://*.test.ch:12345" 114 | } 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /akka-http-cors/src/test/scala/ch/megard/akka/http/cors/scaladsl/settings/CorsSettingsSpec.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2016 Lomig Mégard 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package ch.megard.akka.http.cors.scaladsl.settings 18 | 19 | import akka.http.scaladsl.model.headers.HttpOrigin 20 | import akka.http.scaladsl.model.{HttpMethod, HttpMethods} 21 | import akka.http.scaladsl.testkit.ScalatestRouteTest 22 | import ch.megard.akka.http.cors.scaladsl.model.{HttpHeaderRange, HttpOriginMatcher} 23 | import com.typesafe.config.{ConfigFactory, ConfigValueFactory} 24 | import org.scalatest.matchers.should.Matchers 25 | import org.scalatest.wordspec.AnyWordSpec 26 | 27 | class CorsSettingsSpec extends AnyWordSpec with Matchers with ScalatestRouteTest { 28 | import HttpMethods._ 29 | 30 | // Override some configs loaded through the Actor system 31 | override def testConfigSource = 32 | """ 33 | akka-http-cors { 34 | allow-credentials = false 35 | } 36 | """ 37 | 38 | val validConfig = ConfigFactory.parseString( 39 | """ 40 | akka-http-cors { 41 | allow-generic-http-requests = true 42 | allow-credentials = true 43 | allowed-origins = "*" 44 | allowed-headers = "*" 45 | allowed-methods = ["GET", "OPTIONS", "XXX"] 46 | exposed-headers = [] 47 | max-age = 30 minutes 48 | } 49 | """ 50 | ) 51 | 52 | val referenceSettings = CorsSettings("") 53 | 54 | "CorsSettings" should { 55 | 56 | "load settings from the actor system by default" in { 57 | val settings1 = CorsSettings.default 58 | val settings2 = CorsSettings(system) 59 | 60 | settings1 should not be referenceSettings 61 | settings1 shouldBe settings2 62 | 63 | referenceSettings.allowCredentials shouldBe true 64 | settings1.allowCredentials shouldBe false 65 | } 66 | 67 | "cache the settings from the actor system" in { 68 | val settings1 = CorsSettings(system) 69 | val settings2 = CorsSettings(system) 70 | 71 | settings1 shouldBe theSameInstanceAs(settings2) 72 | } 73 | 74 | "return valid cors settings from a valid config object" in { 75 | val corsSettings = CorsSettings(validConfig) 76 | corsSettings.allowGenericHttpRequests shouldBe true 77 | corsSettings.allowCredentials shouldBe true 78 | corsSettings.allowedOrigins shouldBe HttpOriginMatcher.* 79 | corsSettings.allowedHeaders shouldBe HttpHeaderRange.* 80 | corsSettings.allowedMethods shouldBe List(GET, OPTIONS, HttpMethod.custom("XXX")) 81 | corsSettings.exposedHeaders shouldBe List.empty 82 | corsSettings.maxAge shouldBe Some(1800) 83 | } 84 | 85 | "support space separated list of origins" in { 86 | val config = validConfig.withValue( 87 | "akka-http-cors.allowed-origins", 88 | ConfigValueFactory.fromAnyRef("http://test.com http://any.com") 89 | ) 90 | val corsSettings = CorsSettings(config) 91 | corsSettings.allowedOrigins shouldBe HttpOriginMatcher( 92 | HttpOrigin("http://test.com"), 93 | HttpOrigin("http://any.com") 94 | ) 95 | } 96 | 97 | "support wildcard subdomains" in { 98 | val config = validConfig.withValue( 99 | "akka-http-cors.allowed-origins", 100 | ConfigValueFactory.fromAnyRef("http://*.test.com") 101 | ) 102 | val corsSettings = CorsSettings(config) 103 | corsSettings.allowedOrigins.matches(HttpOrigin("http://sub.test.com")) shouldBe true 104 | } 105 | 106 | "support numeric values on max-age as seconds" in { 107 | val corsSettings = CorsSettings( 108 | validConfig.withValue("akka-http-cors.max-age", ConfigValueFactory.fromAnyRef(1800)) 109 | ) 110 | corsSettings.maxAge shouldBe Some(1800) 111 | } 112 | 113 | "support null value on max-age" in { 114 | val corsSettings = CorsSettings( 115 | validConfig.withValue("akka-http-cors.max-age", ConfigValueFactory.fromAnyRef(null)) 116 | ) 117 | corsSettings.maxAge shouldBe None 118 | } 119 | 120 | "support undefined on max-age" in { 121 | val corsSettings = CorsSettings(validConfig.withoutPath("akka-http-cors.max-age")) 122 | corsSettings.maxAge shouldBe None 123 | } 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /build.sbt: -------------------------------------------------------------------------------- 1 | lazy val commonSettings = Seq( 2 | organization := "ch.megard", 3 | organizationName := "Lomig Mégard", 4 | startYear := Some(2016), 5 | version := "1.2.1-SNAPSHOT", 6 | scalaVersion := "2.13.11", 7 | crossScalaVersions := Seq(scalaVersion.value, "2.12.18", "3.3.0"), 8 | scalacOptions ++= Seq( 9 | "-encoding", 10 | "UTF-8", 11 | "-unchecked", 12 | "-deprecation" 13 | ), 14 | javacOptions ++= Seq( 15 | "-encoding", 16 | "UTF-8", 17 | "-source", 18 | "8", 19 | "-target", 20 | "8" 21 | ), 22 | homepage := Some(url("https://github.com/lomigmegard/akka-http-cors")), 23 | licenses := Seq("Apache-2.0" -> url("https://www.apache.org/licenses/LICENSE-2.0.txt")), 24 | scmInfo := Some( 25 | ScmInfo( 26 | url("https://github.com/lomigmegard/akka-http-cors"), 27 | "scm:git@github.com:lomigmegard/akka-http-cors.git" 28 | ) 29 | ), 30 | developers := List( 31 | Developer(id = "lomigmegard", name = "Lomig Mégard", email = "", url = url("https://lomig.ch")) 32 | ) 33 | ) 34 | 35 | lazy val publishSettings = Seq( 36 | publishMavenStyle := true, 37 | Test / publishArtifact := false, 38 | pomIncludeRepository := { _ => false }, 39 | publishTo := sonatypePublishToBundle.value 40 | ) 41 | 42 | lazy val dontPublishSettings = Seq( 43 | publish / skip := true 44 | ) 45 | 46 | lazy val root = (project in file(".")) 47 | .aggregate(`akka-http-cors`, `akka-http-cors-example`, `akka-http-cors-bench-jmh`) 48 | .settings(commonSettings) 49 | .settings(dontPublishSettings) 50 | 51 | lazy val akkaVersion = "2.6.20" 52 | lazy val akkaHttpVersion = "10.2.10" 53 | 54 | lazy val `akka-http-cors` = project 55 | .settings(commonSettings) 56 | .settings(publishSettings) 57 | .settings( 58 | // Java 9 Automatic-Module-Name (http://openjdk.java.net/projects/jigsaw/spec/issues/#AutomaticModuleNames) 59 | Compile / packageBin / packageOptions += Package.ManifestAttributes( 60 | "Automatic-Module-Name" -> "ch.megard.akka.http.cors" 61 | ), 62 | libraryDependencies += "com.typesafe.akka" %% "akka-http" % akkaHttpVersion cross CrossVersion.for3Use2_13, 63 | libraryDependencies += "com.typesafe.akka" %% "akka-stream" % akkaVersion % Provided cross CrossVersion.for3Use2_13, 64 | libraryDependencies += "com.typesafe.akka" %% "akka-http-testkit" % akkaHttpVersion % Test cross CrossVersion.for3Use2_13, 65 | libraryDependencies += "com.typesafe.akka" %% "akka-stream-testkit" % akkaVersion % Test cross CrossVersion.for3Use2_13, 66 | libraryDependencies += "org.scalatest" %% "scalatest" % "3.2.17" % Test 67 | ) 68 | 69 | lazy val `akka-http-cors-example` = project 70 | .dependsOn(`akka-http-cors`) 71 | .settings(commonSettings) 72 | .settings(dontPublishSettings) 73 | .settings( 74 | libraryDependencies += "com.typesafe.akka" %% "akka-stream" % akkaVersion cross CrossVersion.for3Use2_13, 75 | libraryDependencies += "com.typesafe.akka" %% "akka-actor-typed" % akkaVersion cross CrossVersion.for3Use2_13 76 | // libraryDependencies += "ch.megard" %% "akka-http-cors" % version.value 77 | ) 78 | 79 | lazy val `akka-http-cors-bench-jmh` = project 80 | .dependsOn(`akka-http-cors`) 81 | .enablePlugins(JmhPlugin) 82 | .settings(commonSettings) 83 | .settings(dontPublishSettings) 84 | .settings( 85 | libraryDependencies += "com.typesafe.akka" %% "akka-stream" % akkaVersion cross CrossVersion.for3Use2_13 86 | ) 87 | -------------------------------------------------------------------------------- /project/build.properties: -------------------------------------------------------------------------------- 1 | sbt.version=1.9.7 -------------------------------------------------------------------------------- /project/plugins.sbt: -------------------------------------------------------------------------------- 1 | addSbtPlugin("pl.project13.scala" % "sbt-jmh" % "0.4.6") 2 | addSbtPlugin("org.scalameta" % "sbt-scalafmt" % "2.5.2") 3 | addSbtPlugin("com.github.sbt" % "sbt-pgp" % "2.2.1") 4 | addSbtPlugin("org.xerial.sbt" % "sbt-sonatype" % "3.9.21") 5 | addSbtPlugin("de.heikoseeberger" % "sbt-header" % "5.10.0") 6 | --------------------------------------------------------------------------------