An unofficial, mostly Bitwarden-compatible API server written in Ruby (Sinatra and ActiveRecord)
at master 166 lines 4.5 kB view raw
1#!/usr/bin/env ruby 2# 3# Copyright (c) 2017 joshua stein <jcs@jcs.org> 4# 5# Permission to use, copy, modify, and distribute this software for any 6# purpose with or without fee is hereby granted, provided that the above 7# copyright notice and this permission notice appear in all copies. 8# 9# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 10# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 11# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 12# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 13# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 14# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 15# OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 16# 17 18# 19# A simple proxy intercepting API calls from a Bitwarden client, dumping them 20# out, sending them off to the real Bitwarden servers, dumping the response, 21# and sending it back to the client 22# 23 24require "sinatra" 25require "cgi" 26require "net/https" 27 28set :bind, "0.0.0.0" 29 30# log full queries, otherwise just pretty-printed request and response data 31RAW_QUERIES = false 32 33BASE_URL = "/api" 34IDENTITY_BASE_URL = "/identity" 35ICONS_URL = "/icons" 36 37def upstream_url_for(url) 38 if url.match(/^#{Regexp.escape(IDENTITY_BASE_URL)}/) 39 "https://identity.bitwarden.com" + url.gsub(/^#{Regexp.escape(IDENTITY_BASE_URL)}/, "") 40 elsif url.match(/^#{Regexp.escape(ICONS_URL)}/) 41 "https://icons.bitwarden.com" + url.gsub(/^#{Regexp.escape(ICONS_URL)}/, "") 42 else 43 "https://api.bitwarden.com" + url.gsub(/^#{Regexp.escape(BASE_URL)}/, "") 44 end 45end 46 47# hack in a way to get the actual-cased headers 48module Net::HTTPHeader 49 alias_method :old_add_field, :add_field 50 51 def actual_headers 52 @actual_headers 53 end 54 55 def add_field(key, val) 56 @actual_headers ||= {} 57 @actual_headers[key] = val 58 59 old_add_field key, val 60 end 61end 62 63delete /(.*)/ do 64 proxy_to upstream_url_for(request.path_info), :delete 65end 66 67get /(.*)/ do 68 proxy_to upstream_url_for(request.path_info), :get 69end 70 71post /(.*)/ do 72 proxy_to upstream_url_for(request.path_info), :post 73end 74 75put /(.*)/ do 76 proxy_to upstream_url_for(request.path_info), :put 77end 78 79options /(.*)/ do 80 proxy_to upstream_url_for(request.path_info), :options 81end 82 83def proxy_to(url, method) 84 if RAW_QUERIES 85 puts "#{request.env["REQUEST_METHOD"]} request to #{request.path_info}:" 86 request.env.each do |k,v| 87 if k.match(/^[A-Z_]+$/) 88 puts " #{k}: #{v}" 89 end 90 end 91 end 92 93 puts "proxying #{method.to_s.upcase} to #{url}" 94 95 uri = URI.parse(url) 96 h = Net::HTTP.new(uri.host, uri.port) 97 if RAW_QUERIES 98 h.set_debug_output STDOUT 99 end 100 101 if uri.scheme == "https" 102 h.use_ssl = true 103 end 104 105 send_headers = { 106 "Content-type" => (request.env["CONTENT_TYPE"] || "application/x-www-form-urlencoded"), 107 "Host" => uri.host, 108 "User-Agent" => request.env["HTTP_USER_AGENT"], 109 # disable gzip to make it easier to inspect 110 "Accept-Encoding" => "identity", 111 } 112 113 if a = request.env["HTTP_AUTHORIZATION"] 114 send_headers["Authorization"] = a 115 end 116 117 post_data = request.body.read.to_s 118 119 unless RAW_QUERIES 120 if send_headers["Content-type"].to_s.match(/\/json/i) 121 puts "client JSON request:", 122 JSON.pretty_generate(JSON.parse(post_data)) 123 else 124 puts "client request: #{post_data.inspect}" 125 end 126 end 127 128 res = case method 129 when :post 130 res = h.post(uri.path, post_data, send_headers) 131 when :get 132 res = h.get(uri.path, send_headers) 133 when :put 134 res = h.put(uri.path, post_data, send_headers) 135 when :delete 136 res = h.delete(uri.path, send_headers) 137 when :options 138 res = h.options(uri.path, send_headers) 139 else 140 raise "unknown method type #{method.inspect}" 141 end 142 143 reply_headers = res.actual_headers.reject{|k,v| 144 [ "Connection", "Transfer-Encoding" ].include?(k) 145 } 146 147 r = [ res.code.to_i, reply_headers, res.body ] 148 149 unless RAW_QUERIES 150 if reply_headers["Content-Type"].to_s.match(/\/json/) 151 begin 152 puts "proxy JSON reponse:", 153 JSON.pretty_generate(JSON.parse(res.body)) 154 rescue JSON::ParserError => e 155 puts "failed parsing JSON response: #{e.message}" 156 puts "proxy response: #{res.body}" 157 end 158 elsif reply_headers["Content-Type"].to_s.match(/image\//i) 159 puts "(image data of size #{res.body.bytesize} returned)" 160 else 161 puts "proxy response: #{res.body}" 162 end 163 end 164 165 r 166end