├── test ├── test_helper.exs ├── spacesuit_test.exs ├── spacesuit_debug_middleware_test.exs ├── spacesuit_api_message_test.exs ├── mix_tasks_validate_routes_test.exs ├── spacesuit_auth_middleware_test.exs ├── spacesuit_router_test.exs ├── spacesuit_session_service_test.exs ├── spacesuit_proxy_handler_test.exs └── spacesuit_cors_middleware_test.exs ├── .dockerignore ├── lib ├── spacesuit │ ├── debug_middleware.ex │ ├── health_handler.ex │ ├── api_message.ex │ ├── supervisor.ex │ ├── http_client.ex │ ├── auth_middleware.ex │ ├── http_server.ex │ ├── router.ex │ ├── session_service.ex │ ├── proxy_handler.ex │ └── cors_middleware.ex ├── spacesuit.ex └── mix │ └── tasks │ └── validate_routes.ex ├── config ├── test.exs ├── dev.exs └── config.exs ├── docker ├── build.sh └── Dockerfile ├── .travis.yml ├── .gitignore ├── mix.exs ├── README.md └── mix.lock /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | erlang-*.tar.bz2 2 | v1.*.zip 3 | -------------------------------------------------------------------------------- /test/spacesuit_test.exs: -------------------------------------------------------------------------------- 1 | defmodule SpacesuitTest do 2 | use ExUnit.Case 3 | doctest Spacesuit 4 | 5 | end 6 | -------------------------------------------------------------------------------- /test/spacesuit_debug_middleware_test.exs: -------------------------------------------------------------------------------- 1 | defmodule SpacesuitDebugMiddlewareTest do 2 | use ExUnit.Case 3 | doctest Spacesuit.DebugMiddleware 4 | 5 | test "doesn't interfere with anything" do 6 | assert {:ok, %{}, %{}} = Spacesuit.DebugMiddleware.execute(%{}, %{}) 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /lib/spacesuit/debug_middleware.ex: -------------------------------------------------------------------------------- 1 | # Debugging middleware that just prints the environment for 2 | # every request 3 | 4 | defmodule Spacesuit.DebugMiddleware do 5 | require Logger 6 | 7 | def execute(req, env) do 8 | Logger.debug(inspect(req)) 9 | Logger.debug(inspect(env)) 10 | {:ok, req, env} 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /lib/spacesuit/health_handler.ex: -------------------------------------------------------------------------------- 1 | defmodule Spacesuit.HealthHandler do 2 | require Logger 3 | 4 | @http_server Application.get_env(:spacesuit, :http_server) 5 | 6 | # Callback from the Cowboy handler 7 | def init(req, state) do 8 | msg = 9 | Spacesuit.ApiMessage.encode(%Spacesuit.ApiMessage{ 10 | status: "ok", 11 | message: "Spacesuit running OK" 12 | }) 13 | 14 | @http_server.reply(200, %{}, msg, req) 15 | 16 | {:ok, req, state} 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /lib/spacesuit/api_message.ex: -------------------------------------------------------------------------------- 1 | # Encapsulate the API messages we hand back. Ideally 2 | # this would support a config option to return whatever 3 | # the API uses. i.e. protobuf, msgpack, JSON 4 | 5 | # Just does JSON for now 6 | defmodule Spacesuit.ApiMessage do 7 | @derive [Poison.Encoder] 8 | defstruct [:status, :message] 9 | 10 | def encode(api_message) do 11 | Poison.encode!(api_message) 12 | end 13 | 14 | def decode(str) do 15 | Poison.decode!(str, as: %Spacesuit.ApiMessage{}) 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /config/test.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | 3 | # Silence log output during tests unless it's an error 4 | config :spacesuit, http_client: Spacesuit.HttpClient.Mock 5 | config :spacesuit, http_server: Spacesuit.HttpServer.Mock 6 | 7 | config :spacesuit, jwt_secret: "secret" 8 | config :spacesuit, routes: %{} 9 | 10 | config :spacesuit, cors: %{ 11 | enabled: true, 12 | path_prefixes: ["/matched"], 13 | preflight_max_age: "3600", 14 | access_control_request_headers: ["X-Header1", "X-Header2"] 15 | } 16 | 17 | config :logger, level: :error 18 | -------------------------------------------------------------------------------- /docker/build.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash -e 2 | 3 | docker build -f docker/Dockerfile -t gonitro/spacesuit . 4 | if [[ $? -ne 0 ]]; then 5 | echo "Something went wrong, aborting container build" >&2 6 | exit 7 | fi 8 | 9 | # Either use the Travis tag (shortened), or get it from git 10 | TAG=${TRAVIS_COMMIT:-`git rev-parse --short HEAD`} 11 | TAG=${TAG::7} 12 | 13 | docker tag gonitro/spacesuit gonitro/spacesuit:latest 14 | docker tag gonitro/spacesuit gonitro/spacesuit:$TAG 15 | docker push gonitro/spacesuit:$TAG 16 | # Travis may push latest directly 17 | -------------------------------------------------------------------------------- /lib/spacesuit/supervisor.ex: -------------------------------------------------------------------------------- 1 | defmodule Spacesuit.Supervisor do 2 | use Supervisor 3 | 4 | def start_link do 5 | :supervisor.start_link(__MODULE__, []) 6 | end 7 | 8 | def init([]) do 9 | children = [ 10 | # Define workers and child supervisors to be supervised 11 | # worker(Spacesuit.Worker, []) 12 | ] 13 | 14 | # See http://elixir-lang.org/docs/stable/Supervisor.Behaviour.html 15 | # for other strategies and supported options 16 | supervise(children, strategy: :one_for_one) 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /test/spacesuit_api_message_test.exs: -------------------------------------------------------------------------------- 1 | defmodule SpacesuitApiMessageTest do 2 | use ExUnit.Case 3 | doctest Spacesuit.ApiMessage 4 | 5 | test "encodes messages properly" do 6 | msg = Spacesuit.ApiMessage.encode(%Spacesuit.ApiMessage{status: "error", message: "message"}) 7 | assert "{\"status\":" <> _ = msg 8 | end 9 | 10 | test "decodes messages properly" do 11 | msg = Spacesuit.ApiMessage.decode("{\"status\":\"error\",\"message\":\"message\"}") 12 | assert msg.status == "error" 13 | assert msg.message == "message" 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: elixir 2 | 3 | elixir: 4 | - 1.8.2 5 | 6 | otp_release: 7 | - 21.3.8 8 | 9 | sudo: required 10 | 11 | services: 12 | - docker 13 | 14 | script: 15 | - mix test --trace 16 | 17 | after_success: 18 | - docker login -u="$DOCKER_USERNAME" -p="$DOCKER_PASSWORD" 19 | - if [ "$TRAVIS_BRANCH" == "master" ]; then 20 | echo "Building container gonitro/spacesuit:${TRAVIS_COMMIT::7}"; 21 | docker/build.sh; 22 | fi 23 | - if [ "$TRAVIS_BRANCH" == "master" ] && [ -z "${NO_PUSH_LATEST}" ]; then 24 | docker push gonitro/spacesuit:latest; 25 | fi 26 | -------------------------------------------------------------------------------- /docker/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM elixir:1.8.2 2 | 3 | ADD . /spacesuit 4 | 5 | WORKDIR /spacesuit 6 | RUN rm -Rf {_build,data.nonode@nohost,log.nonode@nohost,deps,mix.lock,tmp} 7 | RUN rm -rf _build 8 | RUN mix local.rebar --force 9 | RUN mix local.hex --force 10 | RUN mix clean 11 | RUN mix deps.get 12 | RUN mix compile 13 | 14 | RUN wget https://github.com/redboxllc/scuttle/releases/download/v1.3.1/scuttle-linux-amd64.zip \ 15 | && unzip scuttle-linux-amd64.zip \ 16 | && mv scuttle /usr/bin \ 17 | && chmod +x /usr/bin/scuttle \ 18 | && rm scuttle-linux-amd64.zip 19 | 20 | EXPOSE 8080 21 | 22 | CMD [ "scuttle", "mix", "run", "--no-halt" ] 23 | -------------------------------------------------------------------------------- /.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 3rd-party dependencies like ExDoc output generated docs. 11 | /doc 12 | 13 | # If the VM crashes, it generates a dump, let's ignore it too. 14 | erl_crash.dump 15 | 16 | # Also ignore archive artifacts (built via "mix archive.build"). 17 | *.ez 18 | 19 | *.swp 20 | *.swo 21 | 22 | # Don't want the lager log/ directory 23 | /log 24 | 25 | # asdf files. 26 | /.tool-versions 27 | 28 | # Temporary files. 29 | /tmp -------------------------------------------------------------------------------- /lib/spacesuit.ex: -------------------------------------------------------------------------------- 1 | require Logger 2 | 3 | defmodule Spacesuit do 4 | use Application 5 | 6 | @moduledoc """ 7 | The main Spacesuit application module. 8 | """ 9 | 10 | @http_port 8080 11 | 12 | def start(_type, _args) do 13 | Logger.info("Spacesuit starting up on :#{@http_port}") 14 | 15 | dispatch = Spacesuit.Router.load_routes() |> :cowboy_router.compile() 16 | 17 | {:ok, _} = 18 | :cowboy.start_clear(:http, 600, [port: @http_port], %{ 19 | env: %{ 20 | dispatch: dispatch 21 | }, 22 | middlewares: [ 23 | # :cowboy_router, Spacesuit.DebugMiddleware, Spacesuit.AuthMiddleware, :cowboy_handler 24 | :cowboy_router, 25 | Spacesuit.CorsMiddleware, 26 | Spacesuit.AuthMiddleware, 27 | :cowboy_handler 28 | ] 29 | }) 30 | 31 | Spacesuit.Supervisor.start_link() 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /lib/spacesuit/http_client.ex: -------------------------------------------------------------------------------- 1 | defmodule HttpClient do 2 | @callback request(String.t(), String.t(), [tuple], String.t(), List.t()) :: any 3 | @callback stream_body(String.t()) :: any 4 | end 5 | 6 | defmodule Spacesuit.HttpClient.Hackney do 7 | @behaviour HttpClient 8 | 9 | def request(method, url, headers, body, pool) do 10 | :hackney.request(method, url, headers, body, pool) 11 | end 12 | 13 | def stream_body(body) do 14 | :hackney.stream_body(body) 15 | end 16 | end 17 | 18 | defmodule Spacesuit.HttpClient.Mock do 19 | @behaviour HttpClient 20 | 21 | # Implementation only matches expected params 22 | def request(_get, _url, _headers, "test body", _pool) do 23 | {:ok, true} 24 | end 25 | 26 | # Implementation only matches expected params 27 | def request(_get, _url, _headers, [], _pool) do 28 | {:ok, false} 29 | end 30 | 31 | def stream_body(:done) do 32 | :done 33 | end 34 | 35 | def stream_body(:error) do 36 | {:error, "testing: uh-oh"} 37 | end 38 | 39 | def stream_body(_body) do 40 | # TODO figure out a better way to keep state here to make sure we 41 | # trigger all the conditions all the time 42 | {result, _} = 43 | [:done, {:ok, "blank"}] 44 | |> Enum.shuffle() 45 | |> List.pop_at(0) 46 | 47 | result 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /config/dev.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | 3 | config :spacesuit, http_client: Spacesuit.HttpClient.Hackney 4 | config :spacesuit, http_server: Spacesuit.HttpServer.Cowboy 5 | 6 | # The secret used in validating JWT HMAC signatures 7 | config :spacesuit, jwt_secret: "secret" 8 | 9 | # Set up routes for Spacesuit. These are keyed by hostname matching 10 | # according to the rules defined by Cowboy: 11 | # https://ninenines.eu/docs/en/cowboy/1.0/guide/routing/ 12 | # 13 | # The content must be a list, to preserve route ordering since it's 14 | # a fall-through list. Generally you want a catch-all route at the 15 | # end of the list. 16 | 17 | config :spacesuit, routes: %{ 18 | "[...]:_" => [ # Match any hostname/port combination 19 | { "/users/:user_id", %{ 20 | description: "users to [::1]:9090", 21 | GET: "http://[::1]:9090/:user_id", # ipv6 localhost (thanks osx) 22 | POST: "http://[::1]:9000/:user_id", 23 | add_headers: %{ 24 | "X-Something-Special" => "Some value" 25 | } 26 | }}, 27 | 28 | {"/users/something/:user_id", %{ 29 | description: "users/something to [::1]:9090", 30 | GET: "http://[::1]:9090/something/:user_id" 31 | }}, 32 | 33 | {"/[...]", %{ 34 | description: "others to apple", 35 | destination: "https://www.apple.com" 36 | }} 37 | ] 38 | 39 | } 40 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Spacesuit.Mixfile do 2 | use Mix.Project 3 | use Mix.Config 4 | 5 | def build_embedded? do 6 | Mix.env() == :prod || Mix.env() == :dev 7 | end 8 | 9 | def project do 10 | [ 11 | app: :spacesuit, 12 | version: "0.1.0", 13 | elixir: "~> 1.4", 14 | build_embedded: build_embedded?(), 15 | start_permanent: Mix.env() == :prod, 16 | deps: deps(), 17 | test_coverage: [tool: ExCoveralls], 18 | preferred_cli_env: [coveralls: :test] 19 | ] 20 | end 21 | 22 | # Configuration for the OTP application 23 | # 24 | # Type "mix help compile.app" for more information 25 | def application do 26 | [ 27 | applications: [ 28 | :logger, 29 | :cowboy, 30 | :hackney, 31 | :crypto, 32 | :jose, 33 | :new_relic_agent 34 | ], 35 | mod: {Spacesuit, []} 36 | ] 37 | end 38 | 39 | # Dependencies can be Hex packages: 40 | # 41 | # {:mydep, "~> 0.3.0"} 42 | # 43 | # Or git/path repositories: 44 | # 45 | # {:mydep, git: "https://github.com/elixir-lang/mydep.git", tag: "0.1.0"} 46 | # 47 | # Type "mix help deps" for more examples and options 48 | defp deps do 49 | [ 50 | # All envs 51 | {:hackney, "~> 1.7.1", override: true}, 52 | {:cowboy, github: "extend/cowboy"}, 53 | {:poison, "~> 2.0"}, 54 | {:httpoison, "~> 0.9.0"}, 55 | {:jsx, "~> 2.8.0"}, 56 | {:joken, "~> 1.4.1"}, 57 | {:new_relic_agent, "~> 1.0"}, 58 | 59 | # Test only 60 | {:excoveralls, "~> 0.6", only: :test}, 61 | {:mock, "~> 0.3.5", only: :test}, 62 | {:apex, "~>1.1.0"} 63 | ] 64 | end 65 | end 66 | -------------------------------------------------------------------------------- /lib/spacesuit/auth_middleware.ex: -------------------------------------------------------------------------------- 1 | defmodule Spacesuit.AuthMiddleware do 2 | require Logger 3 | 4 | @http_server Application.get_env(:spacesuit, :http_server) 5 | 6 | def execute(req, env) do 7 | case req[:headers]["authorization"] do 8 | nil -> 9 | Logger.debug("No auth header") 10 | {:ok, req, env} 11 | 12 | "Bearer " <> token -> 13 | handle_bearer_token(session_service()[:enabled], req, env, token) 14 | 15 | authorization -> 16 | # TODO we got some header but we don't know what it is 17 | Logger.info("Found unknown authorization header! #{authorization}") 18 | {:ok, strip_auth(req), env} 19 | end 20 | end 21 | 22 | defp handle_bearer_token(session_service_enabled, request, environment, token) 23 | defp handle_bearer_token(false, req, env, _token), do: {:ok, req, env} 24 | 25 | defp handle_bearer_token(true, req, env, token) do 26 | if bypass_session_srv?(env) do 27 | case session_service()[:impl].validate_api_token(token) do 28 | :ok -> 29 | {:ok, req, env} 30 | 31 | # Otherwise we blow up the request 32 | unexpected -> 33 | Logger.error("auth_middleware error: unexpected response - #{inspect(unexpected)}") 34 | error_reply(req, 401, "Bad Authentication Token") 35 | {:stop, req} 36 | end 37 | else 38 | session_service()[:impl].handle_bearer_token(req, env, token, session_service()[:url]) 39 | end 40 | end 41 | 42 | defp handle_bearer_token(_, req, env, _token) do 43 | Logger.warn("Session service :enabled not configured!") 44 | {:ok, req, env} 45 | end 46 | 47 | defp strip_auth(req) do 48 | %{req | headers: Map.delete(req[:headers], "authorization")} 49 | end 50 | 51 | # should the session service be bypassed for this route? 52 | defp bypass_session_srv?(env) do 53 | case get_in(env, [:handler_opts, :middleware, :session_service]) do 54 | :disabled -> true 55 | _ -> false 56 | end 57 | end 58 | 59 | defp error_reply(req, code, message) do 60 | msg = Spacesuit.ApiMessage.encode(%Spacesuit.ApiMessage{status: "error", message: message}) 61 | @http_server.reply(code, %{}, msg, req) 62 | end 63 | 64 | # Quick access function for the application settings for this middleware 65 | def session_service do 66 | Application.get_env(:spacesuit, :session_service) 67 | end 68 | end 69 | -------------------------------------------------------------------------------- /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 | use Mix.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 9 | # 3rd-party users, it should be done in your "mix.exs" file. 10 | 11 | # You can configure for your application as: 12 | # 13 | # config :spacesuit, key: :value 14 | # 15 | # And access this configuration in your application as: 16 | # 17 | # Application.get_env(:spacesuit, :key) 18 | # 19 | # Or configure a 3rd-party app: 20 | # 21 | # config :logger, level: :info 22 | # 23 | 24 | # Turn off all file logging 25 | config :logger, backends: [:console] 26 | config :logger, level: String.to_atom(System.get_env("SPACESUIT_LOGGING_LEVEL") || "debug") 27 | 28 | # Get rid of the execessive line feeding and level padding in 29 | # the default Elixir logger. 30 | config :logger, :console, format: "$time $metadata[$level] $message\n" 31 | 32 | # Health route 33 | config :spacesuit, :health_route, %{path: "/health", enabled: true} 34 | 35 | # Do we call out to an external session service that can process JWT tokens? 36 | config :spacesuit, session_service: %{enabled: false} 37 | 38 | # Configuration for the CORS middleware 39 | config :spacesuit, 40 | cors: %{ 41 | # Main kill switch to disable the middleware 42 | enabled: false 43 | # Required prefix match in order to process CORS 44 | # path_prefixes: ["/matched"], 45 | # Maximum age for preflight requests 46 | # preflight_max_age: "3600", 47 | # Headers we validate for CORS 48 | # access_control_request_headers: ["X-Header1", "X-Header2"], 49 | # Allow access from any origin 50 | # any_origin_allowed: false, 51 | # Only allow CORS responses for these methods 52 | # allowed_http_methods: [:GET, :POST] 53 | } 54 | 55 | # It is also possible to import configuration files, relative to this 56 | # directory. For example, you can emulate configuration per environment 57 | # by uncommenting the line below and defining dev.exs, test.exs and such. 58 | # Configuration from the imported file will override the ones defined 59 | # here (which is why it is important to import them last). 60 | # 61 | import_config "#{Mix.env()}.exs" 62 | -------------------------------------------------------------------------------- /lib/spacesuit/http_server.ex: -------------------------------------------------------------------------------- 1 | defmodule HttpServer do 2 | @callback stream_reply(Integer.t(), List.t(), Map.t()) :: any 3 | @callback stream_body(String.t(), Integer.t(), any) :: any 4 | @callback reply(Integer.t(), List.t(), Atom.t(), any) :: any 5 | @callback has_body(Map.t()) :: Boolean.t() 6 | @callback read_body(Map.t()) :: any 7 | @callback body_length(Map.t()) :: Integer.t() 8 | @callback uri(Map.t()) :: String.t() 9 | @callback set_resp_headers(Map.t(), Map.t()) :: any 10 | end 11 | 12 | defmodule Spacesuit.HttpServer.Cowboy do 13 | @behaviour HttpServer 14 | 15 | def stream_reply(status, down_headers, req) do 16 | :cowboy_req.stream_reply(status, down_headers, req) 17 | end 18 | 19 | def stream_body(data, status, downstream) do 20 | :cowboy_req.stream_body(data, status, downstream) 21 | end 22 | 23 | def reply(code, headers, req) do 24 | :cowboy_req.reply(code, headers, req) 25 | end 26 | 27 | def reply(code, headers, msg, req) do 28 | :cowboy_req.reply(code, headers, msg, req) 29 | end 30 | 31 | def has_body(req) do 32 | :cowboy_req.has_body(req) 33 | end 34 | 35 | def read_body(req) do 36 | :cowboy_req.read_body(req) 37 | end 38 | 39 | def body_length(req) do 40 | :cowboy_req.body_length(req) 41 | end 42 | 43 | def uri(req) do 44 | :cowboy_req.uri(req) 45 | end 46 | 47 | def set_resp_headers(headers, req) do 48 | :cowboy_req.set_resp_headers(headers, req) 49 | end 50 | end 51 | 52 | defmodule Spacesuit.HttpServer.Mock do 53 | @behaviour HttpServer 54 | 55 | def stream_reply(_status, _down_headers, _req) do 56 | send self(), {:reply, :stream_reply} 57 | :ok 58 | end 59 | 60 | def stream_body(_data, _status, _downstream) do 61 | :ok 62 | end 63 | 64 | def reply(_code, _headers, _req) do 65 | send self(), {:reply, :reply, :nomsg} 66 | :ok 67 | end 68 | 69 | def reply(_code, _headers, _msg, _req) do 70 | send self(), {:reply, :reply, :msg} 71 | :ok 72 | end 73 | 74 | def has_body(req) do 75 | req[:body] != nil 76 | end 77 | 78 | def read_body(req) do 79 | # Simulate a success request and return the body if we passed one in 80 | {:ok, req[:body], req} 81 | end 82 | 83 | def body_length(req) do 84 | String.length(req[:body] || "") 85 | end 86 | 87 | def uri(req) do 88 | req[:url] 89 | end 90 | 91 | def set_resp_headers(headers, req) do 92 | {_, with_resp_headers} = 93 | Map.get_and_update(req, :resp_headers, fn h -> {h, Map.merge(h || %{}, headers)} end) 94 | 95 | with_resp_headers 96 | end 97 | end 98 | -------------------------------------------------------------------------------- /test/mix_tasks_validate_routes_test.exs: -------------------------------------------------------------------------------- 1 | defmodule MixTasksValidateRoutesTest do 2 | use ExUnit.Case 3 | doctest Mix.Tasks.ValidateRoutes 4 | 5 | test "valid routes should be ok" do 6 | constraint = {:pathvariable, fn _ -> true end} 7 | 8 | route = 9 | {"/path", [constraint], :handler, 10 | %{ 11 | description: "dummy route", 12 | uri: URI.parse("http://example.com/"), 13 | destination: "destination", 14 | all_actions: "action", 15 | add_headers: %{"Accept" => "text/html"} 16 | }} 17 | 18 | res = Mix.Tasks.ValidateRoutes.validate_routes([{"host", [route]}]) 19 | assert :ok = res 20 | end 21 | 22 | test "health route should be ok" do 23 | assert :ok = 24 | Mix.Tasks.ValidateRoutes.validate_one_route( 25 | {["/health"], [], Spacesuit.HealthHandler, %{}} 26 | ) 27 | end 28 | 29 | test "should throw if handler is not an atom" do 30 | assert_raise RuntimeError, fn -> 31 | Mix.Tasks.ValidateRoutes.validate_one_route({"/path", [], "not an atom", %{}}) 32 | end 33 | end 34 | 35 | test "should throw if args is not a map" do 36 | assert_raise RuntimeError, fn -> 37 | Mix.Tasks.ValidateRoutes.validate_one_route({"/path", [], :handler, :not_a_map}) 38 | end 39 | end 40 | 41 | test "invalid path should throw" do 42 | assert_raise RuntimeError, fn -> 43 | Mix.Tasks.ValidateRoutes.validate_one_route({:not_a_path, [], :handler, %{}}) 44 | end 45 | end 46 | 47 | test "add_headers should throw if it is not a map" do 48 | assert_raise RuntimeError, fn -> 49 | Mix.Tasks.ValidateRoutes.validate_one_route( 50 | {"/path", [], :handler, %{add_headers: "not a map"}} 51 | ) 52 | end 53 | end 54 | 55 | test "invalid arg map key should throw" do 56 | assert_raise RuntimeError, fn -> 57 | Mix.Tasks.ValidateRoutes.validate_one_route({"/path", [], :handler, %{bad_option: "oops"}}) 58 | end 59 | end 60 | 61 | test "invalid uri arg should throw" do 62 | assert_raise RuntimeError, fn -> 63 | Mix.Tasks.ValidateRoutes.validate_one_route({"/path", [], :handler, %{GET: nil}}) 64 | end 65 | end 66 | 67 | test "invalid constraint path variable should throw" do 68 | assert_raise RuntimeError, fn -> 69 | Mix.Tasks.ValidateRoutes.validate_one_route( 70 | {"/path", [{"not an atom", fn _ -> true end}], :handler, %{}} 71 | ) 72 | end 73 | end 74 | 75 | test "invalid constraint function should throw" do 76 | assert_raise RuntimeError, fn -> 77 | Mix.Tasks.ValidateRoutes.validate_one_route( 78 | {"/path", [{:pathvariable, "not a function"}], :handler, %{}} 79 | ) 80 | end 81 | end 82 | end 83 | -------------------------------------------------------------------------------- /lib/mix/tasks/validate_routes.ex: -------------------------------------------------------------------------------- 1 | defmodule Mix.Tasks.ValidateRoutes do 2 | use Mix.Task 3 | 4 | @shortdoc "Validate the routes for an environment" 5 | 6 | @valid_map_keys Spacesuit.Router.get_http_verbs() ++ 7 | [ 8 | :description, 9 | :destination, 10 | :all_actions, 11 | :uri, 12 | :add_headers, 13 | :middleware, 14 | :constraints 15 | ] 16 | 17 | def run(_) do 18 | IO.puts("\nValidating Spacesuit Routes") 19 | IO.puts("----------------------------\n") 20 | 21 | routes = Spacesuit.Router.load_routes() 22 | 23 | validate_routes(routes) 24 | 25 | case :cowboy_router.compile(routes) do 26 | {:error, _} -> IO.puts("ERROR: Cowboy unable to compile routes!") 27 | _ -> IO.puts("OK: Cowboy compiled successfully") 28 | end 29 | end 30 | 31 | # The health route has a different format, so just match that one 32 | def validate_one_route({["/health"], [], Spacesuit.HealthHandler, %{}}) do 33 | :ok 34 | end 35 | 36 | # All the generated routes match this pattern 37 | def validate_one_route({path, constraints, handler, args}) do 38 | if !is_binary(path) do 39 | raise "Expected path matcher, found #{inspect(path)}" 40 | end 41 | 42 | IO.puts("Checking: #{path}") 43 | 44 | if !is_atom(handler) do 45 | raise "Expected handler module, found #{inspect(handler)}" 46 | end 47 | 48 | if !is_list(constraints) do 49 | raise "Expected list of constraints, found #{inspect(constraints)}" 50 | end 51 | 52 | for {path_variable, function} <- constraints do 53 | # This should probably also test if the path contains that path_variable 54 | if !is_atom(path_variable) do 55 | raise "Expected path variable in constraint, found #{inspect(path_variable)}" 56 | end 57 | 58 | if !is_function(function) do 59 | raise "Expected function to test constraint, found #{inspect(function)}" 60 | end 61 | end 62 | 63 | if !is_map(args) do 64 | raise "Expected route function map, found #{inspect(args)}" 65 | end 66 | 67 | for {key, value} <- args do 68 | if !(key in @valid_map_keys) do 69 | raise "Expected key to be one of #{inspect(@valid_map_keys)}, got #{inspect(key)}" 70 | end 71 | 72 | # If this is an http verb, let's make sure we got a proper URI passed to us 73 | if key in Spacesuit.Router.get_http_verbs() do 74 | if is_nil(value) do 75 | raise "Invalid route URI: nil" 76 | end 77 | 78 | [uri, _map] = value 79 | 80 | case uri do 81 | %URI{authority: _auth, path: _path, scheme: _scheme} -> 82 | :ok 83 | 84 | _ -> 85 | raise "Constructed URI for #{key} appears to be incomplete! #{inspect(args[:uri])}" 86 | end 87 | end 88 | end 89 | 90 | if !is_nil(args[:add_headers]) && !is_map(args[:add_headers]) do 91 | raise "Expected add_headers option is not a map, #{inspect(args[:add_headers])}" 92 | end 93 | 94 | if !is_nil(args[:middleware]) && !is_map(args[:middleware]) do 95 | raise "Expected middleware option is not a map, #{inspect(args[:middleware])}" 96 | end 97 | end 98 | 99 | def validate_routes(routes) do 100 | routes 101 | |> Enum.each(fn {host, routes} -> 102 | if !is_binary(host) do 103 | raise "Expected host matcher, found #{inspect(host)}" 104 | end 105 | 106 | for route <- routes do 107 | validate_one_route(route) 108 | end 109 | end) 110 | 111 | IO.puts("----------------------------\n") 112 | IO.puts("Generated routes are formatted properly") 113 | :ok 114 | end 115 | end 116 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Spacesuit 2 | ========= 3 | 4 | ![spacesuit build](https://travis-ci.org/Nitro/spacesuit.svg?branch=master) 5 | 6 | An API gateway written in Elixir, built on top of the Cowboy web server and 7 | Hackney http client. Supports streaming requests, remapping by hostname, HTTP 8 | method, and endpoint. Now also includes full CORS support with middleware, 9 | allowing backing services to offload CORS to Spacesuit. 10 | 11 | Sample config: 12 | ```ruby 13 | "[...]:_" => [ # Match any hostname/port 14 | { "/users/:user_id", %{ 15 | description: "users to [::1]:9090", 16 | GET: "http://[::1]:9090/:user_id", # ipv6 localhost (thanks osx) 17 | POST: "http://[::1]:9090/:user_id" 18 | }}, 19 | 20 | {"/users/something/:user_id", %{ 21 | description: "users/something to [::1]:9090", 22 | all_actions: "http://[::1]:9090/something/:user_id" 23 | }}, 24 | 25 | {"/[...]", %{ 26 | description: "others to hacker news", 27 | destination: "https://news.ycombinator.com" 28 | }} 29 | ] 30 | ``` 31 | 32 | Installation 33 | ------------ 34 | 35 | You need to have Elixir and the BEAM VM installed. On OSX the easiest way to do 36 | that is to `brew install elixir`. Next you need to install dependencies, with 37 | `mix deps.get`. Now you're ready to roll. 38 | 39 | *Note*: To run `mix compile` or `mix test` you also have to install Rebar3, the Erlang build system. 40 | On OSX, use `brew install rebar` to install it. 41 | 42 | Running 43 | ------- 44 | 45 | Spacesuit listens on 8080 and waits for requests. You can start it up by running 46 | `iex -S mix run` or `mix run --no-halt`. 47 | 48 | Configuration 49 | ------------- 50 | 51 | Spacesuit relies on the `mix` configuration system with a common config in 52 | `config/config.exs` and environment based configs merged on top of that. If you 53 | were running in the `dev` environment, for example, `config/config.exs` would 54 | get loaded first and then `config/dev.exs` would be loaded afterward. 55 | Additionally, it can be configured with some environment variables. The most 56 | common of these are 57 | 58 | * `MIX_ENV` which describes the current evironment 59 | * `SPACESUIT_LOGGING_LEVEL` which is a string corresponding to the minimum level of 60 | logging we'll show in the console. (e.g. `SPACESUIT_LOGGING_LEVEL="warn"`) 61 | 62 | If you use New Relic for monitoring your applications, you can also turn on basic 63 | metric support in Spacesuit by providing the standard New Relic environment variable 64 | for your license key: 65 | 66 | * `NEW_RELIC_LICENSE_KEY` the string value containing your New Relic license, as 67 | provided to any other New Relic agent. 68 | 69 | Route Configuration 70 | ------------------- 71 | 72 | The routes support a fairly extensive pattern match, primarily from the 73 | underlying Cowboy web server. The good documentation on that is [available 74 | here](https://ninenines.eu/docs/en/cowboy/1.0/guide/routing/). Spacesuit supports 75 | outbound remapping using a very similar syntax, as shown above. 76 | 77 | The routes operate as a drop-through list so the first match will be the one 78 | used. This means you need to order your routes from the most specific to the 79 | least specific in descending order. E.g. if you have a wildcard match that will 80 | match all hostnames, it needs to be below any routes that match on specific 81 | hostnames. If you've written network access lists before, these operate in a 82 | similar manner. 83 | 84 | Once you have written your routes, a good step is to run the `mix validate_routes` 85 | task, which will load the routes for the current `MIX_ENV` and check them all 86 | for correctness. 87 | 88 | CORS Middleware 89 | --------------- 90 | 91 | Spacesuit contains a CORS middleware which handles offloading CORS support from 92 | backend services. You can enable this in the config following the examples set 93 | there. **Note** if upstream services have CORS handling enabled internally and 94 | are sending CORS headers, responses from those services will override any from 95 | Spacesuit, even if CORS is enabled, *except* for `OPTIONS` requests which will 96 | be served from Spacesuit all the time if CORS support is enabled for an endpoint. 97 | 98 | Coverage 99 | -------- 100 | 101 | You can view the coverage output in test mode by running: 102 | ``` 103 | MIX_ENV=test mix coveralls.html 104 | ``` 105 | -------------------------------------------------------------------------------- /test/spacesuit_auth_middleware_test.exs: -------------------------------------------------------------------------------- 1 | defmodule SpacesuitAuthMiddlewareTest do 2 | use ExUnit.Case 3 | doctest Spacesuit.AuthMiddleware 4 | 5 | setup_all do 6 | token = 7 | "eyJhbGciOiJIUzM4NCIsInR5cCI6IkpXVCJ9.eyJhY2N0IjoiMSIsImF6cCI6ImthcmwubWF0dGhpYXNAZ29uaXRyby5jb20iLCJkZWxlZ2F0ZSI6IiIsImV4cCI6IjIwMTctMDItMDNUMTU6MDc6MTRaIiwiZmVhdHVyZXMiOlsidGVhbWRvY3MiLCJjb21iaW5lIiwiZXNpZ24iXSwiaWF0IjoiMjAxNy0wMi0wM1QxNDowNzoxNC40MTMyMTg2OTNaIiwianRpIjoiNTU2ZmU1MTgtYTk0Mi00YTQ3LTkyZmMtNWNmNmVkOWY0YWFhIiwicGVybXMiOlsiYWNjb3VudHM6cmVhZCIsImdyb3VwczpyZWFkIiwidXNlcnM6d3JpdGUiXSwic3ViIjoiY3NzcGVyc29uQGdvbml0cm8uY29tIn0.6eWCzu6yHhgzuvUPaNloNl09uUfaN6nqhK1W--TQwtMk29tf5C5SV-hTT2pxnSxe" 8 | 9 | {:ok, token: token} 10 | end 11 | 12 | describe "handling non-bearer tokens" do 13 | test "passes through OK when there is no auth header" do 14 | assert {:ok, %{}, %{}} = Spacesuit.AuthMiddleware.execute(%{}, %{}) 15 | end 16 | 17 | test "'authorization' header is stripped when present" do 18 | req = %{headers: %{"authorization" => "sometoken"}} 19 | env = %{} 20 | 21 | assert {:ok, %{headers: %{}}, ^env} = Spacesuit.AuthMiddleware.execute(req, env) 22 | end 23 | end 24 | 25 | describe "handling bearer tokens" do 26 | test "with a valid token", state do 27 | req = %{ 28 | headers: %{"authorization" => "Bearer #{state[:token]}"}, 29 | pid: self(), 30 | streamid: 1, 31 | method: "GET" 32 | } 33 | 34 | env = %{} 35 | 36 | assert {:ok, %{headers: _headers}, ^env} = Spacesuit.AuthMiddleware.execute(req, env) 37 | end 38 | 39 | test "with invalid bearer token and without session service" do 40 | Application.put_env(:spacesuit, :session_service, %{enabled: false}) 41 | 42 | req = %{ 43 | headers: %{"authorization" => "Bearer balloney"}, 44 | pid: self(), 45 | streamid: 1, 46 | method: "GET" 47 | } 48 | 49 | env = %{} 50 | 51 | # Should just pass through unaffected 52 | assert {:ok, ^req, ^env} = Spacesuit.AuthMiddleware.execute(req, env) 53 | end 54 | 55 | test "with an invalid token when session service is enabled" do 56 | Application.put_env(:spacesuit, :session_service, %{ 57 | enabled: true, 58 | impl: Spacesuit.MockSessionService 59 | }) 60 | 61 | req = %{ 62 | headers: %{"authorization" => "Bearer error"}, 63 | pid: self(), 64 | streamid: 1, 65 | method: "GET" 66 | } 67 | 68 | env = %{} 69 | 70 | # Unrecognized, we pass it on as is 71 | assert {:stop, ^req} = Spacesuit.AuthMiddleware.execute(req, env) 72 | end 73 | 74 | test "with a valid token when session service is enabled" do 75 | Application.put_env(:spacesuit, :session_service, %{ 76 | enabled: true, 77 | impl: Spacesuit.MockSessionService 78 | }) 79 | 80 | req = %{headers: %{"authorization" => "Bearer ok"}, pid: self(), streamid: 1, method: "GET"} 81 | env = %{} 82 | 83 | # Unrecognized, we pass it on as is 84 | assert {:ok, ^req, ^env} = Spacesuit.AuthMiddleware.execute(req, env) 85 | end 86 | 87 | test "with a missing token when session service is enabled" do 88 | Application.put_env(:spacesuit, :session_service, %{ 89 | enabled: true, 90 | impl: Spacesuit.MockSessionService 91 | }) 92 | 93 | req = %{headers: %{"authorization" => "Bearer "}, pid: self(), streamid: 1, method: "GET"} 94 | env = %{} 95 | 96 | # Unrecognized, we pass it on as is 97 | assert {:ok, ^req, ^env} = Spacesuit.AuthMiddleware.execute(req, env) 98 | end 99 | 100 | test "with a valid token on a bypassed path" do 101 | Application.put_env(:spacesuit, :session_service, %{ 102 | enabled: true, 103 | impl: Spacesuit.MockSessionService 104 | }) 105 | 106 | Application.put_env(:handler_opts, :middleware, %{session_service: :disabled}) 107 | 108 | req = %{headers: %{"authorization" => "Bearer ok"}, pid: self(), streamid: 1, method: "GET"} 109 | env = %{} 110 | 111 | # pass it on as is 112 | assert {:ok, ^req, ^env} = Spacesuit.AuthMiddleware.execute(req, env) 113 | end 114 | 115 | test "with an invalid token on a bypassed path" do 116 | Application.put_env(:spacesuit, :session_service, %{ 117 | enabled: true, 118 | impl: Spacesuit.MockSessionService 119 | }) 120 | 121 | Application.put_env(:handler_opts, :middleware, %{session_service: :disabled}) 122 | 123 | req = %{ 124 | headers: %{"authorization" => "Bearer error"}, 125 | pid: self(), 126 | streamid: 1, 127 | method: "GET" 128 | } 129 | 130 | env = %{} 131 | 132 | # Unrecognized, we pass it on as is 133 | assert {:stop, ^req} = Spacesuit.AuthMiddleware.execute(req, env) 134 | end 135 | end 136 | end 137 | -------------------------------------------------------------------------------- /lib/spacesuit/router.ex: -------------------------------------------------------------------------------- 1 | defmodule Spacesuit.Router do 2 | require Logger 3 | 4 | @http_verbs [:GET, :POST, :PUT, :PATCH, :DELETE, :HEAD, :OPTIONS] 5 | 6 | def load_routes do 7 | Application.get_env(:spacesuit, :routes) 8 | |> transform_routes 9 | |> Enum.reverse() 10 | end 11 | 12 | def transform_routes(source) do 13 | Enum.map(source, fn {host, routes} -> 14 | compiled_routes = 15 | routes 16 | |> Enum.map(&transform_one_route/1) 17 | 18 | # Look this up every time, but it's in ETS and this isn't 19 | # high throughput anyway 20 | stored_route = Application.get_env(:spacesuit, :health_route) 21 | 22 | # We add a health route to each hostname if configured 23 | case stored_route[:enabled] do 24 | true -> 25 | health_route = [{[stored_route[:path]], [], Spacesuit.HealthHandler, %{}}] 26 | {host, health_route ++ compiled_routes} 27 | 28 | false -> 29 | {host, compiled_routes} 30 | end 31 | end) 32 | end 33 | 34 | def transform_one_route(source) do 35 | {route, opts} = source 36 | 37 | compiled_opts = 38 | opts 39 | |> process_verbs 40 | |> process_headers 41 | |> add_all_actions 42 | 43 | constraints = Map.get(compiled_opts, :constraints, []) 44 | compiled_opts = Map.delete(compiled_opts, :constraints) 45 | 46 | {route, constraints, Spacesuit.ProxyHandler, compiled_opts} 47 | end 48 | 49 | # Expose the verbs to the outside 50 | def get_http_verbs do 51 | @http_verbs 52 | end 53 | 54 | # Loop over the map, replacing values with compiled routes 55 | defp process_verbs(opts) do 56 | @http_verbs 57 | |> List.foldl(opts, fn verb, memo -> 58 | case Map.fetch(opts, verb) do 59 | {:ok, map} -> 60 | Map.put(memo, verb, compile(map)) 61 | 62 | :error -> 63 | # do nothing, we just don't have this verb 64 | memo 65 | end 66 | end) 67 | end 68 | 69 | # Will insert custom headers into each request. Currently only 70 | # static headers are supported. Does not modify the casing of 71 | # the headers: they will passed as specified in the config. 72 | defp process_headers(opts) do 73 | case Map.fetch(opts, :add_headers) do 74 | {:ok, headers} -> 75 | valid_opts = 76 | Enum.map(headers, fn {header, value} -> 77 | {to_string(header), to_string(value)} 78 | end) 79 | 80 | Map.put(opts, :add_headers, Map.new(valid_opts)) 81 | 82 | :error -> 83 | opts 84 | end 85 | end 86 | 87 | # If the all_actions key is present, let's add them all. 88 | # This lets us specify `all_actions: route_map` in the config 89 | # instead of writing a line for each and every HTTP verb. 90 | defp add_all_actions(opts) do 91 | case Map.fetch(opts, :all_actions) do 92 | {:ok, route_map} -> 93 | @http_verbs 94 | |> List.foldl(opts, fn verb, memo -> 95 | Map.put(memo, verb, compile(route_map)) 96 | end) 97 | 98 | :error -> 99 | opts 100 | end 101 | end 102 | 103 | # Returns a function that will handle the route substitution 104 | def func_for_key(key) do 105 | case key do 106 | # When beginning with a colon we know it's a substitution 107 | ":" <> lookup_key_str -> 108 | lookup_key = String.to_atom(lookup_key_str) 109 | 110 | fn bindings, _ -> 111 | bindings |> Keyword.fetch!(lookup_key) 112 | end 113 | 114 | # Remainder wildcard matches are built from path_info 115 | "..]" -> 116 | fn _, path_info -> 117 | path_info |> Enum.join("/") 118 | end 119 | 120 | # Otherwise it's just text 121 | _ -> 122 | fn _, _ -> key end 123 | end 124 | end 125 | 126 | # Construct the upstream URL using the route_map which contains 127 | # the compiled routes, and the request method, query string, and 128 | # bindings. 129 | def build(method, qs, state, bindings, path_info) do 130 | verb = method |> String.upcase() |> String.to_atom() 131 | 132 | [uri, map] = Map.get(state, verb) 133 | 134 | path = 135 | map 136 | |> Enum.map(fn x -> x.(bindings, path_info) end) 137 | |> Enum.join("/") 138 | 139 | uri 140 | |> Map.merge(path_and_query(path, qs)) 141 | |> URI.to_string() 142 | end 143 | 144 | defp path_and_query(path, qs) when byte_size(qs) < 1 do 145 | %{path: path} 146 | end 147 | 148 | defp path_and_query(path, qs) do 149 | %{path: path, query: qs} 150 | end 151 | 152 | def compile(map) do 153 | uri = URI.parse(to_string(map)) 154 | 155 | compiled_map = 156 | if uri.path != nil do 157 | # Order of split strings is important so we end up 158 | # with output like "/part1/part2" vs "/part1//part2" 159 | String.split(uri.path, ["/[.", "[.", "/"]) 160 | |> Enum.map(&func_for_key/1) 161 | else 162 | [] 163 | end 164 | 165 | [uri, compiled_map] 166 | end 167 | end 168 | -------------------------------------------------------------------------------- /test/spacesuit_router_test.exs: -------------------------------------------------------------------------------- 1 | defmodule SpacesuitRouterTest do 2 | use ExUnit.Case 3 | doctest Spacesuit.Router 4 | 5 | setup_all do 6 | routes = %{ 7 | 'oh-my.example.com' => [ 8 | {'/somewhere', 9 | %{ 10 | description: 'oh my example', 11 | all_actions: 'http://example.com', 12 | add_headers: %{ 13 | "X-Something-Invalid": 123, 14 | "X-Something-Awesome": "awesome" 15 | } 16 | }} 17 | ], 18 | ':_' => [ 19 | {'/users/:user_id', 20 | %{ 21 | description: 'users to localhost', 22 | GET: 'http://localhost:9090/:user_id', 23 | POST: 'http://example.com:9090/:user_id', 24 | OPTIONS: 'http://ui.example.com:9090/:user_id' 25 | }}, 26 | {'/[...]', 27 | %{ 28 | description: 'others to hacker news', 29 | destination: 'https://news.ycombinator.com' 30 | }} 31 | ] 32 | } 33 | 34 | {:ok, routes: routes} 35 | end 36 | 37 | test "that transform_routes does not raise errors", state do 38 | assert [] != Spacesuit.Router.transform_routes(state[:routes]) 39 | end 40 | 41 | test "that compiling routes returns a uri and list of functions" do 42 | uri_str = "http://example.com/users/:user_id" 43 | 44 | [uri, route_map] = Spacesuit.Router.compile(uri_str) 45 | assert Enum.all?(route_map, fn x -> is_function(x, 2) end) 46 | 47 | parsed_uri = URI.parse(uri) 48 | assert parsed_uri.host == "example.com" 49 | end 50 | 51 | test "that build() can process the output from compile" do 52 | uri_str = "http://example.com/users/:user_id[...]" 53 | route_map = %{GET: Spacesuit.Router.compile(uri_str)} 54 | 55 | result = Spacesuit.Router.build("get", "", route_map, [user_id: 123], ["doc"]) 56 | assert result == "http://example.com/users/123/doc" 57 | end 58 | 59 | test "that build() can process the output from compile when only a path_map exists" do 60 | uri_str = "http://example.com/users/[...]" 61 | route_map = %{GET: Spacesuit.Router.compile(uri_str)} 62 | 63 | result = Spacesuit.Router.build("get", "", route_map, [], ["123"]) 64 | assert result == "http://example.com/users/123" 65 | end 66 | 67 | test "the right functions are generated for each key" do 68 | str_output = Spacesuit.Router.func_for_key("generic") 69 | assert str_output.(nil, nil) == "generic" 70 | 71 | str_output = Spacesuit.Router.func_for_key(":substitution") 72 | assert str_output.([substitution: 123], nil) == 123 73 | 74 | str_output = Spacesuit.Router.func_for_key("..]") 75 | assert str_output.(nil, ["part1", "part2"]) == "part1/part2" 76 | end 77 | 78 | test "transforming one route with http verbs", state do 79 | %{ 80 | ':_' => [route | _] 81 | } = state[:routes] 82 | 83 | output = Spacesuit.Router.transform_one_route(route) 84 | {_route, [], _handler, handler_opts} = output 85 | 86 | assert [_one, _two] = Map.get(handler_opts, :GET) 87 | end 88 | 89 | test "transforming one route with :all_actions" do 90 | route = 91 | {'/users/:user_id', 92 | %{ 93 | description: 'users to localhost', 94 | all_actions: 'http://localhost:9090/:user_id' 95 | }} 96 | 97 | output = Spacesuit.Router.transform_one_route(route) 98 | {_route, [], _handler, handler_opts} = output 99 | 100 | assert [_one, _two] = Map.get(handler_opts, :GET) 101 | assert [_one, _two] = Map.get(handler_opts, :OPTIONS) 102 | end 103 | 104 | test "transforming one route with :constraints" do 105 | route = 106 | {'/users/:user_id', 107 | %{ 108 | description: 'users to localhost', 109 | GET: 'http://localhost:9090/:user_id', 110 | constraints: [{:user_id, :int}] 111 | }} 112 | 113 | output = Spacesuit.Router.transform_one_route(route) 114 | {_route, constraint, _handler, handler_opts} = output 115 | 116 | assert [_one, _two] = Map.get(handler_opts, :GET) 117 | assert [{:user_id, :int}] = constraint 118 | end 119 | 120 | test "adds health route when configured to", state do 121 | Application.put_env(:spacesuit, :health_route, %{enabled: true, path: "/health"}) 122 | [health_route | _] = Spacesuit.Router.transform_routes(state[:routes]) 123 | 124 | assert {':_', [{["/health"], [], Spacesuit.HealthHandler, %{}} | _]} = health_route 125 | end 126 | 127 | test "does not add health route when configured not to", state do 128 | Application.put_env(:spacesuit, :health_route, %{enabled: false, path: "/health"}) 129 | [first_route | _] = Spacesuit.Router.transform_routes(state[:routes]) 130 | 131 | {':_', [{route_path, [], handler, _map} | _]} = first_route 132 | assert "/health" != route_path 133 | assert Spacesuit.HealthHandler != handler 134 | end 135 | 136 | test "generates routes that are properly ordered", state do 137 | Application.put_env(:spacesuit, :routes, state[:routes]) 138 | assert {'oh-my.example.com', _} = List.first(Spacesuit.Router.load_routes()) 139 | assert {':_', _} = List.last(Spacesuit.Router.load_routes()) 140 | end 141 | 142 | test "transforms headers into String:String maps", state do 143 | %{ 144 | 'oh-my.example.com' => [route | _] 145 | } = state[:routes] 146 | 147 | output = Spacesuit.Router.transform_one_route(route) 148 | {_route, [], _handler, handler_opts} = output 149 | 150 | assert map_size(handler_opts[:add_headers]) == 2 151 | assert handler_opts[:add_headers]["X-Something-Invalid"] == "123" 152 | assert handler_opts[:add_headers]["X-Something-Awesome"] == "awesome" 153 | end 154 | end 155 | -------------------------------------------------------------------------------- /test/spacesuit_session_service_test.exs: -------------------------------------------------------------------------------- 1 | defmodule SpacesuitSessionServiceTest do 2 | use ExUnit.Case 3 | import Mock 4 | 5 | doctest Spacesuit.SessionService 6 | 7 | @jwt_secret Application.get_env(:spacesuit, :jwt_secret) 8 | 9 | setup_all do 10 | token = %Joken.Token{ claims: %{ 11 | acct: "1", 12 | azp: "beowulf@geatland.example.com", 13 | exp: (DateTime.utc_now |> DateTime.to_unix) + 100, 14 | iat: (DateTime.utc_now |> DateTime.to_unix) - 100, 15 | jti: "556fe518-a942-4a47-92fc-5cf6ed9f4aaa", 16 | } } 17 | |> Joken.sign(Joken.hs384(@jwt_secret)) 18 | |> Joken.get_compact 19 | 20 | ok_body = Poison.encode!(%{ data: token }) 21 | ok_response = {:ok, %HTTPoison.Response{status_code: 200, body: ok_body }} 22 | 23 | {:ok, token: token, ok_response: ok_response} 24 | end 25 | 26 | describe "validate_api_token/1" do 27 | test "recognizes valid tokens", state do 28 | assert Spacesuit.SessionService.validate_api_token(state[:token]) == :ok 29 | end 30 | 31 | test "rejects invalid tokens" do 32 | assert {:error, :validation, "Invalid signature"} = Spacesuit.SessionService.validate_api_token("junk!") 33 | end 34 | end 35 | 36 | describe "get_enriched_token/2" do 37 | test "with a good token", state do 38 | %{token: token, ok_response: response } = state 39 | 40 | with_mock HTTPoison, [get: fn(_url, _headers, _options) -> response end] do 41 | result = Spacesuit.SessionService.get_enriched_token(token, "example.com") 42 | expected_json = Poison.encode!(%{ data: token }) 43 | assert {:ok, ^expected_json} = result 44 | end 45 | end 46 | 47 | test "with a bad token", state do 48 | response = {:ok, %HTTPoison.Response{status_code: 404, body: "error message"}} 49 | 50 | with_mock HTTPoison, [get: fn(_url, _headers, _options) -> response end] do 51 | result = Spacesuit.SessionService.get_enriched_token(state[:token], "example.com") 52 | assert {:error, :http, 404, "error message"} = result 53 | end 54 | end 55 | 56 | test "with an unexpected response", state do 57 | # Session service must not accept/follow redirects 58 | response = {:ok, %HTTPoison.Response{status_code: 301}} 59 | 60 | with_mock HTTPoison, [get: fn(_url, _headers, _options) -> response end] do 61 | result = Spacesuit.SessionService.get_enriched_token(state[:token], "example.com") 62 | assert {:error, :http, 500, _body} = result 63 | end 64 | end 65 | 66 | test "passes the right headers", state do 67 | token = state[:token] 68 | # Don't care what we return, just look at the headers 69 | validate_headers = fn(_url, headers, _options) -> 70 | assert Keyword.get(headers, :"Authorization") == "Bearer #{token}" 71 | end 72 | 73 | with_mock HTTPoison, [get: validate_headers] do 74 | Spacesuit.SessionService.get_enriched_token(token, "example.com") 75 | end 76 | end 77 | end 78 | 79 | describe "handle_bearer_token/4" do 80 | test "the happy path returns a modified request", state do 81 | %{token: token, ok_response: response } = state 82 | 83 | req = %{ headers: %{ "authorization" => "overwrite this" } } 84 | expected = %{ headers: %{ "authorization" => "Bearer #{token}" } } 85 | 86 | with_mock HTTPoison, [get: fn(_url, _headers, _options) -> response end] do 87 | result = Spacesuit.SessionService.handle_bearer_token(req, %{}, token, "example.com") 88 | assert {:ok, ^expected, %{}} = result 89 | end 90 | end 91 | 92 | test "invalid token signature returns a :stop request" do 93 | token = "garbage" 94 | req = %{ prove_identity: "proof" } 95 | result = Spacesuit.SessionService.handle_bearer_token(req, %{}, token, "example.com") 96 | assert {:stop, ^req} = result 97 | end 98 | 99 | test "bad http response from the session service returns a :stop request", state do 100 | token = state[:token] 101 | response = {:ok, %HTTPoison.Response{status_code: 301}} 102 | req = %{ prove_identity: "proof" } # We pass this request back as well 103 | 104 | with_mock HTTPoison, [get: fn(_url, _headers, _options) -> response end] do 105 | result = Spacesuit.SessionService.handle_bearer_token(req, %{}, token, "example.com") 106 | assert {:stop, ^req} = result 107 | end 108 | end 109 | 110 | test "returns invalid token for an expired token" do 111 | token = %Joken.Token{ claims: %{ 112 | acct: "1", 113 | azp: "beowulf@geatland.example.com", 114 | exp: (DateTime.utc_now |> DateTime.to_unix) - 100, 115 | iat: (DateTime.utc_now |> DateTime.to_unix) - 300, 116 | jti: "556fe518-a942-4a47-92fc-5cf6ed9f4aaa", 117 | } } 118 | |> Joken.sign(Joken.hs384(@jwt_secret)) 119 | |> Joken.get_compact 120 | 121 | result = Spacesuit.SessionService.handle_bearer_token(%{}, %{}, token, "example.com") 122 | assert {:stop, %{}} = result 123 | end 124 | end 125 | 126 | describe "unexpired?/1" do 127 | test "If passed anything but a unix epoch integer, we say it's expired" do 128 | assert Spacesuit.SessionService.unexpired?("asdf") == false 129 | end 130 | 131 | test "If passed an expired time, it's expired" do 132 | expired = DateTime.to_unix(DateTime.utc_now) - 10 133 | assert Spacesuit.SessionService.unexpired?(expired) == false 134 | end 135 | 136 | test "If passed a valid time, its ok" do 137 | valid = DateTime.to_unix(DateTime.utc_now) + 10 138 | assert Spacesuit.SessionService.unexpired?(valid) == true 139 | end 140 | end 141 | end 142 | -------------------------------------------------------------------------------- /lib/spacesuit/session_service.ex: -------------------------------------------------------------------------------- 1 | defmodule SessionService do 2 | @moduledoc """ 3 | Describe a SessionService implementation. Use for handling auth derived 4 | from bearer tokens. 5 | """ 6 | 7 | @callback validate_api_token(String.t()) :: Tuple.t() 8 | @callback handle_bearer_token(Map.t(), Map.t(), String.t(), String.t()) :: Tuple.t() 9 | end 10 | 11 | defmodule Spacesuit.MockSessionService do 12 | @behaviour SessionService 13 | @moduledoc """ 14 | Mock session service used in testing the AuthHandler 15 | """ 16 | 17 | def validate_api_token(token) do 18 | case token do 19 | "ok" -> :ok 20 | "error" -> :error 21 | _ -> :error 22 | end 23 | end 24 | 25 | def handle_bearer_token(req, env, token, _url) do 26 | case token do 27 | "ok" -> {:ok, req, env} 28 | "error" -> {:stop, req} 29 | _ -> {:ok, req, env} 30 | end 31 | end 32 | end 33 | 34 | defmodule Spacesuit.SessionService do 35 | @moduledoc """ 36 | Implementation of a SessionService that calls out to an external service 37 | over HTTP, passing the original bearer token, and receiving back a JSON 38 | blob containing an enriched/modified token. 39 | """ 40 | require Logger 41 | 42 | @behaviour SessionService 43 | 44 | @http_server Application.get_env(:spacesuit, :http_server) 45 | # How many milliseconds before we timeout call to session-service 46 | @recv_timeout 1000 47 | 48 | @doc """ 49 | Consume a bearer token, validate it, and then either 50 | reject it or pass it on to a session service to be 51 | enriched. 52 | """ 53 | def handle_bearer_token(req, env, token, url) do 54 | result = 55 | with :ok <- validate_api_token(token), 56 | {:ok, enriched} <- get_enriched_token(token, url), 57 | {:ok, parsed} <- parse_response_body(enriched), 58 | do: result_with_new_token(req, env, parsed) 59 | 60 | case result do 61 | # Just pass on the result 62 | {:ok, _, _} -> 63 | result 64 | 65 | {:error, type, code, error} -> 66 | Logger.error("Session-service #{inspect(type)} error: #{inspect(error)}") 67 | 68 | if is_binary(error) do 69 | @http_server.reply(code, %{}, error, req) 70 | else 71 | error_reply(req, 503, "Upstream error") 72 | end 73 | 74 | {:stop, req} 75 | 76 | {:error, type, error} -> 77 | # We only warn here because it's (probably) a client side issue 78 | Logger.warn("Session-service #{inspect(type)} error: #{inspect(error)}") 79 | error_reply(req, 401, "Bad Authentication Token") 80 | {:stop, req} 81 | 82 | # Otherwise we blow up the request 83 | unexpected -> 84 | Logger.error("Session-service error: unexpected response - #{inspect(unexpected)}") 85 | error_reply(req, 401, "Bad Authentication Token") 86 | {:stop, req} 87 | end 88 | end 89 | 90 | @doc """ 91 | Do a quick validation on the token provided 92 | """ 93 | def validate_api_token(token) do 94 | jwt_secret = Application.get_env(:spacesuit, :jwt_secret) 95 | 96 | result = 97 | token 98 | |> Joken.token() 99 | |> Joken.with_signer(Joken.hs384(jwt_secret)) 100 | |> Joken.with_validation("exp", &unexpired?/1) 101 | |> Joken.verify() 102 | 103 | case result.error do 104 | nil -> :ok 105 | error -> {:error, :validation, error} 106 | end 107 | end 108 | 109 | def unexpired?(exp_time) when is_integer(exp_time) do 110 | now = DateTime.utc_now() |> DateTime.to_unix() 111 | exp_time > now 112 | end 113 | 114 | def unexpired?(_time) do 115 | # If anything else other than Unix epoch time was sent, we say it's expired 116 | false 117 | end 118 | 119 | @doc """ 120 | Exchange the token for an enriched token from the Session service. This 121 | expects the body of the response to be the new token. The current token 122 | is passed in the Authorization header as a bearer token. 123 | """ 124 | @spec get_enriched_token(String.t(), String.t()) :: String.t() 125 | def get_enriched_token(token, url) do 126 | headers = [ 127 | Authorization: "Bearer #{token}", 128 | Accept: "Application/json; Charset=utf-8" 129 | ] 130 | 131 | options = [ 132 | ssl: [{:versions, [:"tlsv1.2"]}], 133 | recv_timeout: @recv_timeout 134 | ] 135 | 136 | case HTTPoison.get(url, headers, options) do 137 | {:ok, %HTTPoison.Response{status_code: 200, body: body}} -> 138 | {:ok, body} 139 | 140 | {:ok, %HTTPoison.Response{status_code: code, body: body}} 141 | when code >= 400 and code <= 499 -> 142 | {:error, :http, code, body} 143 | 144 | {:error, %HTTPoison.Error{reason: reason}} -> 145 | {:error, :http, 500, reason} 146 | 147 | unexpected -> 148 | {:error, :http, 500, "Unexpected response from #{url}: #{inspect(unexpected)}"} 149 | end 150 | end 151 | 152 | @spec parse_response_body(String.t()) :: Tuple.t() 153 | def parse_response_body(body) do 154 | Logger.debug("Parsing response: #{inspect(body)}") 155 | 156 | case Poison.decode(body) do 157 | {:ok, data} -> Map.fetch(data, "data") 158 | {:error, :parser, error} -> {:error, :parsing, error} 159 | end 160 | end 161 | 162 | @spec result_with_new_token(Map.t(), Map.t(), String.t()) :: Tuple.t() 163 | def result_with_new_token(req, env, token) do 164 | new_req = %{ 165 | req 166 | | headers: Map.put(req[:headers], "authorization", "Bearer #{token}") 167 | } 168 | 169 | {:ok, new_req, env} 170 | end 171 | 172 | @spec error_reply(Map.t(), String.t(), String.t()) :: nil 173 | defp error_reply(req, code, message) do 174 | msg = Spacesuit.ApiMessage.encode(%Spacesuit.ApiMessage{status: "error", message: message}) 175 | @http_server.reply(code, %{}, msg, req) 176 | end 177 | end 178 | -------------------------------------------------------------------------------- /lib/spacesuit/proxy_handler.ex: -------------------------------------------------------------------------------- 1 | defmodule Spacesuit.ProxyHandler do 2 | require Logger 3 | require IEx 4 | 5 | @http_client Application.get_env(:spacesuit, :http_client) 6 | @http_server Application.get_env(:spacesuit, :http_server) 7 | 8 | # Callback from the Cowboy handler 9 | def init(req, state) do 10 | route_name = Map.get(state, :description, "un-named") 11 | Logger.debug("Processing '#{route_name}'") 12 | NewRelic.start_transaction("Spacesuit.ProxyHandler", route_name) 13 | 14 | try do 15 | %{method: method, headers: headers, peer: peer} = req 16 | 17 | # Prepare some things we'll need 18 | ups_url = build_upstream_url(req, state) 19 | peer = format_peer(peer) 20 | all_headers = add_headers_to(headers, state[:add_headers]) 21 | ups_headers = cowboy_to_hackney(all_headers, peer, @http_server.uri(req)) 22 | 23 | # Make the proxy request 24 | req = handle_request(req, ups_url, ups_headers, method) 25 | {:ok, req, state} 26 | after 27 | NewRelic.stop_transaction() 28 | end 29 | end 30 | 31 | def handle_request(req, ups_url, ups_headers, method) do 32 | case request_upstream(method, ups_url, ups_headers, req) do 33 | {:ok, status, headers, upstream} -> 34 | handle_reply(status, req, headers, upstream) 35 | 36 | {:ok, status, headers} -> 37 | handle_reply(status, req, headers, nil) 38 | 39 | {:error, :econnrefused} -> 40 | error_reply(req, 503, "Service Unavailable - Connection refused") 41 | 42 | {:error, :closed} -> 43 | error_reply(req, 502, "Bad Gateway - Connection closed") 44 | 45 | {:error, :connect_timeout} -> 46 | error_reply(req, 502, "Bad Gateway - Connection timeout") 47 | 48 | {:error, :timeout} -> 49 | error_reply(req, 502, "Bad Gateway - Timeout") 50 | 51 | {:error, :bad_request} -> 52 | error_reply(req, 400, "Bad Request") 53 | 54 | unexpected -> 55 | Logger.warn("Received unexpected upstream response: '#{inspect(unexpected)}'") 56 | req 57 | end 58 | end 59 | 60 | # The presence of a content-length header or a transfer-encoding header 61 | # indicates that the response will contain a body. We have to decide 62 | # based on headers because we don't want to buffer the body into memory. 63 | def has_body_keys?(headers) do 64 | List.keymember?(headers, "content-length", 0) or 65 | List.keymember?(headers, "transfer-encoding", 0) 66 | end 67 | 68 | # Look at the headers and the status and figure out if this reponse 69 | # will contain a body. Needed to make sure we send either a reply 70 | # or a chunked reply. 71 | def has_body?(headers, status) do 72 | has_key = 73 | headers 74 | |> Enum.map(fn x -> {String.downcase(elem(x, 0)), elem(x, 1)} end) 75 | |> has_body_keys? 76 | 77 | has_key and status != 204 78 | end 79 | 80 | # We got a valid response from upstream, so now we have to send it 81 | # back to the client. But some requests need to be treated differently 82 | # because Cowboy will return a 204 No Content if we try to use a 83 | # `stream_reply` call and the body is empty. 84 | def handle_reply(status, req, headers, upstream) do 85 | down_headers = headers |> hackney_to_cowboy 86 | inner_reply(status, req, down_headers, upstream, {:body, has_body?(headers, status)}) 87 | end 88 | 89 | def inner_reply(204, req, down_headers, _, _) do 90 | @http_server.reply(204, down_headers, <<>>, req) 91 | end 92 | 93 | def inner_reply(status, req, down_headers, upstream, _) when is_nil(upstream) do 94 | @http_server.reply(status, down_headers, <<>>, req) 95 | end 96 | 97 | def inner_reply(status, req, down_headers, _, {:body, false}) do 98 | @http_server.reply(status, down_headers, <<>>, req) 99 | end 100 | 101 | def inner_reply(status, req, down_headers, upstream, {:body, true}) do 102 | # stream_reply always does a chunked reply, which is a shame because we 103 | # usually have the content-length. TODO figure this out. 104 | downstream = @http_server.stream_reply(status, down_headers, req) 105 | stream(upstream, downstream) 106 | end 107 | 108 | # Run the route builder to generate the correct upstream URL based 109 | # on the bindings and the request method/http verb. 110 | def build_upstream_url(req, state) do 111 | %{bindings: bindings, method: method, qs: qs, path_info: path_info} = req 112 | 113 | case Map.fetch(state, :destination) do 114 | {:ok, destination} -> destination 115 | :error -> Spacesuit.Router.build(method, qs, state, bindings, path_info) 116 | end 117 | end 118 | 119 | # Make the request to the destination using Hackney 120 | def request_upstream(method, url, ups_headers, downstream) do 121 | method = String.downcase(method) 122 | 123 | if @http_server.has_body(downstream) do 124 | # This reads the whole incoming body into RAM. TODO see if we can not do that. 125 | case @http_server.read_body(downstream) do 126 | {:ok, body, _downstream} -> 127 | @http_client.request(method, url, ups_headers, body, []) 128 | 129 | {:more, _body, _downstream} -> 130 | # TODO this doesn't handle large bodies 131 | Logger.error("Request body too large! Size was #{:cowboy_req.body_length(downstream)}") 132 | end 133 | else 134 | @http_client.request(method, url, ups_headers, [], []) 135 | end 136 | end 137 | 138 | # Convert headers from Hackney list format to Cowboy map format. 139 | # Drops some headers we don't want to pass through. 140 | def hackney_to_cowboy(headers) do 141 | headers 142 | |> List.foldl(%{}, fn {k, v}, memo -> Map.put(memo, String.downcase(k), v) end) 143 | |> Map.drop(["date", "content-length"]) 144 | end 145 | 146 | # Format the peer from the request into a string that 147 | # we can pass in the X-Forwarded-For header. 148 | def format_peer(peer) do 149 | {ip, _port} = peer 150 | 151 | ip 152 | |> Tuple.to_list() 153 | |> Enum.map(&Integer.to_string(&1)) 154 | |> Enum.join(".") 155 | end 156 | 157 | # Take static headers from the config and add them to this request 158 | def add_headers_to(headers, added_headers) do 159 | Map.merge(headers || %{}, added_headers || %{}) 160 | end 161 | 162 | # Convert headers from Cowboy map format to Hackney list format 163 | def cowboy_to_hackney(headers, peer, url) do 164 | [[_, _, host | _] | _] = url 165 | 166 | (headers || %{}) 167 | |> Map.merge(%{"x-forwarded-for" => peer}, fn _k, a, b -> "#{a}, #{b}" end) 168 | |> Map.put("x-forwarded-url", url) 169 | |> Map.put("x-forwarded-host", host) 170 | |> Map.drop(["host", "Host"]) 171 | |> Map.to_list() 172 | end 173 | 174 | # Copy data from one connection to the other until there is no more 175 | def stream(upstream, downstream) do 176 | case @http_client.stream_body(upstream) do 177 | {:ok, data} -> 178 | :ok = @http_server.stream_body(data, :nofin, downstream) 179 | stream(upstream, downstream) 180 | 181 | :done -> 182 | :ok = @http_server.stream_body(<<>>, :fin, downstream) 183 | :ok 184 | 185 | {:error, reason} -> 186 | Logger.error("Error in stream/2: #{reason}") 187 | 188 | bad -> 189 | Logger.error("Unexpected non-match in stream/2! (#{inspect(bad)})") 190 | end 191 | end 192 | 193 | # Send messages back to Cowboy, encoded in the format used 194 | # by the API 195 | def error_reply(req, code, message) do 196 | msg = Spacesuit.ApiMessage.encode(%Spacesuit.ApiMessage{status: "error", message: message}) 197 | @http_server.reply(code, %{}, msg, req) 198 | end 199 | 200 | def terminate(_reason, _downstream, _state), do: :ok 201 | end 202 | -------------------------------------------------------------------------------- /lib/spacesuit/cors_middleware.ex: -------------------------------------------------------------------------------- 1 | defmodule Spacesuit.CorsMiddleware do 2 | @moduledoc """ 3 | Handles CORS requests for backing services. If the incoming request is 4 | properly formed and is an OPTIONS request, then we'll serve the response 5 | directly without further upstream processing. For all other requests, 6 | we try validate a number of features and determine first if it's a valid 7 | request at all, and then if we should process it. If we do, we add 8 | appropriate response headers and send on for upstream processing. 9 | """ 10 | 11 | require Logger 12 | 13 | @http_server Application.get_env(:spacesuit, :http_server) 14 | @supported_http_methods [:GET, :POST, :PUT, :PATCH, :DELETE, :HEAD, :OPTIONS] 15 | 16 | def execute(req, env) do 17 | result = 18 | with {true, :enabled?} <- enabled?(), 19 | {true, :handled_path?} <- handled_path?(req[:path]), 20 | {:ok, origin, method} <- valid_cors_request?(req), 21 | {:ok, headers} <- process_cors(origin, method, req) do 22 | handle_success(req, env, headers) 23 | else 24 | # The whole middleware is disabled 25 | {false, :enabled?} -> 26 | Logger.debug("CORS middleware disabled, skipping") 27 | {:ok, req, env} 28 | 29 | {false, :handled_path?} -> 30 | Logger.debug("No CORS headers set for #{req[:method]} #{req[:path]}") 31 | {:ok, req, env} 32 | 33 | # OPTIONS request, we handle these ourselves. So short-circuit 34 | # downstream response and send 200 35 | {:ok, headers, :handle_ourselves} -> 36 | with_resp_headers = @http_server.set_resp_headers(headers, req) 37 | @http_server.reply(200, headers, with_resp_headers) 38 | {:stop, with_resp_headers} 39 | 40 | # There were no CORS headers, continue processing 41 | {:ok, {:skip, :valid_cors_request?}} -> 42 | Logger.debug("No CORS headers set for #{req[:method]} #{req[:path]}") 43 | {:ok, req, env} 44 | 45 | # There were CORS headers, but they are invalid or malformed 46 | {:error, :invalid, :valid_cors_request?} -> 47 | handle_error(req, env) 48 | 49 | # We got a method that we don't allow processing CORS for 50 | {:error, :unsupported, :process_cors} -> 51 | handle_error(req, env) 52 | 53 | # It was an OPTIONS request but with invalid headers 54 | {:error, :invalid_options, :process_cors} -> 55 | handle_error(req, env) 56 | end 57 | end 58 | 59 | # Quick access function for the application settings for this middleware 60 | def cors do 61 | Application.get_env(:spacesuit, :cors) || %{} 62 | end 63 | 64 | # We processed CORS headers and we pass on upstream to other middlewares. 65 | defp handle_success(req, env, headers) do 66 | {:ok, @http_server.set_resp_headers(headers, req), env} 67 | end 68 | 69 | # Something was wrong with the request and we have to stop the processing 70 | # by all other middlewares. 71 | defp handle_error(req, _env) do 72 | origin = req[:headers]["origin"] 73 | 74 | Logger.warn( 75 | """ 76 | Invalid CORS request: 77 | Origin=#{origin} 78 | Method=#{req[:method]} 79 | ACCESS_CONTROL_REQUEST_HEADERS=#{req[:headers]["access_control_request_headers"]} 80 | """ 81 | |> String.replace(" ", "") 82 | ) 83 | 84 | msg = 85 | Spacesuit.ApiMessage.encode(%Spacesuit.ApiMessage{ 86 | status: "error", 87 | message: "Invalid CORS request" 88 | }) 89 | 90 | @http_server.reply(403, %{}, msg, req) 91 | {:stop, req} 92 | end 93 | 94 | # Do we even have the middleware enabled? 95 | defp enabled? do 96 | enabled = Map.get(cors(), :enabled, false) 97 | {enabled, :enabled?} 98 | end 99 | 100 | # Is the path something we can send a CORS response for? 101 | defp handled_path?(path) do 102 | path_prefixes = cors()[:path_prefixes] || ["/"] 103 | result = Enum.any?(path_prefixes, fn p -> String.starts_with?(path, p) end) 104 | {result, :handled_path?} 105 | end 106 | 107 | # Is this request something we can handle? 108 | defp valid_cors_request?(req) do 109 | origin = req[:headers]["origin"] 110 | 111 | cond do 112 | is_nil(origin) || same_origin?(origin, req) -> 113 | {:ok, {:skip, :valid_cors_request?}} 114 | 115 | serve_from_origin?(origin) -> 116 | {:ok, origin, String.to_atom(req[:method])} 117 | 118 | # Default case 119 | true -> 120 | {:error, :invalid, :valid_cors_request?} 121 | end 122 | end 123 | 124 | # Do we have headers we're allowed to process? 125 | def verify_access_control_request_headers(req) do 126 | # Access control request headers come jammed into a single string 127 | acr_headers = 128 | (req[:headers]["access-control-request-headers"] || "") 129 | |> String.split(",") 130 | |> Enum.map(&String.downcase/1) 131 | |> Enum.map(&String.trim/1) 132 | |> Enum.into(MapSet.new()) 133 | 134 | empty_mapset = MapSet.new([""]) 135 | 136 | case acr_headers do 137 | ^empty_mapset -> 138 | {:ok, %{}} 139 | 140 | headers -> 141 | if valid_control_headers?(headers) do 142 | {:ok, %{"access-control-allow-headers" => Enum.join(headers, ",")}} 143 | else 144 | :error 145 | end 146 | end 147 | end 148 | 149 | defp valid_control_headers?(headers) do 150 | MapSet.size(access_control_request_headers()) == 0 || 151 | MapSet.subset?(headers, access_control_request_headers()) 152 | end 153 | 154 | defp process_cors(origin, method, req) do 155 | if method == :OPTIONS do 156 | case handle_options_method(origin, method, req) do 157 | :error -> {:error, :invalid_options, :process_cors} 158 | {:ok, headers} -> {:ok, headers, :handle_ourselves} 159 | end 160 | else 161 | if supported_http_method?(method) do 162 | {:ok, origin_headers(origin)} 163 | else 164 | {:error, :unsupported, :process_cors} 165 | end 166 | end 167 | end 168 | 169 | def handle_options_method(origin, method, req) do 170 | case verify_access_control_request_headers(req) do 171 | {:ok, allow_headers} -> 172 | method = String.to_atom(req[:headers]["access-control-request-method"] || "") 173 | 174 | if supported_http_method?(method) && allowed_http_method?(method) do 175 | headers = 176 | allow_headers 177 | |> Map.merge(origin_headers(origin)) 178 | |> Map.merge(preflight_header()) 179 | |> Map.put("access-control-allow-methods", Atom.to_string(method)) 180 | 181 | {:ok, headers} 182 | else 183 | :error 184 | end 185 | 186 | _ -> 187 | :error 188 | end 189 | end 190 | 191 | @spec origin_headers(String.t()) :: Map.t() 192 | defp origin_headers(origin) do 193 | if cors()[:any_origin_allowed] do 194 | %{ 195 | "access-control-allow-origin" => "*" 196 | } 197 | else 198 | %{ 199 | "access-control-allow-origin" => origin, 200 | "vary" => "Origin" 201 | } 202 | end 203 | end 204 | 205 | @spec preflight_header() :: Map.t() 206 | defp preflight_header do 207 | max_age = cors()[:preflight_max_age] 208 | 209 | if is_nil(max_age) || max_age < 0 do 210 | %{} 211 | else 212 | %{"access-control-max-age" => max_age} 213 | end 214 | end 215 | 216 | @spec serve_from_origin?(String.t()) :: boolean 217 | defp serve_from_origin?(origin) do 218 | !String.contains?(origin || "", "%") && !is_nil(URI.parse(origin).scheme) && 219 | allowed_origin?(origin) 220 | end 221 | 222 | @spec same_origin?(String.t(), Req.t()) :: boolean 223 | defp same_origin?(origin, req) do 224 | origin_uri = URI.parse(origin) 225 | 226 | {req[:scheme], req[:host], req[:port]} == 227 | {origin_uri.scheme, origin_uri.host, origin_uri.port} 228 | end 229 | 230 | @spec allowed_origin?(String.t()) :: boolean 231 | defp allowed_origin?(origin) do 232 | allowed_origins = cors()[:allowed_origins] 233 | is_nil(allowed_origins) || Enum.member?(allowed_origins, origin) 234 | end 235 | 236 | @spec supported_http_method?(atom) :: boolean 237 | def supported_http_method?(method) do 238 | Enum.member?(@supported_http_methods, method) 239 | end 240 | 241 | @spec allowed_http_method?(String.t()) :: boolean 242 | def allowed_http_method?(method) do 243 | is_nil(cors()[:allowed_http_methods]) || 244 | cors() 245 | |> Map.get(:allowed_http_methods, []) 246 | |> Enum.member?(method) 247 | end 248 | 249 | defp access_control_request_headers do 250 | (cors()[:access_control_request_headers] || []) 251 | |> Enum.map(&String.downcase/1) 252 | |> Enum.into(MapSet.new()) 253 | end 254 | end 255 | -------------------------------------------------------------------------------- /test/spacesuit_proxy_handler_test.exs: -------------------------------------------------------------------------------- 1 | defmodule SpacesuitProxyHandlerTest do 2 | use ExUnit.Case 3 | doctest Spacesuit.ProxyHandler 4 | 5 | test "formatting the peer address from the request" do 6 | peer = {{127, 0, 0, 1}, 32767} 7 | 8 | assert Spacesuit.ProxyHandler.format_peer(peer) == "127.0.0.1" 9 | end 10 | 11 | describe "converting headers to Cowboy format" do 12 | test "converts headers to a map" do 13 | headers = [ 14 | {"cookie", "some-cookie-data"}, 15 | {"Date", "Sun, 18 Dec 2016 12:12:02 GMT"}, 16 | {"Host", "localhost"} 17 | ] 18 | 19 | processed = Spacesuit.ProxyHandler.hackney_to_cowboy(headers) 20 | assert "localhost" = Map.get(processed, "host") 21 | assert "not-found" = Map.get(processed, "date", "not-found") 22 | assert "some-cookie-data" = Map.get(processed, "cookie", "empty") 23 | end 24 | 25 | test "downcases all the response headers" do 26 | headers = [ 27 | {"cookie", "some-cookie-data"}, 28 | {"Date", "Sun, 18 Dec 2016 12:12:02 GMT"}, 29 | {"Host", "localhost"} 30 | ] 31 | 32 | processed = Spacesuit.ProxyHandler.hackney_to_cowboy(headers) 33 | assert Enum.all?(Map.keys(processed), fn k -> k == String.downcase(k) end) 34 | end 35 | 36 | test "drops the content-length since we're running chunked encoding" do 37 | headers = [ 38 | {"Content-Length", "1500"}, 39 | {"content-length", "1500"}, 40 | {"Another-Header", "this is valid"} 41 | ] 42 | 43 | processed = Spacesuit.ProxyHandler.hackney_to_cowboy(headers) 44 | assert Enum.count(processed) == 1 45 | end 46 | end 47 | 48 | test "adding headers specified in the config" do 49 | headers = %{ 50 | "user-agent" => 51 | Enum.join( 52 | [ 53 | "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.10; rv:50.0) ", 54 | "Gecko/20100101 Firefox/50.0" 55 | ], 56 | "" 57 | ), 58 | "accept-language" => "en-US,en;q=0.5", 59 | "Host" => " localhost:9090" 60 | } 61 | 62 | added_headers = %{"Add-One" => "1", "Add-Two" => "2"} 63 | all_headers = Spacesuit.ProxyHandler.add_headers_to(headers, added_headers) 64 | 65 | assert all_headers["Add-One"] == "1" 66 | assert all_headers["Add-Two"] == "2" 67 | end 68 | 69 | test "doesn't crash on nil added_headeres" do 70 | result = Spacesuit.ProxyHandler.add_headers_to(%{}, nil) 71 | 72 | assert %{} == result 73 | end 74 | 75 | test "converting headers to Hackney format and adding proxy info" do 76 | headers = %{ 77 | "user-agent" => 78 | Enum.join( 79 | [ 80 | "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.10; rv:50.0) ", 81 | "Gecko/20100101 Firefox/50.0" 82 | ], 83 | "" 84 | ), 85 | "accept-language" => "en-US,en;q=0.5", 86 | "Host" => " localhost:9090", 87 | "x-forwarded-for" => "example.com" 88 | } 89 | 90 | peer = Spacesuit.ProxyHandler.format_peer({{127, 0, 0, 1}, 32767}) 91 | # Cowboy sends the URL through in this format 92 | original_url = [ 93 | [["http", 58], "//", "localhost", [58, "8080"]], 94 | "/v1/people/123/things", 95 | "", 96 | "" 97 | ] 98 | 99 | processed = Spacesuit.ProxyHandler.cowboy_to_hackney(headers, peer, original_url) 100 | 101 | assert {"x-forwarded-for", "example.com, 127.0.0.1"} = 102 | List.keyfind(processed, "x-forwarded-for", 0) 103 | 104 | assert {"accept-language", "en-US,en;q=0.5"} = List.keyfind(processed, "accept-language", 0) 105 | 106 | assert {"x-forwarded-url", ^original_url} = List.keyfind(processed, "x-forwarded-url", 0) 107 | 108 | assert {"x-forwarded-host", "localhost"} = List.keyfind(processed, "x-forwarded-host", 0) 109 | 110 | assert nil == List.keyfind(processed, "Host", 0) 111 | end 112 | 113 | test "building the upstream url when destination is set and no bindings exist" do 114 | req = %{bindings: [], method: "GET", qs: "", path_info: []} 115 | 116 | url = 117 | Spacesuit.ProxyHandler.build_upstream_url( 118 | req, 119 | %{destination: "the moon", map: %{}} 120 | ) 121 | 122 | assert ^url = "the moon" 123 | end 124 | 125 | test "building the upstream url when bindings exist" do 126 | uri_str = "http://elsewhere.example.com/:asdf" 127 | 128 | route_map = %{GET: Spacesuit.Router.compile(uri_str)} 129 | 130 | req = %{bindings: [asdf: "foo"], method: "GET", qs: "", path_info: []} 131 | url = Spacesuit.ProxyHandler.build_upstream_url(req, route_map) 132 | 133 | assert ^url = "http://elsewhere.example.com/foo" 134 | end 135 | 136 | test "building the upstream url when there is a query string" do 137 | uri_str = "http://elsewhere.example.com/:asdf" 138 | route_map = %{GET: Spacesuit.Router.compile(uri_str)} 139 | 140 | req = %{ 141 | bindings: [asdf: "foo"], 142 | method: "GET", 143 | qs: "shakespeare=literature", 144 | path_info: [] 145 | } 146 | 147 | url = Spacesuit.ProxyHandler.build_upstream_url(req, route_map) 148 | 149 | assert ^url = "http://elsewhere.example.com/foo?shakespeare=literature" 150 | end 151 | 152 | test "request_upstream passes the body when there is one" do 153 | result = 154 | Spacesuit.ProxyHandler.request_upstream( 155 | "get", 156 | "http://example.com", 157 | [{"Content-Type", "html"}], 158 | %{has_body: true, body: "test body"} 159 | ) 160 | 161 | assert {:ok, true} = result 162 | end 163 | 164 | test "request_upstream skips the body when there isn't one" do 165 | result = 166 | Spacesuit.ProxyHandler.request_upstream( 167 | "get", 168 | "http://example.com", 169 | [{"Content-Type", "html"}], 170 | %{has_body: false} 171 | ) 172 | 173 | assert {:ok, false} = result 174 | end 175 | 176 | test "stream calls complete properly" do 177 | assert :ok = Spacesuit.ProxyHandler.stream(:done, nil) 178 | end 179 | 180 | test "stream calls handle errors" do 181 | assert :ok = Spacesuit.ProxyHandler.stream(:error, nil) 182 | end 183 | 184 | test "stream recurses" do 185 | assert :ok = Spacesuit.ProxyHandler.stream(nil, nil) 186 | end 187 | 188 | test "proxies requests with upstreams but without bodies without streaming" do 189 | headers = [ 190 | {"Date", "Sun, 18 Dec 2016 12:12:02 GMT"} 191 | ] 192 | 193 | req = %{ 194 | bindings: [asdf: "foo"], 195 | method: "GET", 196 | qs: "shakespeare=literature" 197 | } 198 | 199 | Spacesuit.ProxyHandler.handle_reply(200, req, headers, self()) 200 | 201 | # The mocked Http server will send us a message telling us 202 | # which method was called. Not the best, but works. 203 | good_reply = 204 | receive do 205 | {:reply, :stream_reply} -> false 206 | {:reply, :reply, _} -> true 207 | end 208 | 209 | assert good_reply == true 210 | end 211 | 212 | test "proxies requests with upstreams AND bodies by streaming" do 213 | headers = [ 214 | {"Date", "Sun, 18 Dec 2016 12:12:02 GMT"}, 215 | {"Content-Length", "1"} 216 | ] 217 | 218 | req = %{ 219 | bindings: [asdf: "foo"], 220 | method: "GET", 221 | qs: "shakespeare=literature", 222 | path_info: [] 223 | } 224 | 225 | Spacesuit.ProxyHandler.handle_reply(200, req, headers, self()) 226 | 227 | # The mocked Http server will send us a message telling us 228 | # which method was called. Not the best, but works. 229 | good_reply = 230 | receive do 231 | {:reply, :stream_reply} -> true 232 | {:reply, :reply, _} -> false 233 | end 234 | 235 | assert good_reply == true 236 | end 237 | 238 | test "proxies requests with a 204 status without streaming" do 239 | headers = [ 240 | {"Date", "Sun, 18 Dec 2016 12:12:02 GMT"} 241 | ] 242 | 243 | req = %{ 244 | bindings: [asdf: "foo"], 245 | method: "GET", 246 | qs: "shakespeare=literature" 247 | } 248 | 249 | Spacesuit.ProxyHandler.handle_reply(204, req, headers, self()) 250 | 251 | # The mocked Http server will send us a message telling us 252 | # which method was called. Not the best, but works. 253 | good_reply = 254 | receive do 255 | {:reply, :stream_reply} -> false 256 | {:reply, :reply, _} -> true 257 | end 258 | 259 | assert good_reply == true 260 | end 261 | 262 | test "proxies requests with a nil upstream without streaming" do 263 | headers = [ 264 | {"Date", "Sun, 18 Dec 2016 12:12:02 GMT"} 265 | ] 266 | 267 | req = %{ 268 | bindings: [asdf: "foo"], 269 | method: "GET", 270 | qs: "shakespeare=literature" 271 | } 272 | 273 | Spacesuit.ProxyHandler.handle_reply(200, req, headers, nil) 274 | 275 | # The mocked Http server will send us a message telling us 276 | # which method was called. Not the best, but works. 277 | good_reply = 278 | receive do 279 | {:reply, :stream_reply} -> false 280 | {:reply, :reply, _} -> true 281 | end 282 | 283 | assert good_reply == true 284 | end 285 | 286 | test "proxies requests with a transfer-encoding by streaming" do 287 | headers = [ 288 | {"Date", "Sun, 18 Dec 2016 12:12:02 GMT"}, 289 | {"TRanSfer-encodinG", "chunked"} 290 | ] 291 | 292 | req = %{ 293 | bindings: [asdf: "foo"], 294 | method: "GET", 295 | qs: "shakespeare=literature" 296 | } 297 | 298 | Spacesuit.ProxyHandler.handle_reply(200, req, headers, self()) 299 | 300 | # The mocked Http server will send us a message telling us 301 | # which method was called. Not the best, but works. 302 | good_reply = 303 | receive do 304 | {:reply, :stream_reply} -> true 305 | {:reply, :reply, _} -> false 306 | end 307 | 308 | assert good_reply == true 309 | end 310 | end 311 | -------------------------------------------------------------------------------- /test/spacesuit_cors_middleware_test.exs: -------------------------------------------------------------------------------- 1 | defmodule SpacesuitCorsMiddlewareTest do 2 | use ExUnit.Case 3 | doctest Spacesuit.CorsMiddleware 4 | 5 | @original_app_config Application.get_env(:spacesuit, :cors) 6 | 7 | setup_all do 8 | req = %{ 9 | :headers => %{"origin" => "http://localhost"}, 10 | :scheme => "http", 11 | :host => "www.example.com", 12 | :port => 80, 13 | :path => "/matched", 14 | :method => "GET" 15 | } 16 | 17 | {:ok, req: req} 18 | end 19 | 20 | setup do 21 | Application.put_env(:spacesuit, :cors, @original_app_config) 22 | end 23 | 24 | describe "changing various configuration settings" do 25 | test "passes all requests when the middleware is disabled", state do 26 | # Override the :enabled setting 27 | current = Application.get_env(:spacesuit, :cors) 28 | Application.put_env(:spacesuit, :cors, Map.merge(current, %{enabled: false})) 29 | 30 | # This would fail if the CORS support were enabled 31 | req = Map.merge(state[:req], %{:headers => %{"origin" => ""}}) 32 | env = %{} 33 | assert {:ok, ^req, ^env} = Spacesuit.CorsMiddleware.execute(req, env) 34 | end 35 | 36 | test "allows any origin when configured to do so", state do 37 | current = Application.get_env(:spacesuit, :cors) 38 | Application.put_env(:spacesuit, :cors, Map.merge(current, %{any_origin_allowed: true})) 39 | 40 | req = 41 | Map.merge(state[:req], %{ 42 | :host => "example.com", 43 | :method => "OPTIONS", 44 | :headers => %{ 45 | "origin" => "http://www.example.com", 46 | "access-control-request-method" => "GET" 47 | } 48 | }) 49 | 50 | env = %{} 51 | 52 | {:stop, req2} = Spacesuit.CorsMiddleware.execute(req, env) 53 | assert req2[:resp_headers]["access-control-allow-origin"] == "*" 54 | end 55 | 56 | test "limits allowed HTTP methods when set" do 57 | Application.put_env(:spacesuit, :cors, %{allowed_http_methods: [:GET]}) 58 | assert Spacesuit.CorsMiddleware.allowed_http_method?(:PUT) == false 59 | assert Spacesuit.CorsMiddleware.allowed_http_method?(:GET) == true 60 | end 61 | end 62 | 63 | describe "handling non-matching request paths" do 64 | test "passes through OK" do 65 | req = %{path: "/not-matched"} 66 | env = %{} 67 | assert {:ok, ^req, ^env} = Spacesuit.CorsMiddleware.execute(req, env) 68 | end 69 | end 70 | 71 | describe "handling common CORS request" do 72 | test "passes through requests without an origin header" do 73 | req = %{path: "/matched"} 74 | env = %{} 75 | assert {:ok, ^req, ^env} = Spacesuit.CorsMiddleware.execute(req, env) 76 | end 77 | 78 | test "passes through same origin requests with a port number", state do 79 | req = 80 | Map.merge(state[:req], %{ 81 | :port => 9000, 82 | :headers => %{"origin" => "http://www.example.com:9000"} 83 | }) 84 | 85 | env = %{} 86 | assert {:ok, ^req, ^env} = Spacesuit.CorsMiddleware.execute(req, env) 87 | end 88 | 89 | test "passes through same origin requests without a port number", state do 90 | req = Map.merge(state[:req], %{:headers => %{"origin" => "http://www.example.com"}}) 91 | env = %{} 92 | assert {:ok, ^req, ^env} = Spacesuit.CorsMiddleware.execute(req, env) 93 | end 94 | 95 | test "does not consider subdomains to be the same origin", state do 96 | req = 97 | Map.merge(state[:req], %{ 98 | :host => "example.com", 99 | :headers => %{"origin" => "http://www.example.com"} 100 | }) 101 | 102 | env = %{} 103 | {_, req2, _} = Spacesuit.CorsMiddleware.execute(req, env) 104 | assert req2[:resp_headers]["access-control-allow-origin"] == "http://www.example.com" 105 | end 106 | 107 | test "does not consider different ports to be the same origin", state do 108 | req = 109 | Map.merge(state[:req], %{ 110 | :headers => %{"origin" => "http://www.example.com:9000"}, 111 | :host => "www.example.com:9001" 112 | }) 113 | 114 | env = %{} 115 | {_, req2, _} = Spacesuit.CorsMiddleware.execute(req, env) 116 | assert req2[:resp_headers]["access-control-allow-origin"] == "http://www.example.com:9000" 117 | end 118 | 119 | test "does not consider different protocols to be the same origin", state do 120 | req = 121 | Map.merge(state[:req], %{ 122 | :headers => %{"origin" => "https://www.example.com:9000"}, 123 | :host => "www.example.com:9000" 124 | }) 125 | 126 | env = %{} 127 | {_, req2, _} = Spacesuit.CorsMiddleware.execute(req, env) 128 | assert req2[:resp_headers]["access-control-allow-origin"] == "https://www.example.com:9000" 129 | end 130 | 131 | test "forbids an empty origin header", state do 132 | req = Map.merge(state[:req], %{:headers => %{"origin" => ""}}) 133 | env = %{} 134 | assert {:stop, _} = Spacesuit.CorsMiddleware.execute(req, env) 135 | end 136 | 137 | test "forbids an invalid origin header", state do 138 | req = Map.merge(state[:req], %{:headers => %{"origin" => "localhost"}}) 139 | env = %{} 140 | assert {:stop, _} = Spacesuit.CorsMiddleware.execute(req, env) 141 | end 142 | 143 | test "forbids an unrecognized HTTP method", state do 144 | req = Map.merge(state[:req], %{:method => "FOO"}) 145 | env = %{} 146 | assert {:stop, _} = Spacesuit.CorsMiddleware.execute(req, env) 147 | end 148 | 149 | test "forbids an empty Access-Control-Request-Method header in a preflight request", state do 150 | req = 151 | Map.merge(state[:req], %{ 152 | :method => "OPTIONS", 153 | :headers => %{"origin" => "http://localhost", "access-control-request-method" => ""} 154 | }) 155 | 156 | env = %{} 157 | assert {:stop, _} = Spacesuit.CorsMiddleware.execute(req, env) 158 | end 159 | 160 | test "handles a simple cross-origin request", state do 161 | {:ok, with_resp_headers, _} = Spacesuit.CorsMiddleware.execute(state[:req], %{}) 162 | resp_headers = with_resp_headers[:resp_headers] 163 | assert "http://localhost" = resp_headers["access-control-allow-origin"] 164 | assert is_nil(resp_headers["access-control-allow-headers"]) 165 | assert is_nil(resp_headers["access-control-allow-methods"]) 166 | assert is_nil(resp_headers["access-control-expose-headers"]) 167 | assert is_nil(resp_headers["access-control-max-age"]) 168 | assert "Origin" = resp_headers["vary"] 169 | end 170 | 171 | test "handles a basic preflight request", state do 172 | req = 173 | Map.merge(state[:req], %{ 174 | :method => "OPTIONS", 175 | :headers => %{"origin" => "http://localhost", "access-control-request-method" => "PUT"} 176 | }) 177 | 178 | {:stop, with_resp_headers} = Spacesuit.CorsMiddleware.execute(req, %{}) 179 | resp_headers = with_resp_headers[:resp_headers] 180 | assert "http://localhost" = resp_headers["access-control-allow-origin"] 181 | assert is_nil(resp_headers["access-control-allow-headers"]) 182 | assert "PUT" = resp_headers["access-control-allow-methods"] 183 | assert is_nil(resp_headers["access-control-expose-headers"]) 184 | assert "3600" = resp_headers["access-control-max-age"] 185 | assert "Origin" = resp_headers["vary"] 186 | end 187 | 188 | test "should not include access control max age header if option is invalid", state do 189 | invalidMaxAge = -1000 190 | current = Application.get_env(:spacesuit, :cors) 191 | 192 | Application.put_env( 193 | :spacesuit, 194 | :cors, 195 | Map.merge(current, %{preflight_max_age: invalidMaxAge}) 196 | ) 197 | 198 | req = 199 | Map.merge(state[:req], %{ 200 | :method => "OPTIONS", 201 | :headers => %{ 202 | "origin" => "http://localhost", 203 | "access-control-request-method" => "PUT" 204 | } 205 | }) 206 | 207 | {:stop, with_resp_headers} = Spacesuit.CorsMiddleware.execute(req, %{}) 208 | resp_headers = with_resp_headers[:resp_headers] 209 | assert is_nil(resp_headers["access-control-max-age"]) 210 | end 211 | 212 | test "handles a preflight request with request headers", state do 213 | req = 214 | Map.merge(state[:req], %{ 215 | :method => "OPTIONS", 216 | :headers => %{ 217 | "origin" => "http://localhost", 218 | "access-control-request-method" => "PUT", 219 | "access-control-request-headers" => "X-Header1, X-Header2" 220 | } 221 | }) 222 | 223 | {:stop, with_resp_headers} = Spacesuit.CorsMiddleware.execute(req, %{}) 224 | resp_headers = with_resp_headers[:resp_headers] 225 | assert "http://localhost" = resp_headers["access-control-allow-origin"] 226 | assert "x-header1,x-header2" = resp_headers["access-control-allow-headers"] 227 | assert "PUT" = resp_headers["access-control-allow-methods"] 228 | assert is_nil(resp_headers["access-control-expose-headers"]) 229 | assert "3600" = resp_headers["access-control-max-age"] 230 | assert "Origin" = resp_headers["vary"] 231 | end 232 | end 233 | 234 | test "handles requests with any access control headers if option is empty", state do 235 | current = Application.get_env(:spacesuit, :cors) 236 | 237 | Application.put_env( 238 | :spacesuit, 239 | :cors, 240 | Map.merge(current, %{access_control_request_headers: nil}) 241 | ) 242 | 243 | req = 244 | Map.merge(state[:req], %{ 245 | :method => "OPTIONS", 246 | :headers => %{ 247 | "origin" => "http://localhost", 248 | "access-control-request-method" => "PUT", 249 | "access-control-request-headers" => "Fancy-header" 250 | } 251 | }) 252 | 253 | {:stop, with_resp_headers} = Spacesuit.CorsMiddleware.execute(req, %{}) 254 | resp_headers = with_resp_headers[:resp_headers] 255 | assert "fancy-header" = resp_headers["access-control-allow-headers"] 256 | end 257 | end 258 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "afunix": {:git, "https://github.com/tonyrog/afunix.git", "37611258f5446a578391c23def6ca4565c4c67e7", [tag: "1.0"]}, 3 | "amqp_client": {:git, "git://github.com/jbrisbin/amqp_client.git", "0878abe6b40cc202b35d86fae32698598c022f0d", [tag: "rabbitmq-3.3.5"]}, 4 | "apex": {:hex, :apex, "1.1.0", "d7a03b8d3e4d9241bd33d986f7bc440f32987ca92dfbec07d491f85593791c82", [:mix], [], "hexpm", "af4ab590d7f2631e9a29fbd9f691a03d61f4269f4095544f5476a9f3b1cc928c"}, 5 | "base64url": {:hex, :base64url, "0.0.1", "36a90125f5948e3afd7be97662a1504b934dd5dac78451ca6e9abf85a10286be", [:rebar], [], "hexpm", "fab09b20e3f5db886725544cbcf875b8e73ec93363954eb8a1a9ed834aa8c1f9"}, 6 | "bear": {:hex, :bear, "0.8.3", "866002127a97932720a0d475d8582c98ec5fb78db3a18dfe7eaf91594d9024b8", [:rebar3], [], "hexpm", "0a04ce4702e00e0a43c0fcdd63e38c9c7d64dceb32b27ffed261709e7c3861ad"}, 7 | "certifi": {:hex, :certifi, "1.0.0", "1c787a85b1855ba354f0b8920392c19aa1d06b0ee1362f9141279620a5be2039", [:rebar3], [], "hexpm", "44a5aa4261490a7d7fa6909ab4bcf14bff928a4fef49e80fc1e7a8fdb7b45f79"}, 8 | "cowboy": {:git, "https://github.com/extend/cowboy.git", "80f8cda7ff8fe6a575b4c2eaedd8451acf4fcef3", []}, 9 | "cowlib": {:git, "https://github.com/ninenines/cowlib", "8e6d0f462850a90bd8a60995f52027839288d038", [ref: "master"]}, 10 | "edown": {:hex, :edown, "0.7.0", "6803599606b10f8e328d6d60c44cd16244cd2429908894c415fce4211c3bfefd", [:make, :rebar], [], "hexpm", "6d7365a7854cd724e8d1fd005f5faa4444eae6a87eb6df9b789b6e7f6f09110a"}, 11 | "elixometer": {:git, "https://github.com/pinterest/elixometer.git", "0ef0f2036014e8edc292ec2d3077142af975df73", []}, 12 | "excoveralls": {:hex, :excoveralls, "0.6.1", "9e946b6db84dba592f47632157ecd135a46384b98a430fd16007dc910c70348b", [:mix], [{:exjsx, "~> 3.0", [hex: :exjsx, repo: "hexpm", optional: false]}, {:hackney, ">= 0.12.0", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "2af724ca7bf060701c36fe70fa78f2c705dc57547ce82ef975cda18607728903"}, 13 | "exjsx": {:hex, :exjsx, "3.2.1", "1bc5bf1e4fd249104178f0885030bcd75a4526f4d2a1e976f4b428d347614f0f", [:mix], [{:jsx, "~> 2.8.0", [hex: :jsx, repo: "hexpm", optional: false]}], "hexpm", "b55727b206dab96feb025267e5c122ddb448f55b6648f9156b8d481215d80290"}, 14 | "exometer": {:git, "https://github.com/Feuerlabs/exometer.git", "7a7bd8d2b52de4d90f65aa3f6044b0e988319b9e", []}, 15 | "exometer_collectd": {:git, "git://github.com/Feuerlabs/exometer_collectd.git", "a1968f534375adbd775e7cce6694db613a5468c0", [tag: "1.0.1"]}, 16 | "exometer_core": {:hex, :exometer_core, "1.4.0", "a546ccd38be910de6ea75ea95b724b6b2db513c26f02e30919b25eb19651815a", [:rebar3], [{:edown, "0.7.0", [hex: :edown, repo: "hexpm", optional: false]}, {:folsom, "0.8.3", [hex: :folsom, repo: "hexpm", optional: false]}, {:lager, "3.0.2", [hex: :lager, repo: "hexpm", optional: false]}, {:parse_trans, "2.9.0", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:setup, "1.7.0", [hex: :setup, repo: "hexpm", optional: false]}], "hexpm", "4cee5d30f2865955b894503c9a00fc8d60a5a33c4577327b8fb2ec430fc3777e"}, 17 | "exometer_newrelic_reporter": {:git, "https://github.com/nitro/exometer_newrelic_reporter.git", "ad17c0b0ccd29e85711ba8ac6e89c828c7d49f09", []}, 18 | "folsom": {:hex, :folsom, "0.8.3", "71cff146152fa95e6092e62119f9dc96888660111e7b61853c8fdcf80598a4e0", [:rebar3], [{:bear, "0.8.3", [hex: :bear, repo: "hexpm", optional: false]}], "hexpm", "afaa1ea4cd2a10a32242ac5d76fa7b17e98d202883859136b791d9a383b26820"}, 19 | "goldrush": {:hex, :goldrush, "0.1.9", "f06e5d5f1277da5c413e84d5a2924174182fb108dabb39d5ec548b27424cd106", [:rebar3], [], "hexpm", "99cb4128cffcb3227581e5d4d803d5413fa643f4eb96523f77d9e6937d994ceb"}, 20 | "hackney": {:hex, :hackney, "1.7.1", "e238c52c5df3c3b16ce613d3a51c7220a784d734879b1e231c9babd433ac1cb4", [:rebar3], [{:certifi, "1.0.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "4.0.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "1.0.1", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "1.0.2", [hex: :mimerl, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "1.1.1", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}], "hexpm", "ec8309cb6d42251513492ef683d212c614d78b20594e5f4d89a05d8411dd0dea"}, 21 | "httpoison": {:hex, :httpoison, "0.9.2", "a211a8e87403a043c41218e64df250d321f236ac57f786c6a0ccf3e9e817c819", [:mix], [{:hackney, "~> 1.6.0", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "edccf20b43da7dabd2f9f2776edc9e18280c0655b2218f4b823900d0d622cbdc"}, 22 | "hut": {:git, "git://github.com/tolbrino/hut.git", "025540398478ab6f95932c3234382ac5bb21ad3e", [ref: "v1.1.1"]}, 23 | "idna": {:hex, :idna, "4.0.0", "10aaa9f79d0b12cf0def53038547855b91144f1bfcc0ec73494f38bb7b9c4961", [:rebar3], [], "hexpm", "f1b699f7275728538da7b5e35679f9e0f41ad8e0a49896e6a27b61867ed344eb"}, 24 | "jason": {:hex, :jason, "1.2.1", "12b22825e22f468c02eb3e4b9985f3d0cb8dc40b9bd704730efa11abd2708c44", [:mix], [{:decimal, "~> 1.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "b659b8571deedf60f79c5a608e15414085fa141344e2716fbd6988a084b5f993"}, 25 | "jiffy": {:git, "git://github.com/davisp/jiffy.git", "137d3d94b6ee10001d761d412cbbe7f665680c98", [ref: "0.13.3"]}, 26 | "joken": {:hex, :joken, "1.4.1", "16b87dcbad59dfb1e75231b9d6e504d852ce11f849efb7150a41dcad29dba8e1", [:mix], [{:jose, "~> 1.8", [hex: :jose, repo: "hexpm", optional: false]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}, {:poison, "~> 1.5 or ~> 2.0 or ~> 3.0", [hex: :poison, repo: "hexpm", optional: true]}], "hexpm", "f995193891960cea0af8cbc56a34acb57ba09c50e5c0343338e959aa554982ae"}, 27 | "jose": {:hex, :jose, "1.8.1", "840ebf379dbdc36a2856d579d911faefe0810270efa97113423af9f76ca7ca0c", [:mix, :rebar3], [{:base64url, "~> 0.0.1", [hex: :base64url, repo: "hexpm", optional: false]}], "hexpm", "e823e2f59e460db7a1db396c8b9a6771274c10bcf3142688899d4007ffcc7e12"}, 28 | "jsk": {:git, "https://github.com/talentdeficit/jsx.git", "45ffea21a6863c58fb7da1f937e868916ff68b27", []}, 29 | "jsx": {:hex, :jsx, "2.8.1", "1453b4eb3615acb3e2cd0a105d27e6761e2ed2e501ac0b390f5bbec497669846", [:mix, :rebar3], [], "hexpm", "0b963582fe53d31b717eb78198c2f6f2f19e36e390cf490bf47b0b25e8022305"}, 30 | "lager": {:hex, :lager, "3.2.4", "a6deb74dae7927f46bd13255268308ef03eb206ec784a94eaf7c1c0f3b811615", [:rebar3], [{:goldrush, "0.1.9", [hex: :goldrush, repo: "hexpm", optional: false]}], "hexpm", "eec0b676776adcfc4f281add4acad1806b2f399774eaaa30f9ec47d2b7881b58"}, 31 | "lager_logger": {:git, "https://github.com/PSPDFKit-labs/lager_logger.git", "432ebf910d33766338b531a0329a2e7a86f98d84", []}, 32 | "meck": {:hex, :meck, "0.8.13", "ffedb39f99b0b99703b8601c6f17c7f76313ee12de6b646e671e3188401f7866", [:rebar3], [], "hexpm", "d34f013c156db51ad57cc556891b9720e6a1c1df5fe2e15af999c84d6cebeb1a"}, 33 | "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, 34 | "mime": {:hex, :mime, "1.4.0", "5066f14944b470286146047d2f73518cf5cca82f8e4815cf35d196b58cf07c47", [:mix], [], "hexpm", "75fa42c4228ea9a23f70f123c74ba7cece6a03b1fd474fe13f6a7a85c6ea4ff6"}, 35 | "mimerl": {:hex, :mimerl, "1.0.2", "993f9b0e084083405ed8252b99460c4f0563e41729ab42d9074fd5e52439be88", [:rebar3], [], "hexpm", "7a4c8e1115a2732a67d7624e28cf6c9f30c66711a9e92928e745c255887ba465"}, 36 | "mock": {:hex, :mock, "0.3.5", "feb81f52b8dcf0a0d65001d2fec459f6b6a8c22562d94a965862f6cc066b5431", [:mix], [{:meck, "~> 0.8.13", [hex: :meck, repo: "hexpm", optional: false]}], "hexpm", "6fae404799408300f863550392635d8f7e3da6b71abdd5c393faf41b131c8728"}, 37 | "netlink": {:git, "git://github.com/Feuerlabs/netlink.git", "aab67b0a16d88e32a2790a3c0a06c37f98ae121e", [ref: "aab67b0"]}, 38 | "new_relic_agent": {:hex, :new_relic_agent, "1.19.4", "e918751b85375185a9f20dbe8acdb0230d0fda3adab842bda6fc86fa925b796b", [:mix], [{:ecto, ">= 3.4.1", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_sql, ">= 3.4.0", [hex: :ecto_sql, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:redix, ">= 0.11.0", [hex: :redix, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "03bd88cdfe258ac37355b7f1e8467921d7b731286a32d8e58ce910af9658ed29"}, 39 | "parse_trans": {:hex, :parse_trans, "2.9.0", "3f5f7b402928fb9fd200c891e635de909045d1efac40ce3f924d3892898f85eb", [:rebar], [{:edown, "> 0.0.0", [hex: :edown, repo: "hexpm", optional: false]}], "hexpm", "dda020976ad4aafe051ce785c0460a71a11b8b6b8c08a98e2c45b83edfdf2978"}, 40 | "plug": {:hex, :plug, "1.10.4", "41eba7d1a2d671faaf531fa867645bd5a3dce0957d8e2a3f398ccff7d2ef017f", [:mix], [{:mime, "~> 1.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", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "ad1e233fe73d2eec56616568d260777b67f53148a999dc2d048f4eb9778fe4a0"}, 41 | "plug_crypto": {:hex, :plug_crypto, "1.1.2", "bdd187572cc26dbd95b87136290425f2b580a116d3fb1f564216918c9730d227", [:mix], [], "hexpm", "6b8b608f895b6ffcfad49c37c7883e8df98ae19c6a28113b02aa1e9c5b22d6b5"}, 42 | "pobox": {:hex, :pobox, "1.0.2", "45a5bc91e3cf20f9bc0b94494f00fcdccbc333ab2e0856972b7f0f196fc41613", [:rebar3], [], "hexpm", "372090633c2565cd645acf2d1e2354c0791d5a5dc2f74885795b8807d402fe88"}, 43 | "poison": {:hex, :poison, "2.2.0", "4763b69a8a77bd77d26f477d196428b741261a761257ff1cf92753a0d4d24a63", [:mix], [], "hexpm", "519bc209e4433961284174c497c8524c001e285b79bdf80212b47a1f898084cc"}, 44 | "rabbit_common": {:git, "git://github.com/jbrisbin/rabbit_common.git", "9c1965273032ffb79ec0ff80b250e5d0b4608aa7", [tag: "rabbitmq-3.3.5"]}, 45 | "ranch": {:git, "https://github.com/ninenines/ranch", "40809cd2b257a8a53bcb5588ecaa88cc5381ff5c", [ref: "1.3.0"]}, 46 | "setup": {:hex, :setup, "1.8.4", "738db0685dc1741f45c6a9bf78478e0d5877f3d0876c0b50fd02f0210edb5aa4", [:rebar3], [], "hexpm", "dde429443bcf40519eb02e45caa589a3d0193a8bf59cfafe8270e2b883f37430"}, 47 | "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.1", "28a4d65b7f59893bc2c7de786dec1e1555bd742d336043fe644ae956c3497fbe", [:make, :rebar], [], "hexpm", "4f8805eb5c8a939cf2359367cb651a3180b27dfb48444846be2613d79355d65e"}, 48 | "telemetry": {:hex, :telemetry, "0.4.2", "2808c992455e08d6177322f14d3bdb6b625fbcfd233a73505870d8738a2f4599", [:rebar3], [], "hexpm", "2d1419bd9dda6a206d7b5852179511722e2b18812310d304620c7bd92a13fcef"}, 49 | } 50 | --------------------------------------------------------------------------------