From 088b04485fee6cee05e074bc79e3f53bf12582de Mon Sep 17 00:00:00 2001 From: Manu de Bump Date: Thu, 31 Oct 2024 11:13:40 +0100 Subject: [PATCH] Validate payload --- Gemfile | 1 + Gemfile.lock | 2 + README.md | 7 + proxy_server.rb | 45 ++++++- spec/proxy_server_spec.rb | 272 ++++++++++++++++++++++++++++++++------ 5 files changed, 288 insertions(+), 39 deletions(-) diff --git a/Gemfile b/Gemfile index 25b0d57..94a2111 100644 --- a/Gemfile +++ b/Gemfile @@ -8,6 +8,7 @@ gem "jwt", "~> 2.9" gem "sinatra", "~> 4.0" gem "rackup", "~> 2.1" gem "puma" +gem "logger" group :development, :test do gem "rspec", "~> 3.13" diff --git a/Gemfile.lock b/Gemfile.lock index c072151..378cf1e 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -24,6 +24,7 @@ GEM base64 language_server-protocol (3.17.0.3) lint_roller (1.1.0) + logger (1.6.1) mustermann (3.0.3) ruby2_keywords (~> 0.0.1) nio4r (2.7.3) @@ -122,6 +123,7 @@ DEPENDENCIES debug (>= 1.0.0) dotenv jwt (~> 2.9) + logger puma rack-test (~> 2.1) rackup (~> 2.1) diff --git a/README.md b/README.md index ed8c37d..69dd60b 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,13 @@ Run the following command to start the server on port 4567: rackup config.ru ``` +### Run the tests + +Run the following command to run the test +```bash +RACK_ENV=test bundle exec rspec --color -fd spec/proxy_server_spec.rb +``` + ### Making Requests - Include the `x-bump-jwt-token` header with a valid JWT in your requests. diff --git a/proxy_server.rb b/proxy_server.rb index c7c2e6d..d48cba7 100644 --- a/proxy_server.rb +++ b/proxy_server.rb @@ -38,7 +38,38 @@ class ProxyServer < Sinatra::Base # Verify JWT token begin public_key = OpenSSL::PKey.read(PUBLIC_KEY) - JWT.decode(token, public_key, true, {algorithm: "RS512"}) + @payload = JWT.decode(token, public_key, false, {algorithm: "RS512"})[0] + + + # Verify token hasn't expired + if Time.now.to_i >= @payload["exp"] + halt 401, {error: "Token has expired"}.to_json + end + + # Verify HTTP method matches + unless @payload["verb"] == request.request_method + halt 403, {error: "HTTP method not allowed"}.to_json + end + + # Get target URL from the request + target_url = request.fullpath[1..].gsub(":/", "://") + uri = URI.parse(target_url) + + # Verify server is allowed + # base_url = "#{uri.scheme}://#{uri.host}#{uri.port == uri.default_port ? '' : ":#{uri.port}"}" + matching_server = @payload["servers"].find { |server| target_url.to_s.include?(server) } + + unless matching_server + halt 403, {error: "Server not allowed"}.to_json + end + + # Verify path matches the pattern + unless path_matches_pattern?(uri.path, @payload["path"]) + halt 403, {error: "Path not allowed"}.to_json + end + + JWT.decode(token, public_key, true, {algorithm: "RS512"})[0] + rescue JWT::DecodeError halt 401, {error: "Invalid token"}.to_json end @@ -51,8 +82,18 @@ class ProxyServer < Sinatra::Base end helpers do + def path_matches_pattern?(actual_path, pattern_path) + # Convert pattern with {param} to regex + # e.g., "/docs/{doc_id}/branches/{slug}" becomes /^\/docs\/[^\/]+\/branches\/[^\/]+$/ + pattern_regex = pattern_path.gsub(/\{[^}]+\}/, '[^/]+') + pattern_regex = "^#{pattern_regex}$" + + # Match the actual path against the regex + Regexp.new(pattern_regex).match?(actual_path) + end + def forward_request(method) - target_url = params["splat"][0].gsub(":/", "://") + target_url = request.fullpath[1..].gsub(":/", "://") uri = URI.parse(target_url) # Set up the request to the target server diff --git a/spec/proxy_server_spec.rb b/spec/proxy_server_spec.rb index f455630..cceb6f7 100644 --- a/spec/proxy_server_spec.rb +++ b/spec/proxy_server_spec.rb @@ -18,11 +18,36 @@ def expect_header(k, v) expect(last_response.headers[k]).to eq v end - let(:valid_token) do + def expect_json_body(k, v) + expect(JSON.parse(last_response.body)[k]).to eq v + end + + let(:verb) { "GET" } + let(:servers) do + [ + "https://jsonplaceholder.typicode.com/" + ] + end + + let(:path) { "/posts" } + let(:exp) { Time.now.to_i + 4 * 3600 } + + let(:payload) do + { + servers: servers, + verb: verb, + path: path, + exp: exp + } + end + + let(:proxy_token) do private_key = OpenSSL::PKey::RSA.new(PRIVATE_KEY) - JWT.encode({data: "test"}, private_key, "RS512") + JWT.encode(payload, private_key, "RS512") end - let(:invalid_token) { "invalid.token.here" } + + let(:invalid_proxy_token) { "invalid.token.here" } + let(:target_url) { "https://jsonplaceholder.typicode.com/posts" } # Mock external requests with WebMock or a similar tool (if desired) @@ -49,63 +74,236 @@ def expect_header(k, v) context "when x-bump-jwt-token is present" do context "and is valid" do - before(:each) do - header "x-bump-jwt-token", valid_token - header "x-foo", "bar" - get "/#{target_url}" + context "when no path params" do + before(:each) do + header "x-bump-proxy-token", proxy_token + header "x-foo", "bar" + get "/#{target_url}" + end + + it "returns 200" do + expect(last_response.status).to eq(200) + end + + it "returns cors headers" do + expect_header("access-control-allow-origin", "*") + end end - it "returns 200" do - expect(last_response.status).to eq(200) + context "when multiple path params" do + let(:path) { "/posts/{post_id}/comments/{id}" } + let(:target_url) { "https://jsonplaceholder.typicode.com/posts/123/comments/456" } + + before(:each) do + stub_request(:get, "https://jsonplaceholder.typicode.com/posts/123/comments/456") + .with( + headers: { + 'Accept'=>'*/*', + 'Accept-Encoding'=>'gzip;q=1.0,deflate;q=0.6,identity;q=0.3', + 'Cookie'=>'', + 'Host'=>'jsonplaceholder.typicode.com', + 'User-Agent'=>'Ruby', + 'X-Foo'=>'bar' + }) + .to_return(status: 200, body: "", headers: {}) + + header "x-bump-proxy-token", proxy_token + header "x-foo", "bar" + get "/#{target_url}" + + + end + + it "returns 200" do + expect(last_response.status).to eq(200) + end + + it "returns cors headers" do + expect_header("access-control-allow-origin", "*") + end end - it "returns cors headers" do + + end + + context "but is invalid" do + before(:each) do + header "x-bump-proxy-token", invalid_proxy_token + get "/#{target_url}" + end + + it "returns a 401 Unauthorized status" do + expect(last_response.status).to eq(401) + end + + it "includes CORS headers in the response" do expect_header("access-control-allow-origin", "*") end + + it "returns the correct error message in the response body" do + expect(JSON.parse(last_response.body)["error"]).to eq("Invalid token") + end end - it "returns 401 for an invalid token" do - header "x-bump-jwt-token", invalid_token - get "/#{target_url}" + describe "Token Payload" do + context "when token is expired" do + let(:exp) { Time.now.to_i - 500 } # 5 minutes ago + + before(:each) do + header "x-bump-proxy-token", proxy_token + header "x-foo", "bar" + get "/#{target_url}" + end + + it "returns 401" do + expect(last_response.status).to eq(401) + end + + it "has error message" do + expect_json_body("error", "Token has expired") + end + + it "returns cors headers" do + expect_header("access-control-allow-origin", "*") + end + end - expect(last_response.status).to eq(401) - expect_header("access-control-allow-origin", "*") - expect(JSON.parse(last_response.body)["error"]).to eq("Invalid token") + context "when HTTP method is not allowed" do + let(:verb) { "PATCH" } # wrong http method + + before(:each) do + header "x-bump-proxy-token", proxy_token + header "x-foo", "bar" + get "/#{target_url}" + end + + it "returns 403" do + expect(last_response.status).to eq(403) + end + + it "has error message" do + expect_json_body("error", "HTTP method not allowed") + end + + it "returns cors headers" do + expect_header("access-control-allow-origin", "*") + end + end + + context "when server is not allowed" do + let(:servers) { ["https://staging.bump.sh/api/v1/"] } + + before(:each) do + header "x-bump-proxy-token", proxy_token + header "x-foo", "bar" + get "/#{target_url}" + end + + it "returns 403" do + expect(last_response.status).to eq(403) + end + + it "has error message" do + expect_json_body("error", "Server not allowed") + end + + it "returns cors headers" do + expect_header("access-control-allow-origin", "*") + end + end + + context "when is not allowed" do + let(:path) { "/comments" } + + before(:each) do + header "x-bump-proxy-token", proxy_token + header "x-foo", "bar" + get "/#{target_url}" + end + + it "returns 403" do + expect(last_response.status).to eq(403) + end + + it "has error message" do + expect_json_body("error", "Path not allowed") + end + + it "returns cors headers" do + expect_header("access-control-allow-origin", "*") + end + end end end - context "when x-bump-jwt-token is missing" do - it "returns 401 Unauthorized" do - get "/#{target_url}" + context "when x-bump-proxy-token is missing" do + before(:each) do + get "/#{target_url}" + end + it "returns 401 Unauthorized status" do expect(last_response.status).to eq(401) + end + + it "includes CORS headers in the response" do expect_header("access-control-allow-origin", "*") - expect(JSON.parse(last_response.body)["error"]).to eq("x-bump-jwt-token header is missing") + end + + it "returns the correct error message in the response body" do + expect(JSON.parse(last_response.body)["error"]).to eq("x-bump-proxy-token header is missing") end end context "request forwarding" do - it "forwards headers and body for POST requests" do - header "x-bump-jwt-token", valid_token - header "Content-Type", "application/json" - post "/#{target_url}", {title: "foo", body: "bar", userId: 1}.to_json + context "when POST requests" do + let(:verb) { "POST" } + let(:request_body) { {title: "foo", body: "bar", userId: 1} } - expect(last_response.status).to eq(201) # Expect created status if target server responds as expected - response_body = JSON.parse(last_response.body) - expect_header("access-control-allow-origin", "*") - expect(response_body["title"]).to eq("foo") - expect(response_body["body"]).to eq("bar") - expect(response_body["userId"]).to eq(1) + before(:each) do + header "x-bump-proxy-token", proxy_token + header "Content-Type", "application/json" + post "/#{target_url}", request_body.to_json + end + + it "returns a 201 Created status" do + expect(last_response.status).to eq(201) + end + + it "includes CORS headers in the response" do + expect_header("access-control-allow-origin", "*") + end + + it "returns the correct title in the response body" do + response_body = JSON.parse(last_response.body) + expect(response_body["title"]).to eq("foo") + end + + it "returns the correct body in the response body" do + response_body = JSON.parse(last_response.body) + expect(response_body["body"]).to eq("bar") + end + + it "returns the correct userId in the response body" do + response_body = JSON.parse(last_response.body) + expect(response_body["userId"]).to eq(1) + end end - it "forwards headers and body for PUT requests" do - header "x-bump-jwt-token", valid_token - header "Content-Type", "application/json" - put "/#{target_url}/1", {id: 1, title: "updated title"}.to_json + context "when PUT requests" do + let(:verb) { "PUT" } + let(:path) { "/posts/{id}" } - expect(last_response.status).to eq(200) # Expect OK status if target server responds as expected - response_body = JSON.parse(last_response.body) - expect(response_body["title"]).to eq("updated title") + before(:each) do + header "x-bump-proxy-token", proxy_token + header "Content-Type", "application/json" + put "/#{target_url}/1", {id: 1, title: "updated title"}.to_json + end + + it "forwards headers and body for PUT requests" do + expect(last_response.status).to eq(200) # Expect OK status if target server responds as expected + response_body = JSON.parse(last_response.body) + expect(response_body["title"]).to eq("updated title") + end end end end