#!/usr/bin/env ruby # # Copyright (c) 2017 joshua stein # # Permission to use, copy, modify, and distribute this software for any # purpose with or without fee is hereby granted, provided that the above # copyright notice and this permission notice appear in all copies. # # THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES # WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF # MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR # ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES # WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN # ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF # OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. # # # A simple proxy intercepting API calls from a Bitwarden client, dumping them # out, sending them off to the real Bitwarden servers, dumping the response, # and sending it back to the client # require "sinatra" require "cgi" require "net/https" set :bind, "0.0.0.0" # log full queries, otherwise just pretty-printed request and response data RAW_QUERIES = false BASE_URL = "/api" IDENTITY_BASE_URL = "/identity" ICONS_URL = "/icons" def upstream_url_for(url) if url.match(/^#{Regexp.escape(IDENTITY_BASE_URL)}/) "https://identity.bitwarden.com" + url.gsub(/^#{Regexp.escape(IDENTITY_BASE_URL)}/, "") elsif url.match(/^#{Regexp.escape(ICONS_URL)}/) "https://icons.bitwarden.com" + url.gsub(/^#{Regexp.escape(ICONS_URL)}/, "") else "https://api.bitwarden.com" + url.gsub(/^#{Regexp.escape(BASE_URL)}/, "") end end # hack in a way to get the actual-cased headers module Net::HTTPHeader alias_method :old_add_field, :add_field def actual_headers @actual_headers end def add_field(key, val) @actual_headers ||= {} @actual_headers[key] = val old_add_field key, val end end delete /(.*)/ do proxy_to upstream_url_for(request.path_info), :delete end get /(.*)/ do proxy_to upstream_url_for(request.path_info), :get end post /(.*)/ do proxy_to upstream_url_for(request.path_info), :post end put /(.*)/ do proxy_to upstream_url_for(request.path_info), :put end options /(.*)/ do proxy_to upstream_url_for(request.path_info), :options end def proxy_to(url, method) if RAW_QUERIES puts "#{request.env["REQUEST_METHOD"]} request to #{request.path_info}:" request.env.each do |k,v| if k.match(/^[A-Z_]+$/) puts " #{k}: #{v}" end end end puts "proxying #{method.to_s.upcase} to #{url}" uri = URI.parse(url) h = Net::HTTP.new(uri.host, uri.port) if RAW_QUERIES h.set_debug_output STDOUT end if uri.scheme == "https" h.use_ssl = true end send_headers = { "Content-type" => (request.env["CONTENT_TYPE"] || "application/x-www-form-urlencoded"), "Host" => uri.host, "User-Agent" => request.env["HTTP_USER_AGENT"], # disable gzip to make it easier to inspect "Accept-Encoding" => "identity", } if a = request.env["HTTP_AUTHORIZATION"] send_headers["Authorization"] = a end post_data = request.body.read.to_s unless RAW_QUERIES if send_headers["Content-type"].to_s.match(/\/json/i) puts "client JSON request:", JSON.pretty_generate(JSON.parse(post_data)) else puts "client request: #{post_data.inspect}" end end res = case method when :post res = h.post(uri.path, post_data, send_headers) when :get res = h.get(uri.path, send_headers) when :put res = h.put(uri.path, post_data, send_headers) when :delete res = h.delete(uri.path, send_headers) when :options res = h.options(uri.path, send_headers) else raise "unknown method type #{method.inspect}" end reply_headers = res.actual_headers.reject{|k,v| [ "Connection", "Transfer-Encoding" ].include?(k) } r = [ res.code.to_i, reply_headers, res.body ] unless RAW_QUERIES if reply_headers["Content-Type"].to_s.match(/\/json/) begin puts "proxy JSON reponse:", JSON.pretty_generate(JSON.parse(res.body)) rescue JSON::ParserError => e puts "failed parsing JSON response: #{e.message}" puts "proxy response: #{res.body}" end elsif reply_headers["Content-Type"].to_s.match(/image\//i) puts "(image data of size #{res.body.bytesize} returned)" else puts "proxy response: #{res.body}" end end r end