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
79def proxy_to(url, method)
80 puts "proxying #{method.to_s.upcase} to #{url}"
81
82 uri = URI.parse(url)
83 h = Net::HTTP.new(uri.host, uri.port)
84 if RAW_QUERIES
85 h.set_debug_output STDOUT
86 end
87
88 if uri.scheme == "https"
89 h.use_ssl = true
90 end
91
92 send_headers = {
93 "Content-type" => (request.env["CONTENT_TYPE"] || "application/x-www-form-urlencoded"),
94 "Host" => uri.host,
95 "User-Agent" => request.env["HTTP_USER_AGENT"],
96 # disable gzip to make it easier to inspect
97 "Accept-Encoding" => "identity",
98 }
99
100 if a = request.env["HTTP_AUTHORIZATION"]
101 send_headers["Authorization"] = a
102 end
103
104 post_data = request.body.read.to_s
105
106 unless RAW_QUERIES
107 if send_headers["Content-type"].to_s.match(/\/json/i)
108 puts "client JSON request:",
109 JSON.pretty_generate(JSON.parse(post_data))
110 else
111 puts "client request: #{post_data.inspect}"
112 end
113 end
114
115 res = case method
116 when :post
117 res = h.post(uri.path, post_data, send_headers)
118 when :get
119 res = h.get(uri.path, send_headers)
120 when :put
121 res = h.put(uri.path, post_data, send_headers)
122 when :delete
123 res = h.delete(uri.path, send_headers)
124 else
125 raise "unknown method type #{method.inspect}"
126 end
127
128 reply_headers = res.actual_headers.reject{|k,v|
129 [ "Connection", "Transfer-Encoding" ].include?(k)
130 }
131
132 r = [ res.code.to_i, reply_headers, res.body ]
133
134 unless RAW_QUERIES
135 if reply_headers["Content-Type"].to_s.match(/\/json/)
136 begin
137 puts "proxy JSON reponse:",
138 JSON.pretty_generate(JSON.parse(res.body))
139 rescue JSON::ParserError => e
140 puts "failed parsing JSON response: #{e.message}"
141 puts "proxy response: #{res.body}"
142 end
143 elsif reply_headers["Content-Type"].to_s.match(/image\//i)
144 puts "(image data of size #{res.body.bytesize} returned)"
145 else
146 puts "proxy response: #{res.body}"
147 end
148 end
149
150 r
151end