├── .formatter.exs ├── .github ├── dependabot.yml └── workflows │ ├── ci.yml │ └── publish.yml ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── config └── config.exs ├── lib └── cors_plug.ex ├── mix.exs ├── mix.lock └── test ├── cors_plug_test.exs └── test_helper.exs /.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | inputs: ["mix.exs", "{config,lib,test}/**/*.{ex,exs}"] 4 | ] 5 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: mix 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | open-pull-requests-limit: 10 8 | target-branch: master 9 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | on: [push, pull_request] 3 | 4 | jobs: 5 | test: 6 | runs-on: ubuntu-latest 7 | name: OTP ${{matrix.otp}} / Elixir ${{matrix.elixir}} 8 | strategy: 9 | matrix: 10 | otp: [22, 23, 24] 11 | elixir: [1.11, 1.12, 1.13] 12 | steps: 13 | - uses: actions/checkout@v2 14 | - uses: erlef/setup-beam@v1 15 | with: 16 | otp-version: ${{matrix.otp}} 17 | elixir-version: ${{matrix.elixir}} 18 | - run: mix deps.get 19 | - run: mix test 20 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | on: 3 | push: 4 | tags: 5 | - '*' 6 | 7 | jobs: 8 | publish: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Check out 12 | uses: actions/checkout@v2 13 | 14 | - name: Publish package to hex.pm 15 | uses: wesleimp/action-publish-hex@v1 16 | env: 17 | HEX_API_KEY: ${{ secrets.HEX_API_KEY }} 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where third-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | 22 | # Ignore package tarball (built via "mix hex.build"). 23 | cors_plug-*.tar 24 | 25 | # Temporary files for e.g. tests 26 | /tmp 27 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## v3.0.3 - 2022-03-02 4 | Released to keep tag integrity, equivalent to v3.0.0 5 | 6 | * **BREAKING CHANGES** / Fixes 7 | * Remove allow-credentials when set to false (thanks @AntoineAugusti) 8 | * Don't halt non-CORS OPTIONS requests 9 | 10 | ## v2.0.3 - 2021-02-06 11 | * Fixes 12 | * Use recent versions of Elixir and Erlang for testing (thanks @anthonator) 13 | * Fix compilation warnings (thanks @thiamsantos) 14 | 15 | ## v2.0.2 - 2020-02-18 16 | 17 | * Fixes 18 | * Fixes an issue where the plug would error when no CORS header was set 19 | (thanks @alexeyds) 20 | 21 | ## v2.0.1 - 2020-01-26 22 | 23 | * Enhancements 24 | * Passing a function with arity 2 as `origin` will pass the `conn` to the 25 | function, allowing configuration based on conn (thanks @billionlaughs). 26 | * You can now pass regexes as part of the list of origins (thanks @gabrielpra1). 27 | * Fixes 28 | * Fixes an issue where the request was missing the 29 | `access-control-request-headers` (thanks @zhhz for the initial report and 30 | @mfeckie for the fix). 31 | 32 | ## v2.0.0 - 2018-11-06 33 | 34 | * Enhancements 35 | * Instead of sending `"null"` we don't set the headers at all if the origin doesn't match, as suggested by the [CORS draft 7.2](https://w3c.github.io/webappsec-cors-for-developers/#avoid-returning-access-control-allow-origin-null). Thanks to @YuLeven for initiating the discussion and @slashmili for fixing it. Since we change the return values I consider this a breaking change and released a new major version. 36 | * You can now set the option `send_preflight_response?` to `false` (it's `true` by default) to stop `CorsPlug` sending a response to the preflight request. That way the correct headers are set but it's up to you to respond to the request downstream. 37 | 38 | ## v1.5.2 - 2018-03-19 39 | 40 | * Fixes 41 | * Relax version requirements 42 | 43 | ## v1.5.1 - 2018-03-14 44 | 45 | * Fixes 46 | * Send proper return value if `Access-Control-Request-Headers` is not present. 47 | (thanks @shivamMg) 48 | 49 | ## v1.5.0 - 2017-12-06 50 | 51 | * Enhancements 52 | * Allow configuration of origin via function (thanks @mauricioszabo). 53 | 54 | ## v1.4.0 - 2017-01-13 55 | 56 | * Enhancements 57 | * Allows both `*` as well as specific domains in the `origins` config, returns 58 | the corresponding value (thanks @mustafaturan) 59 | * Fixes 60 | * Don't overwrite `vary` header values with `"Origin"`, instead append it. 61 | * Don't set `vary` header to empty string if not needed. 62 | * Use `Plug.Conn.merge_resp_headers/2` 63 | 64 | New major release because of the `vary` header changes, I don't expect this 65 | to break anything. 66 | 67 | ## v1.3.0 - 2017-05-24 68 | 69 | * Enhancements 70 | * Allows configuration via app config (see [README.md](README.md), thanks 71 | @TokiTori). 72 | 73 | ## v1.2.1 - 2017-02-07 74 | 75 | * Fixes 76 | * Match for exact origin only (thanks @somlor and @JordanAdams). 77 | * Add Vary to response header (thanks @linjunpop). 78 | 79 | ## v1.2.0 - 2017-02-07 80 | 81 | * Fixes 82 | * Remove cowboy dependency. Plug should be server-agnostic and this plug does 83 | not need cowboy. Thanks to @hauleth and @ewitchin for making me aware. 84 | 85 | As I changed dependency this is a minor release. I don't anticipate any 86 | regressions tho. 87 | 88 | ## v1.1.4 - 2017-05-24 89 | 90 | * Fixes 91 | * Add method parens to suppress Elixir 1.4.0 warnings (thanks @seivan). 92 | 93 | ## v1.1.3 - 2016-12-24 94 | 95 | * Enhancements 96 | * Support regex for `origin` (thanks @somlor) 97 | 98 | ## v1.1.2 - 2016-05-08 99 | 100 | * Enhancements 101 | * Allow client to set `allow-headers` by sending `request-headers` when using 102 | a wildcard. 103 | 104 | This enhancement is brought to you by @arathunku 105 | 106 | ## v1.1.1 - 2016-03-11 107 | 108 | * Fixes 109 | * Return "null" instead of null when no origin matches. 110 | 111 | Many thanks to @somlor for the fix! 112 | 113 | ## v1.1.0 - 2016-02-10 114 | 115 | * Enhancements 116 | * Allow multiple origins. When configuring you can now pass a list for 117 | `origins` (`plug: CORSPlug, origin: ~w(example1.com example2.com)`). 118 | * Fixes 119 | * `Access-Control-Expose-Headers` now works 120 | 121 | Both of these have been brought to you by @jer-k - many thanks! 122 | 123 | ## v1.0.0 - 2016-01-22 124 | 125 | * Fixes 126 | * Don't override headers. Earlier headers would've been overridden by the 127 | CORS Plug. Amazing that this hasn't popped up before... 128 | 129 | As this makes a backward-incompatible change (no longer overriding headers 130 | this is a new major). 131 | 132 | ## v0.1.4 - 2015-09-24 133 | 134 | * Enhancements 135 | * Add [`Access-Control-Expose-Headers`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Access_control_CORS#Access-Control-Expose-Headers) (thanks @jaketrent) 136 | 137 | ## v0.1.3 - 2015-07-09 138 | 139 | * Enhancements 140 | * Add license 141 | * Improve readme (thanks @leighhalliday, @patricksrobertson) 142 | * Simplify travis.yml (thanks @lowks) 143 | 144 | ## v0.1.2 - 2015-02-12 145 | 146 | * Release plug dependency 147 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2020 Michael Schaefermeyer 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. 14 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # CorsPlug 2 | 3 | [![CI](https://github.com/mschae/cors_plug/workflows/Tests/badge.svg)](https://github.com/mschae/cors_plug/actions?query=workflow%3ATests) 4 | [![Module Version](https://img.shields.io/hexpm/v/cors_plug.svg)](https://hex.pm/packages/cors_plug) 5 | [![Hex Docs](https://img.shields.io/badge/hex-docs-lightgreen.svg)](https://hexdocs.pm/cors_plug/) 6 | [![Total Download](https://img.shields.io/hexpm/dt/cors_plug.svg)](https://hex.pm/packages/cors_plug) 7 | [![License](https://img.shields.io/hexpm/l/cors_plug.svg)](https://github.com/mschae/cors_plug/blob/main/LICENSE) 8 | [![Last Updated](https://img.shields.io/github/last-commit/mschae/cors_plug.svg)](https://github.com/mschae/cors_plug/commits/main) 9 | 10 | An [Elixir Plug](http://github.com/elixir-lang/plug) to add [Cross-Origin Resource Sharing (CORS)](http://www.w3.org/TR/cors/). 11 | 12 | ## Usage 13 | 14 | Add this plug to your `mix.exs` dependencies: 15 | 16 | ```elixir 17 | def deps do 18 | # ... 19 | {:cors_plug, "~> 3.0"}, 20 | #... 21 | end 22 | ``` 23 | 24 | When used together with the awesomeness that's the [Phoenix Framework](http://www.phoenixframework.org/) 25 | please note that putting the `CORSPlug` in a pipeline won't work as they are only invoked for 26 | matched routes. 27 | 28 | I therefore recommend to put it in `lib/your_app/endpoint.ex`: 29 | 30 | ```elixir 31 | defmodule YourApp.Endpoint do 32 | use Phoenix.Endpoint, otp_app: :your_app 33 | 34 | # ... 35 | plug CORSPlug 36 | 37 | plug YourApp.Router 38 | end 39 | ``` 40 | 41 | Alternatively you can add options routes to your scope and `CORSPlug` to your pipeline, as 42 | suggested by @leighhalliday 43 | 44 | ```elixir 45 | pipeline :api do 46 | plug CORSPlug 47 | # ... 48 | end 49 | 50 | scope "/api", PhoenixApp do 51 | pipe_through :api 52 | 53 | resources "/articles", ArticleController 54 | options "/articles", ArticleController, :options 55 | options "/articles/:id", ArticleController, :options 56 | end 57 | ``` 58 | 59 | ## Compatibility 60 | 61 | Whenever I get around to, I will bump the plug dependency to the latest version 62 | of plug. This will ensure compatibility with the latest plug versions. 63 | 64 | As of Elixir and Open Telecom Platform (OTP), my goal is to test against the three most recent versions respectively. 65 | 66 | ## Configuration 67 | 68 | This plug will return the following headers: 69 | 70 | On preflight (`OPTIONS`) requests: 71 | 72 | * Access-Control-Allow-Origin 73 | * Access-Control-Allow-Credentials 74 | * Access-Control-Max-Age 75 | * Access-Control-Allow-Headers 76 | * Access-Control-Allow-Methods 77 | 78 | On `GET`, `POST`, etc. requests: 79 | 80 | * Access-Control-Allow-Origin 81 | * Access-Control-Expose-Headers 82 | * Access-Control-Allow-Credentials 83 | 84 | You can configure allowed origins using one of the following methods: 85 | 86 | ### Using a list 87 | 88 | **Lists can now be comprised of strings, regexes or a mix of both:** 89 | 90 | ```elixir 91 | plug CORSPlug, origin: ["http://example1.com", "http://example2.com", ~r/https?.*example\d?\.com$/] 92 | ``` 93 | 94 | ### Using a regex 95 | 96 | ```elixir 97 | plug CORSPlug, origin: ~r/https?.*example\d?\.com$/ 98 | ``` 99 | 100 | 101 | ### Using the config.exs file 102 | 103 | ```elixir 104 | config :cors_plug, 105 | origin: ["http://example.com"], 106 | max_age: 86400, 107 | methods: ["GET", "POST"] 108 | ``` 109 | 110 | ### Using a `function/0` or `function/1` that returns the allowed origin as a string 111 | 112 | **Caveat: Anonymous functions are not possible as they can't be quoted.** 113 | 114 | ```elixir 115 | plug CORSPlug, origin: &MyModule.my_fun/0 116 | 117 | def my_fun do 118 | ["http://example.com"] 119 | end 120 | ``` 121 | 122 | ```elixir 123 | plug CORSPlug, origin: &MyModule.my_fun/1 124 | 125 | def my_fun(conn) do 126 | # Do something with conn 127 | 128 | ["http://example.com"] 129 | end 130 | ``` 131 | 132 | ### send_preflight_response? 133 | 134 | There may be times when you would like to retain control over the response sent to OPTIONS requests. If you 135 | would like CORSPlug to only set headers, then set the `send_preflight_response?` option to false. 136 | 137 | ```elixir 138 | plug CORSPlug, send_preflight_response?: false 139 | 140 | # or in the app config 141 | 142 | config :cors_plug, 143 | send_preflight_response?: false 144 | ``` 145 | 146 | Please note that options passed to the plug overrides app config but app config 147 | overrides default options. 148 | 149 | Please find the list of current defaults in 150 | [cors_plug.ex](lib/cors_plug.ex#L5:L26). 151 | 152 | **As per the [W3C Recommendation](https://www.w3.org/TR/cors/#access-control-allow-origin-response-header) 153 | the string `null` is returned when no configured origin matched the request.** 154 | 155 | 156 | ## License 157 | 158 | Copyright 2020 Michael Schaefermeyer 159 | 160 | Licensed under the Apache License, Version 2.0 (the "License"); 161 | you may not use this file except in compliance with the License. 162 | You may obtain a copy of the License at 163 | 164 | http://www.apache.org/licenses/LICENSE-2.0 165 | 166 | Unless required by applicable law or agreed to in writing, software 167 | distributed under the License is distributed on an "AS IS" BASIS, 168 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 169 | See the License for the specific language governing permissions and 170 | limitations under the License. 171 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | # This file is responsible for configuring your application 2 | # and its dependencies with the aid of the Mix.Config module. 3 | import Config 4 | 5 | # This configuration is loaded before any dependency and is restricted 6 | # to this project. If another project depends on this project, this 7 | # file won't be loaded nor affect the parent project. For this reason, 8 | # if you want to provide default values for your application for third- 9 | # party users, it should be done in your mix.exs file. 10 | 11 | # Sample configuration: 12 | # 13 | # config :logger, :console, 14 | # level: :info, 15 | # format: "$date $time [$level] $metadata$message\n", 16 | # metadata: [:user_id] 17 | 18 | # It is also possible to import configuration files, relative to this 19 | # directory. For example, you can emulate configuration per environment 20 | # by uncommenting the line below and defining dev.exs, test.exs and such. 21 | # Configuration from the imported file will override the ones defined 22 | # here (which is why it is important to import them last). 23 | # 24 | # import_config "#{Mix.env}.exs" 25 | 26 | config :plug, :validate_header_keys_during_test, true 27 | -------------------------------------------------------------------------------- /lib/cors_plug.ex: -------------------------------------------------------------------------------- 1 | defmodule CORSPlug do 2 | import Plug.Conn 3 | 4 | def defaults do 5 | [ 6 | origin: "*", 7 | credentials: true, 8 | max_age: 1_728_000, 9 | headers: [ 10 | "Authorization", 11 | "Content-Type", 12 | "Accept", 13 | "Origin", 14 | "User-Agent", 15 | "DNT", 16 | "Cache-Control", 17 | "X-Mx-ReqToken", 18 | "Keep-Alive", 19 | "X-Requested-With", 20 | "If-Modified-Since", 21 | "X-CSRF-Token" 22 | ], 23 | expose: [], 24 | methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"], 25 | send_preflight_response?: true 26 | ] 27 | end 28 | 29 | @doc false 30 | def call(conn, options) do 31 | case {options[:send_preflight_response?], conn.method, get_req_header(conn, "access-control-request-method")} do 32 | # Not a CORS preflight request 33 | {_, "OPTIONS", []} -> 34 | conn 35 | 36 | {true, "OPTIONS", _} -> 37 | conn 38 | |> merge_resp_headers(add_cors_headers(conn, options)) 39 | |> send_resp(204, "") 40 | |> halt() 41 | 42 | {false, "OPTIONS", _} -> 43 | merge_resp_headers(conn, add_cors_headers(conn, options)) 44 | 45 | {_, _, _} -> 46 | merge_resp_headers(conn, add_cors_headers(conn, options)) 47 | end 48 | end 49 | 50 | @doc false 51 | def init(options) do 52 | options 53 | |> prepare_cfg(Application.get_all_env(:cors_plug)) 54 | |> Keyword.update!(:expose, &Enum.join(&1, ",")) 55 | |> Keyword.update!(:methods, &Enum.join(&1, ",")) 56 | end 57 | 58 | defp prepare_cfg(options, env) do 59 | defaults() 60 | |> Keyword.merge(env) 61 | |> Keyword.merge(options) 62 | end 63 | 64 | # headers specific to OPTIONS request 65 | defp add_cors_headers(conn = %Plug.Conn{method: "OPTIONS"}, options) do 66 | add_cors_headers(%{conn | method: nil}, options) ++ 67 | [ 68 | {"access-control-max-age", "#{options[:max_age]}"}, 69 | {"access-control-allow-headers", allowed_headers(options[:headers], conn)}, 70 | {"access-control-allow-methods", options[:methods]} 71 | ] 72 | end 73 | 74 | # universal headers 75 | defp add_cors_headers(conn, options) do 76 | allowed_origin = origin(options[:origin], conn) 77 | vary_header = vary_header(allowed_origin, get_resp_header(conn, "vary")) 78 | 79 | vary_header ++ cors_headers(allowed_origin, options) 80 | end 81 | 82 | # When the origin doesn't match, dont send CORS headers 83 | defp cors_headers(nil, _options) do 84 | [] 85 | end 86 | 87 | defp cors_headers(allowed_origin, options) do 88 | headers = [ 89 | {"access-control-allow-origin", allowed_origin}, 90 | {"access-control-expose-headers", options[:expose]} 91 | ] 92 | 93 | if options[:credentials] do 94 | [{"access-control-allow-credentials", "true"} | headers] 95 | else 96 | headers 97 | end 98 | end 99 | 100 | # Allow all requested headers 101 | defp allowed_headers(["*"], conn) do 102 | case get_req_header(conn, "access-control-request-headers") do 103 | [first | _tail] -> first 104 | _ -> "" 105 | end 106 | end 107 | 108 | defp allowed_headers(key, _conn) do 109 | Enum.join(key, ",") 110 | end 111 | 112 | # return origin if it matches regex, otherwise nil 113 | defp origin(%Regex{} = regex, conn) do 114 | req_origin = conn |> request_origin() |> to_string() 115 | 116 | if origins_match?(req_origin, regex), do: req_origin, else: nil 117 | end 118 | 119 | # get value if origin is a function 120 | defp origin(fun, conn) when is_function(fun) do 121 | case Function.info(fun, :arity) do 122 | {:arity, 0} -> 123 | origin(fun.(), conn) 124 | 125 | {:arity, 1} -> 126 | origin(fun.(conn), conn) 127 | 128 | {:arity, arity} -> 129 | raise """ 130 | Passing a function with arity #{arity} is not supported. Please use 131 | one with arity 0 or 1 (in which case it will be passed the `conn`). 132 | """ 133 | end 134 | end 135 | 136 | # normalize non-list to list 137 | defp origin(key, conn) when not is_list(key) do 138 | key 139 | |> List.wrap() 140 | |> origin(conn) 141 | end 142 | 143 | # whitelist internal requests 144 | defp origin([:self], conn) do 145 | request_origin(conn) || "*" 146 | end 147 | 148 | # return "*" if origin list is ["*"] 149 | defp origin(["*"], _conn) do 150 | "*" 151 | end 152 | 153 | defp origin(origins, conn) when is_list(origins) do 154 | req_origin = conn |> request_origin() |> to_string() 155 | 156 | cond do 157 | origin_in_list?(req_origin, origins) -> req_origin 158 | "*" in origins -> "*" 159 | true -> nil 160 | end 161 | end 162 | 163 | def origin_in_list?(req_origin, origins) do 164 | Enum.any?(origins, &origins_match?(req_origin, &1)) 165 | end 166 | 167 | def origins_match?(req_origin, origin) when is_binary(origin) do 168 | req_origin == origin 169 | end 170 | 171 | def origins_match?(req_origin, %Regex{} = origin) do 172 | req_origin =~ origin 173 | end 174 | 175 | defp request_origin(%Plug.Conn{req_headers: headers}) do 176 | Enum.find_value(headers, fn {k, v} -> k =~ ~r/^origin$/i && v end) 177 | end 178 | 179 | # Set the Vary response header 180 | # see: https://www.w3.org/TR/cors/#resource-implementation 181 | defp vary_header("*", _headers), do: [] 182 | defp vary_header(nil, _headers), do: [] 183 | defp vary_header(_allowed_origin, []), do: [{"vary", "Origin"}] 184 | 185 | defp vary_header(_allowed_origin, headers) do 186 | vary = Enum.join(["Origin" | headers], ", ") 187 | 188 | [{"vary", vary}] 189 | end 190 | end 191 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule CorsPlug.Mixfile do 2 | use Mix.Project 3 | 4 | @source_url "https://github.com/mschae/cors_plug" 5 | @version "3.0.3" 6 | 7 | def project do 8 | [ 9 | app: :cors_plug, 10 | version: @version, 11 | elixir: "~> 1.11", 12 | deps: deps(), 13 | package: package(), 14 | description: description(), 15 | docs: [ 16 | extras: ~W(CHANGELOG.md README.md), 17 | main: "readme", 18 | api_reference: false, 19 | source_url: @source_url, 20 | source_ref: "v#{@version}" 21 | ] 22 | ] 23 | end 24 | 25 | def application do 26 | [extra_applications: [:logger]] 27 | end 28 | 29 | defp deps do 30 | [ 31 | {:plug, "~> 1.13"}, 32 | {:ex_doc, "~> 0.28.0", only: :dev, runtime: false}, 33 | {:mix_test_watch, "~> 1.1", only: :test} 34 | ] 35 | end 36 | 37 | defp description do 38 | """ 39 | An Elixir Plug that adds Cross-Origin Resource Sharing (CORS) headers to 40 | requests and responds to preflight requests (OPTIONS). 41 | """ 42 | end 43 | 44 | defp package do 45 | [ 46 | files: ~w(lib mix.exs README.md LICENSE CHANGELOG.md), 47 | maintainers: ["Michael Schaefermeyer"], 48 | licenses: ["Apache-2.0"], 49 | links: %{ 50 | "Changelog" => "https://hexdocs.pm/cors_plug/changelog.html", 51 | "Github" => @source_url 52 | } 53 | ] 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "cowboy": {:hex, :cowboy, "1.0.4", "a324a8df9f2316c833a470d918aaf73ae894278b8aa6226ce7a9bf699388f878", [:make, :rebar], [{:cowlib, "~> 1.0.0", [hex: :cowlib, optional: false]}, {:ranch, "~> 1.0", [hex: :ranch, optional: false]}]}, 3 | "cowlib": {:hex, :cowlib, "1.0.2", "9d769a1d062c9c3ac753096f868ca121e2730b9a377de23dec0f7e08b1df84ee", [:make], []}, 4 | "earmark": {:hex, :earmark, "1.4.20", "d5097b1c7417a03c73a2985fcf01c3f72192c427b8a498719737dca5273938cb", [:mix], [{:earmark_parser, "== 1.4.18", [hex: :earmark_parser, repo: "hexpm", optional: false]}], "hexpm", "7be744242dbde74c858279f4a65d9d31f37d163190d739340015c30038c1edb3"}, 5 | "earmark_parser": {:hex, :earmark_parser, "1.4.20", "89970db71b11b6b89759ce16807e857df154f8df3e807b2920a8c39834a9e5cf", [:mix], [], "hexpm", "1eb0d2dabeeeff200e0d17dc3048a6045aab271f73ebb82e416464832eb57bdd"}, 6 | "ex_doc": {:hex, :ex_doc, "0.28.2", "e031c7d1a9fc40959da7bf89e2dc269ddc5de631f9bd0e326cbddf7d8085a9da", [:mix], [{:earmark_parser, "~> 1.4.19", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1", [hex: :makeup_erlang, repo: "hexpm", optional: false]}], "hexpm", "51ee866993ffbd0e41c084a7677c570d0fc50cb85c6b5e76f8d936d9587fa719"}, 7 | "file_system": {:hex, :file_system, "0.2.10", "fb082005a9cd1711c05b5248710f8826b02d7d1784e7c3451f9c1231d4fc162d", [:mix], [], "hexpm", "41195edbfb562a593726eda3b3e8b103a309b733ad25f3d642ba49696bf715dc"}, 8 | "fs": {:hex, :fs, "0.9.2", "ed17036c26c3f70ac49781ed9220a50c36775c6ca2cf8182d123b6566e49ec59", [:rebar], [], "hexpm"}, 9 | "makeup": {:hex, :makeup, "1.1.0", "6b67c8bc2882a6b6a445859952a602afc1a41c2e08379ca057c0f525366fc3ca", [:mix], [{:nimble_parsec, "~> 1.2.2 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "0a45ed501f4a8897f580eabf99a2e5234ea3e75a4373c8a52824f6e873be57a6"}, 10 | "makeup_elixir": {:hex, :makeup_elixir, "0.15.2", "dc72dfe17eb240552857465cc00cce390960d9a0c055c4ccd38b70629227e97c", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.1", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "fd23ae48d09b32eff49d4ced2b43c9f086d402ee4fd4fcb2d7fad97fa8823e75"}, 11 | "makeup_erlang": {:hex, :makeup_erlang, "0.1.1", "3fcb7f09eb9d98dc4d208f49cc955a34218fc41ff6b84df7c75b3e6e533cc65f", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "174d0809e98a4ef0b3309256cbf97101c6ec01c4ab0b23e926a9e17df2077cbb"}, 12 | "mime": {:hex, :mime, "2.0.2", "0b9e1a4c840eafb68d820b0e2158ef5c49385d17fb36855ac6e7e087d4b1dcc5", [:mix], [], "hexpm", "e6a3f76b4c277739e36c2e21a2c640778ba4c3846189d5ab19f97f126df5f9b7"}, 13 | "mix_test_watch": {:hex, :mix_test_watch, "1.1.0", "330bb91c8ed271fe408c42d07e0773340a7938d8a0d281d57a14243eae9dc8c3", [:mix], [{:file_system, "~> 0.2.1 or ~> 0.3", [hex: :file_system, repo: "hexpm", optional: false]}], "hexpm", "52b6b1c476cbb70fd899ca5394506482f12e5f6b0d6acff9df95c7f1e0812ec3"}, 14 | "nimble_parsec": {:hex, :nimble_parsec, "1.2.3", "244836e6e3f1200c7f30cb56733fd808744eca61fd182f731eac4af635cc6d0b", [:mix], [], "hexpm", "c8d789e39b9131acf7b99291e93dae60ab48ef14a7ee9d58c6964f59efb570b0"}, 15 | "plug": {:hex, :plug, "1.13.3", "93b299039c21a8b82cc904d13812bce4ced45cf69153e8d35ca16ffb3e8c5d98", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "98c8003e4faf7b74a9ac41bee99e328b08f069bf932747d4a7532e97ae837a17"}, 16 | "plug_crypto": {:hex, :plug_crypto, "1.2.2", "05654514ac717ff3a1843204b424477d9e60c143406aa94daf2274fdd280794d", [:mix], [], "hexpm", "87631c7ad914a5a445f0a3809f99b079113ae4ed4b867348dd9eec288cecb6db"}, 17 | "ranch": {:hex, :ranch, "1.2.1", "a6fb992c10f2187b46ffd17ce398ddf8a54f691b81768f9ef5f461ea7e28c762", [:make], []}, 18 | "telemetry": {:hex, :telemetry, "1.0.0", "0f453a102cdf13d506b7c0ab158324c337c41f1cc7548f0bc0e130bbf0ae9452", [:rebar3], [], "hexpm", "73bc09fa59b4a0284efb4624335583c528e07ec9ae76aca96ea0673850aec57a"}, 19 | } 20 | -------------------------------------------------------------------------------- /test/cors_plug_test.exs: -------------------------------------------------------------------------------- 1 | defmodule CORSPlugTest do 2 | use ExUnit.Case, async: true 3 | use Plug.Test 4 | import Plug.Conn, only: [get_resp_header: 2, put_req_header: 3] 5 | 6 | test "returns the right options for regular requests" do 7 | opts = CORSPlug.init([]) 8 | 9 | conn = 10 | :get 11 | |> conn("/") 12 | |> CORSPlug.call(opts) 13 | 14 | assert ["*"] == get_resp_header(conn, "access-control-allow-origin") 15 | end 16 | 17 | test "lets me overwrite options" do 18 | opts = CORSPlug.init(origin: "http://example.com") 19 | 20 | conn = 21 | :get 22 | |> conn("/") 23 | |> put_req_header("origin", "http://example.com") 24 | |> CORSPlug.call(opts) 25 | 26 | assert ["http://example.com"] == get_resp_header(conn, "access-control-allow-origin") 27 | end 28 | 29 | test "halts and returns https status 204 for options requests by default" do 30 | opts = CORSPlug.init([]) 31 | 32 | conn = 33 | :options 34 | |> conn("/") 35 | |> put_req_header("access-control-request-method", "GET") 36 | |> CORSPlug.call(opts) 37 | 38 | assert %Plug.Conn{halted: true, status: 204, state: :sent, resp_body: ""} = conn 39 | end 40 | 41 | test "lets me set options requests to not be halted" do 42 | opts = CORSPlug.init(send_preflight_response?: false) 43 | 44 | conn = 45 | :options 46 | |> conn("/") 47 | |> put_req_header("access-control-request-method", "GET") 48 | |> CORSPlug.call(opts) 49 | 50 | assert %Plug.Conn{halted: false, status: nil, state: :unset, resp_body: nil} = conn 51 | end 52 | 53 | test "lets me call a function to resolve origin on every request" do 54 | opts = CORSPlug.init(origin: fn -> "http://example.com" end) 55 | 56 | conn = 57 | :get 58 | |> conn("/") 59 | |> put_req_header("origin", "http://example.com") 60 | |> CORSPlug.call(opts) 61 | 62 | assert ["http://example.com"] == get_resp_header(conn, "access-control-allow-origin") 63 | end 64 | 65 | test "lets me call a function with conn as a arg to resolve origin on every request" do 66 | opts = CORSPlug.init(origin: fn _conn -> "http://example.com" end) 67 | 68 | conn = 69 | :get 70 | |> conn("/") 71 | |> put_req_header("origin", "http://example.com") 72 | |> CORSPlug.call(opts) 73 | 74 | assert ["http://example.com"] == get_resp_header(conn, "access-control-allow-origin") 75 | end 76 | 77 | test "raises when I call a function with arity > 1" do 78 | assert_raise RuntimeError, fn -> 79 | opts = CORSPlug.init(origin: fn _conn, _what? -> "http://example.com" end) 80 | 81 | :get 82 | |> conn("/") 83 | |> put_req_header("origin", "http://example.com") 84 | |> CORSPlug.call(opts) 85 | end 86 | end 87 | 88 | test "passes all the relevant headers on an options request" do 89 | opts = CORSPlug.init([]) 90 | 91 | conn = 92 | :options 93 | |> conn("/") 94 | |> put_req_header("access-control-request-method", "GET") 95 | |> CORSPlug.call(opts) 96 | 97 | required_headers = [ 98 | "access-control-allow-origin", 99 | "access-control-expose-headers", 100 | "access-control-allow-credentials", 101 | "access-control-max-age", 102 | "access-control-allow-headers", 103 | "access-control-allow-methods" 104 | ] 105 | 106 | for header <- required_headers do 107 | assert header in Enum.map(conn.resp_headers, fn {k, _} -> k end) 108 | end 109 | end 110 | 111 | test "does not include allow-credentials if set to false" do 112 | opts = CORSPlug.init(origin: "http://example.com", credentials: false) 113 | 114 | conn = :get |> conn("/") |> put_req_header("origin", "http://example.com") 115 | 116 | conn = CORSPlug.call(conn, opts) 117 | refute "access-control-allow-credentials" in Enum.map(conn.resp_headers, fn {k, _} -> k end) 118 | end 119 | 120 | test "returns the origin when origin is equal to origin option string" do 121 | opts = CORSPlug.init(origin: "http://example1.com") 122 | 123 | conn = 124 | :get 125 | |> conn("/") 126 | |> put_req_header("origin", "http://example1.com") 127 | 128 | conn = CORSPlug.call(conn, opts) 129 | assert assert ["http://example1.com"] == get_resp_header(conn, "access-control-allow-origin") 130 | end 131 | 132 | test "returns no cors header when origin is not equal to origin option string" do 133 | opts = CORSPlug.init(origin: "http://example1.com") 134 | 135 | conn = 136 | :get 137 | |> conn("/") 138 | |> put_req_header("origin", "http://example2.com") 139 | 140 | conn = CORSPlug.call(conn, opts) 141 | assert [] == get_resp_header(conn, "access-control-allow-origin") 142 | end 143 | 144 | test "returns the origin when origin is in origin option list" do 145 | opts = CORSPlug.init(origin: ["http://example1.com", "http://example2.com", "*"]) 146 | 147 | conn = 148 | :get 149 | |> conn("/") 150 | |> put_req_header("origin", "http://example2.com") 151 | 152 | conn = CORSPlug.call(conn, opts) 153 | assert assert ["http://example2.com"] == get_resp_header(conn, "access-control-allow-origin") 154 | end 155 | 156 | test "returns * string when the origin * in the list" do 157 | opts = CORSPlug.init(origin: ["http://example1.com", "*"]) 158 | 159 | conn = 160 | :get 161 | |> conn("/") 162 | |> put_req_header("origin", "http://example2.com") 163 | 164 | conn = CORSPlug.call(conn, opts) 165 | assert ["*"] == get_resp_header(conn, "access-control-allow-origin") 166 | end 167 | 168 | test "returns no CORS header when origin is not in origin option list" do 169 | opts = CORSPlug.init(origin: ["http://example1.com"]) 170 | 171 | conn = 172 | :get 173 | |> conn("/") 174 | |> put_req_header("origin", "http://example2.com") 175 | 176 | conn = CORSPlug.call(conn, opts) 177 | assert [] == get_resp_header(conn, "access-control-allow-origin") 178 | end 179 | 180 | test "returns the origin when origin matches origin option regex" do 181 | opts = CORSPlug.init(origin: ~r/^example.+\.com$/) 182 | 183 | conn = 184 | :get 185 | |> conn("/") 186 | |> put_req_header("origin", "example42.com") 187 | |> CORSPlug.call(opts) 188 | 189 | assert assert ["example42.com"] == get_resp_header(conn, "access-control-allow-origin") 190 | end 191 | 192 | test "returns no CORS header when origin is null and origin option is regex" do 193 | opts = CORSPlug.init(origin: ~r/^example.+\.com$/) 194 | 195 | conn = 196 | :get 197 | |> conn("/") 198 | |> CORSPlug.call(opts) 199 | 200 | assert [] == get_resp_header(conn, "access-control-allow-origin") 201 | end 202 | 203 | test "returns no CORS header when origin is null and origin option is a list containing a regex" do 204 | opts = CORSPlug.init(origin: [~r/^example.+\.com$/]) 205 | 206 | conn = 207 | :get 208 | |> conn("/") 209 | |> CORSPlug.call(opts) 210 | 211 | assert [] == get_resp_header(conn, "access-control-allow-origin") 212 | end 213 | 214 | test "returns no CORS header when origin does not match origin option regex" do 215 | opts = CORSPlug.init(origin: ~r/^example.+\.com$/) 216 | 217 | conn = 218 | :get 219 | |> conn("/") 220 | |> put_req_header("origin", "null-example42.com") 221 | |> CORSPlug.call(opts) 222 | 223 | assert [] == get_resp_header(conn, "access-control-allow-origin") 224 | end 225 | 226 | test "returns the request host when origin is :self" do 227 | opts = CORSPlug.init(origin: [:self]) 228 | 229 | conn = 230 | :get 231 | |> conn("/") 232 | |> put_req_header("origin", "http://cors-plug.example") 233 | |> CORSPlug.call(opts) 234 | 235 | assert ["http://cors-plug.example"] == get_resp_header(conn, "access-control-allow-origin") 236 | end 237 | 238 | test "uses exact match origin header" do 239 | opts = CORSPlug.init(origin: "example1.com") 240 | 241 | conn = 242 | :get 243 | |> conn("/") 244 | |> put_req_header("x-origin", "example0.com") 245 | |> put_req_header("origin", "example1.com") 246 | |> put_req_header("original", "example2.com") 247 | |> CORSPlug.call(opts) 248 | 249 | assert ["example1.com"] == get_resp_header(conn, "access-control-allow-origin") 250 | end 251 | 252 | test "exposed headers are returned" do 253 | opts = CORSPlug.init(expose: ["content-range", "content-length", "accept-ranges"]) 254 | 255 | conn = 256 | :options 257 | |> conn("/") 258 | |> put_req_header("access-control-request-method", "GET") 259 | |> CORSPlug.call(opts) 260 | 261 | assert get_resp_header(conn, "access-control-expose-headers") == 262 | ["content-range,content-length,accept-ranges"] 263 | end 264 | 265 | test "allows all incoming headers" do 266 | headers = "custom-header,upgrade-insecure-requests" 267 | opts = CORSPlug.init(headers: ["*"]) 268 | 269 | conn = 270 | :options 271 | |> conn("/") 272 | |> put_req_header("access-control-request-headers", headers) 273 | |> put_req_header("access-control-request-method", "GET") 274 | |> CORSPlug.call(opts) 275 | 276 | assert get_resp_header(conn, "access-control-allow-headers") == 277 | ["custom-header,upgrade-insecure-requests"] 278 | end 279 | 280 | test "handles missing access-control-request-headers" do 281 | opts = CORSPlug.init(headers: ["*"]) 282 | 283 | conn = 284 | :options 285 | |> conn("/") 286 | |> put_req_header("access-control-request-method", "GET") 287 | |> CORSPlug.call(opts) 288 | 289 | assert get_resp_header(conn, "access-control-allow-headers") == 290 | [""] 291 | end 292 | 293 | test "dont include Origin in Vary response header if the Origin doesn't match" do 294 | opts = CORSPlug.init(origin: "http://example.com") 295 | 296 | conn = 297 | :get 298 | |> conn("/") 299 | |> put_req_header("origin", "null-example42.com") 300 | |> CORSPlug.call(opts) 301 | 302 | assert [] == get_resp_header(conn, "vary") 303 | end 304 | 305 | test "dont include Origin in Vary response header if the Access-Control-Allow-Origin is `*`" do 306 | opts = CORSPlug.init(origin: "*") 307 | 308 | conn = 309 | :get 310 | |> conn("/") 311 | |> put_req_header("origin", "null-example42.com") 312 | |> CORSPlug.call(opts) 313 | 314 | assert [] == get_resp_header(conn, "vary") 315 | end 316 | 317 | test "dont change Vary response header if the Access-Control-Allow-Origin is `*`" do 318 | opts = CORSPlug.init(origin: "*") 319 | 320 | conn = 321 | :get 322 | |> conn("/") 323 | |> put_req_header("origin", "null-example42.com") 324 | |> Plug.Conn.put_resp_header("vary", "User-Agent") 325 | |> CORSPlug.call(opts) 326 | 327 | assert ["User-Agent"] == get_resp_header(conn, "vary") 328 | end 329 | 330 | test "prepend Origin in Vary response header if the Origin matches and Vary header was set" do 331 | opts = CORSPlug.init(origin: "http://example.com") 332 | 333 | conn = 334 | :get 335 | |> conn("/") 336 | |> put_req_header("origin", "http://example.com") 337 | |> Plug.Conn.put_resp_header("vary", "User-Agent") 338 | |> CORSPlug.call(opts) 339 | 340 | assert ["Origin, User-Agent"] == get_resp_header(conn, "vary") 341 | end 342 | 343 | test "allowed methods in options are properly returned" do 344 | opts = CORSPlug.init(methods: ~w[GET POST]) 345 | 346 | conn = 347 | :options 348 | |> conn("/") 349 | |> put_req_header("access-control-request-method", "GET") 350 | |> CORSPlug.call(opts) 351 | 352 | allowed_methods = get_resp_header(conn, "access-control-allow-methods") 353 | assert allowed_methods == ["GET,POST"] 354 | end 355 | 356 | test "default allowed methods are properly returned" do 357 | opts = CORSPlug.init([]) 358 | 359 | conn = 360 | :options 361 | |> conn("/") 362 | |> put_req_header("access-control-request-method", "GET") 363 | |> CORSPlug.call(opts) 364 | 365 | allowed_methods = get_resp_header(conn, "access-control-allow-methods") 366 | assert allowed_methods == ["GET,POST,PUT,PATCH,DELETE,OPTIONS"] 367 | end 368 | 369 | test "expose headers in options are properly returned" do 370 | opts = CORSPlug.init(expose: ["X-My-Custom-Header", "X-Another-Custom-Header"]) 371 | 372 | conn = 373 | :get 374 | |> conn("/") 375 | |> CORSPlug.call(opts) 376 | 377 | expose_headers = get_resp_header(conn, "access-control-expose-headers") 378 | assert expose_headers == ["X-My-Custom-Header,X-Another-Custom-Header"] 379 | end 380 | 381 | test "allows to be configured via app config" do 382 | Application.put_env(:cors_plug, :headers, ["X-App-Config-Header"]) 383 | 384 | opts = CORSPlug.init([]) 385 | 386 | conn = 387 | :options 388 | |> conn("/") 389 | |> put_req_header("access-control-request-method", "GET") 390 | |> CORSPlug.call(opts) 391 | 392 | expose_headers = get_resp_header(conn, "access-control-allow-headers") 393 | assert expose_headers == ["X-App-Config-Header"] 394 | end 395 | 396 | test "init headers override app headers" do 397 | Application.put_env(:cors_plug, :headers, ["X-App-Config-Header"]) 398 | 399 | opts = CORSPlug.init(headers: ["X-Init-Config-Header"]) 400 | 401 | conn = 402 | :options 403 | |> conn("/") 404 | |> put_req_header("access-control-request-method", "GET") 405 | |> CORSPlug.call(opts) 406 | 407 | expose_headers = get_resp_header(conn, "access-control-allow-headers") 408 | assert expose_headers == ["X-Init-Config-Header"] 409 | end 410 | 411 | test "allows to mix regex and string in origin configuration" do 412 | opts = CORSPlug.init(origin: ["http://string.com", ~r/^regex.+\.com$/]) 413 | 414 | conn = 415 | :get 416 | |> conn("/") 417 | |> put_req_header("origin", "regex42.com") 418 | |> CORSPlug.call(opts) 419 | 420 | assert ["regex42.com"] == get_resp_header(conn, "access-control-allow-origin") 421 | 422 | conn = 423 | :get 424 | |> conn("/") 425 | |> put_req_header("origin", "http://string.com") 426 | |> CORSPlug.call(opts) 427 | 428 | assert ["http://string.com"] == get_resp_header(conn, "access-control-allow-origin") 429 | end 430 | 431 | test "don't process non-cors preflight requests" do 432 | opts = CORSPlug.init(origin: "http://example.com") 433 | 434 | conn = 435 | :options 436 | |> conn("/") 437 | |> put_req_header("origin", "http://example.com") 438 | |> CORSPlug.call(opts) 439 | 440 | assert [] == get_resp_header(conn, "access-control-allow-origin") 441 | end 442 | end 443 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | --------------------------------------------------------------------------------