An unofficial, mostly Bitwarden-compatible API server written in Ruby (Sinatra and ActiveRecord)
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