tor.rb 14.3 KB
Newer Older
1
2
def iptables_chains_parse(iptables, table = "filter", &block)
  assert(block_given?)
anonym's avatar
anonym committed
3
  cmd = "#{iptables}-save -c -t #{table} | iptables-xml"
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
  xml_str = $vm.execute_successfully(cmd).stdout
  rexml = REXML::Document.new(xml_str)
  rexml.get_elements('iptables-rules/table/chain').each do |element|
    yield(
      element.attribute('name').to_s,
      element.attribute('policy').to_s,
      element.get_elements('rule')
    )
  end
end

def ip4tables_chains(table = "filter", &block)
  iptables_chains_parse('iptables', table, &block)
end

def ip6tables_chains(table = "filter", &block)
  iptables_chains_parse('ip6tables', table, &block)
end

anonym's avatar
anonym committed
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
def iptables_rules_parse(iptables, chain, table)
  iptables_chains_parse(iptables, table) do |name, _, rules|
    return rules if name == chain
  end
  return nil
end

def iptables_rules(chain, table = "filter")
  iptables_rules_parse("iptables", chain, table)
end

def ip6tables_rules(chain, table = "filter")
  iptables_rules_parse("ip6tables", chain, table)
end

38
39
40
41
42
43
44
45
46
47
48
49
def ip4tables_packet_counter_sum(filters = {})
  pkts = 0
  ip4tables_chains do |name, _, rules|
    next if filters[:tables] && not(filters[:tables].include?(name))
    rules.each do |rule|
      next if filters[:uid] && not(rule.elements["conditions/owner/uid-owner[text()=#{filters[:uid]}]"])
      pkts += rule.attribute('packet-count').to_s.to_i
    end
  end
  return pkts
end

50
51
52
53
54
def try_xml_element_text(element, xpath, default = nil)
  node = element.elements[xpath]
  (node.nil? or not(node.has_text?)) ? default : node.text
end

55
56
Then /^the firewall's policy is to (.+) all IPv4 traffic$/ do |expected_policy|
  expected_policy.upcase!
anonym's avatar
anonym committed
57
58
59
60
61
  ip4tables_chains do |name, policy, _|
    if ["INPUT", "FORWARD", "OUTPUT"].include?(name)
      assert_equal(expected_policy, policy,
                   "Chain #{name} has unexpected policy #{policy}")
    end
62
63
  end
end
64
65
66
67
68

Then /^the firewall is configured to only allow the (.+) users? to connect directly to the Internet over IPv4$/ do |users_str|
  users = users_str.split(/, | and /)
  expected_uids = Set.new
  users.each do |user|
69
    expected_uids << $vm.execute_successfully("id -u #{user}").stdout.to_i
70
  end
anonym's avatar
anonym committed
71
72
73
74
75
76
77
78
79
80
81
  allowed_output = iptables_rules("OUTPUT").find_all do |rule|
    out_iface = rule.elements['conditions/match/o']
    is_maybe_accepted = rule.get_elements('actions/*').find do |action|
      not(["DROP", "REJECT", "LOG"].include?(action.name))
    end
    is_maybe_accepted &&
    (
      # nil => match all interfaces according to iptables-xml
      out_iface.nil? ||
      ((out_iface.text == 'lo') == (out_iface.attribute('invert').to_s == '1'))
    )
82
83
84
  end
  uids = Set.new
  allowed_output.each do |rule|
anonym's avatar
anonym committed
85
86
87
88
89
90
91
92
    rule.elements.each('actions/*') do |action|
      destination = try_xml_element_text(rule, "conditions/match/d")
      if action.name == "ACCEPT"
        # nil == 0.0.0.0/0 according to iptables-xml
        assert(destination == '0.0.0.0/0' || destination.nil?,
               "The following rule has an unexpected destination:\n" +
               rule.to_s)
        state_cond = try_xml_element_text(rule, "conditions/state/state")
93
        next if state_cond == "ESTABLISHED"
anonym's avatar
anonym committed
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
        assert_not_nil(rule.elements['conditions/owner/uid-owner'])
        rule.elements.each('conditions/owner/uid-owner') do |owner|
          uid = owner.text.to_i
          uids << uid
          assert(expected_uids.include?(uid),
                 "The following rule allows uid #{uid} to access the " +
                 "network, but we only expect uids #{expected_uids.to_a} " +
                 "(#{users_str}) to have such access:\n#{rule.to_s}")
        end
      elsif action.name == "call" && action.elements[1].name == "lan"
        lan_subnets = ["10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16"]
        assert(lan_subnets.include?(destination),
               "The following lan-targeted rule's destination is " +
               "#{destination} which may not be a private subnet:\n" +
               rule.to_s)
      else
        raise "Unexpected iptables OUTPUT chain rule:\n#{rule.to_s}"
      end
112
113
114
115
116
117
118
    end
  end
  uids_not_found = expected_uids - uids
  assert(uids_not_found.empty?,
         "Couldn't find rules allowing uids #{uids_not_found.to_a.to_s} " \
         "access to the network")
end
119
120

Then /^the firewall's NAT rules only redirect traffic for Tor's TransPort and DNSPort$/ do
anonym's avatar
anonym committed
121
  loopback_address = "127.0.0.1/32"
122
  tor_onion_addr_space = "127.192.0.0/10"
anonym's avatar
anonym committed
123
124
125
126
  tor_trans_port = "9040"
  dns_port = "53"
  tor_dns_port = "5353"
  ip4tables_chains('nat') do |name, _, rules|
127
128
    if name == "OUTPUT"
      good_rules = rules.find_all do |rule|
anonym's avatar
anonym committed
129
130
131
132
133
134
135
136
137
138
139
140
141
142
        redirect = rule.get_elements('actions/*').all? do |action|
          action.name == "REDIRECT"
        end
        destination = try_xml_element_text(rule, "conditions/match/d")
        redir_port = try_xml_element_text(rule, "actions/REDIRECT/to-ports")
        redirected_to_trans_port = redir_port == tor_trans_port
        udp_destination_port = try_xml_element_text(rule, "conditions/udp/dport")
        dns_redirected_to_tor_dns_port = (udp_destination_port == dns_port) &&
                                         (redir_port == tor_dns_port)
        redirect &&
        (
         (destination == tor_onion_addr_space && redirected_to_trans_port) ||
         (destination == loopback_address && dns_redirected_to_tor_dns_port)
        )
143
      end
anonym's avatar
anonym committed
144
145
146
147
      bad_rules = rules - good_rules
      assert(bad_rules.empty?,
             "The NAT table's OUTPUT chain contains some unexpected " +
             "rules:\n#{bad_rules}")
148
149
    else
      assert(rules.empty?,
anonym's avatar
anonym committed
150
151
             "The NAT table contains unexpected rules for the #{name} " +
             "chain:\n#{rules}")
152
153
154
    end
  end
end
155

156
157
Then /^the firewall is configured to block all external IPv6 traffic$/ do
  ip6_loopback = '::1/128'
158
  expected_policy = "DROP"
159
  ip6tables_chains do |name, policy, rules|
160
161
162
    assert_equal(expected_policy, policy,
                 "The IPv6 #{name} chain has policy #{policy} but we " \
                 "expected #{expected_policy}")
163
164
165
166
167
168
169
170
    good_rules = rules.find_all do |rule|
      ["DROP", "REJECT", "LOG"].any? do |target|
        rule.elements["actions/#{target}"]
      end \
      ||
      ["s", "d"].all? do |x|
        try_xml_element_text(rule, "conditions/match/#{x}") == ip6_loopback
      end
171
    end
172
    bad_rules = rules - good_rules
173
    assert(bad_rules.empty?,
174
           "The IPv6 table's #{name} chain contains some unexpected rules:\n" +
175
           (bad_rules.map { |r| r.to_s }).join("\n"))
176
177
  end
end
178

179
def firewall_has_dropped_packet_to?(proto, host, port)
180
181
182
  regex = "^Dropped outbound packet: .* "
  regex += "DST=#{Regexp.escape(host)} .* "
  regex += "PROTO=#{Regexp.escape(proto)} "
183
  regex += ".* DPT=#{port} " if port
184
  $vm.execute("journalctl --dmesg --output=cat | grep -qP '#{regex}'").success?
185
186
end

intrigeri's avatar
intrigeri committed
187
When /^I open an untorified (TCP|UDP|ICMP) connection to (\S*)(?: on port (\d+))?$/ do |proto, host, port|
188
  assert(!firewall_has_dropped_packet_to?(proto, host, port),
189
190
191
         "A #{proto} packet to #{host}" +
         (port.nil? ? "" : ":#{port}") +
         " has already been dropped by the firewall")
192
  @conn_proto = proto
193
194
  @conn_host = host
  @conn_port = port
195
196
  case proto
  when "TCP"
197
    assert_not_nil(port)
198
    cmd = "echo | nc.traditional #{host} #{port}"
intrigeri's avatar
intrigeri committed
199
    user = LIVE_USER
200
  when "UDP"
201
    assert_not_nil(port)
202
    cmd = "echo | nc.traditional -u #{host} #{port}"
intrigeri's avatar
intrigeri committed
203
    user = LIVE_USER
204
205
  when "ICMP"
    cmd = "ping -c 5 #{host}"
intrigeri's avatar
intrigeri committed
206
    user = 'root'
207
  end
intrigeri's avatar
intrigeri committed
208
  @conn_res = $vm.execute(cmd, :user => user)
209
210
211
end

Then /^the untorified connection fails$/ do
212
213
214
215
216
  case @conn_proto
  when "TCP"
    expected_in_stderr = "Connection refused"
    conn_failed = !@conn_res.success? &&
      @conn_res.stderr.chomp.end_with?(expected_in_stderr)
217
  when "UDP", "ICMP"
218
219
220
221
    conn_failed = !@conn_res.success?
  end
  assert(conn_failed,
         "The untorified #{@conn_proto} connection didn't fail as expected:\n" +
222
         @conn_res.to_s)
223
end
224

225
Then /^the untorified connection is logged as dropped by the firewall$/ do
226
  assert(firewall_has_dropped_packet_to?(@conn_proto, @conn_host, @conn_port),
227
228
229
         "No #{@conn_proto} packet to #{@conn_host}" +
         (@conn_port.nil? ? "" : ":#{@conn_port}") +
         " was dropped by the firewall")
230
end
231

232
When /^the system DNS is(?: still)? using the local DNS resolver$/ do
233
  resolvconf = $vm.file_content("/etc/resolv.conf")
234
235
236
237
238
239
240
  bad_lines = resolvconf.split("\n").find_all do |line|
    !line.start_with?("#") && !/^nameserver\s+127\.0\.0\.1$/.match(line)
  end
  assert_empty(bad_lines,
               "The following bad lines were found in /etc/resolv.conf:\n" +
               bad_lines.join("\n"))
end
241
242
243

def stream_isolation_info(application)
  case application
244
245
  when "htpdate"
    {
246
      :grep_monitor_expr => 'users:(("curl"',
247
248
      :socksport => 9062
    }
249
  when "tails-security-check"
250
    {
251
252
253
254
255
256
      :grep_monitor_expr => 'users:(("tails-security-"',
      :socksport => 9062
    }
  when "tails-upgrade-frontend-wrapper"
    {
      :grep_monitor_expr => 'users:(("tails-iuk-get-u"',
257
258
      :socksport => 9062
    }
259
260
  when "Tor Browser"
    {
261
      :grep_monitor_expr => 'users:(("firefox"',
262
263
      :socksport => 9150,
      :controller => true,
264
    }
265
266
  when "Gobby"
    {
267
      :grep_monitor_expr => 'users:(("gobby-0.5"',
268
269
      :socksport => 9050
    }
270
271
  when "SSH"
    {
272
      :grep_monitor_expr => 'users:(("\(nc\|ssh\)"',
273
274
      :socksport => 9050
    }
275
276
  when "whois"
    {
277
      :grep_monitor_expr => 'users:(("whois"',
278
279
      :socksport => 9050
    }
280
281
282
283
284
  else
    raise "Unknown application '#{application}' for the stream isolation tests"
  end
end

Tails developers's avatar
Tails developers committed
285
When /^I monitor the network connections of (.*)$/ do |application|
286
  @process_monitor_log = "/tmp/ss.log"
287
  info = stream_isolation_info(application)
288
  $vm.spawn("while true; do " +
289
            "  ss -taupen | grep '#{info[:grep_monitor_expr]}'; " +
290
291
292
293
294
            "  sleep 0.1; " +
            "done > #{@process_monitor_log}")
end

Then /^I see that (.+) is properly stream isolated$/ do |application|
295
296
297
  info = stream_isolation_info(application)
  expected_ports = [info[:socksport]]
  expected_ports << 9051 if info[:controller]
298
  assert_not_nil(@process_monitor_log)
299
  log_lines = $vm.file_content(@process_monitor_log).split("\n")
300
301
302
303
  assert(log_lines.size > 0,
         "Couldn't see any connection made by #{application} so " \
         "something is wrong")
  log_lines.each do |line|
304
    ip_port = line.split(/\s+/)[5]
305
306
307
    assert(expected_ports.map { |port| "127.0.0.1:#{port}" }.include?(ip_port),
           "#{application} should only connect to #{expected_ports} but " \
           "was seen connecting to #{ip_port}")
308
309
310
311
  end
end

And /^I re-run tails-security-check$/ do
312
  $vm.execute_successfully("tails-security-check", :user => LIVE_USER)
313
end
314
315

And /^I re-run htpdate$/ do
316
  $vm.execute_successfully("service htpdate stop && " \
317
                           "rm -f /run/htpdate/* && " \
318
                           "systemctl --no-block start htpdate.service")
319
320
  step "the time has synced"
end
321
322

And /^I re-run tails-upgrade-frontend-wrapper$/ do
323
  $vm.execute_successfully("tails-upgrade-frontend-wrapper", :user => LIVE_USER)
324
325
end

326
When /^I connect Gobby to "([^"]+)"$/ do |host|
anonym's avatar
anonym committed
327
  gobby = Dogtail::Application.new('gobby-0.5')
328
  gobby.child('Welcome to Gobby', roleName: 'label')
anonym's avatar
anonym committed
329
  gobby.button('Close').click
330
331
332
  # This indicates that Gobby has finished initializing itself
  # (generating DH parameters, etc.) -- before, the UI is not responsive
  # and our CTRL-t is lost.
333
  gobby.child('Failed to share documents', roleName: 'label')
anonym's avatar
anonym committed
334
335
  gobby.menu('File').click
  gobby.menuItem('Connect to Server...').click
336
  @screen.type("t", Sikuli::KeyModifier.CTRL)
anonym's avatar
anonym committed
337
338
339
340
341
  connect_dialog = gobby.dialog('Connect to Server')
  connect_dialog.child('', roleName: 'text').typeText(host)
  connect_dialog.button('Connect').click
  # This looks for the live user's presence entry in the chat, which
  # will only be shown if the connection succeeded.
342
  try_for(60) { gobby.child(LIVE_USER, roleName: 'table cell'); true }
343
end
344
345

When /^the Tor Launcher autostarts$/ do
346
  @screen.wait('TorLauncherWindow.png', 60)
347
348
end

349
When /^I configure some (\w+) pluggable transports in Tor Launcher$/ do |bridge_type|
anonym's avatar
anonym committed
350
351
352
353
354
355
  @screen.wait_and_click('TorLauncherConfigureButton.png', 10)
  @screen.wait('TorLauncherBridgePrompt.png', 10)
  @screen.wait_and_click('TorLauncherYesRadioOption.png', 10)
  @screen.wait_and_click('TorLauncherNextButton.png', 10)
  @screen.wait_and_click('TorLauncherBridgeList.png', 10)
  @bridge_hosts = []
356
  chutney_src_dir = "#{GIT_DIR}/submodules/chutney"
357
358
359
360
  bridge_dirs = Dir.glob(
    "#{$config['TMPDIR']}/chutney-data/nodes/*#{bridge_type}/"
  )
  bridge_dirs.each do |bridge_dir|
anonym's avatar
anonym committed
361
362
363
364
    address = $vmnet.bridge_ip_addr
    port = nil
    fingerprint = nil
    extra = nil
365
366
    if bridge_type == 'bridge'
      open(bridge_dir + "/torrc") do |f|
anonym's avatar
anonym committed
367
        port = f.grep(/^OrPort\b/).first.split.last
368
369
370
371
372
373
374
375
376
      end
    else
      # This is the pluggable transport case. While we could set a
      # static port via ServerTransportListenAddr we instead let it be
      # picked randomly so an already used port is not picked --
      # Chutney already has issues with that for OrPort selection.
      pt_re = /Registered server transport '#{bridge_type}' at '[^']*:(\d+)'/
      open(bridge_dir + "/notice.log") do |f|
        pt_lines = f.grep(pt_re)
anonym's avatar
anonym committed
377
        port = pt_lines.last.match(pt_re)[1]
378
379
380
      end
      if bridge_type == 'obfs4'
        open(bridge_dir + "/pt_state/obfs4_bridgeline.txt") do |f|
anonym's avatar
anonym committed
381
          extra = f.readlines.last.chomp.sub(/^.* cert=/, 'cert=')
382
383
384
385
        end
      end
    end
    open(bridge_dir + "/fingerprint") do |f|
anonym's avatar
anonym committed
386
      fingerprint = f.read.chomp.split.last
387
    end
388
    @bridge_hosts << { address: address, port: port.to_i }
anonym's avatar
anonym committed
389
390
    bridge_line = bridge_type + " " + address + ":" + port
    [fingerprint, extra].each { |e| bridge_line += " " + e.to_s if e }
391
392
    @screen.type(bridge_line + Sikuli::Key.ENTER)
  end
393
394
  @screen.wait_and_click('TorLauncherNextButton.png', 10)
  @screen.hide_cursor
395
396
397
398
  @screen.wait_and_click('TorLauncherFinishButton.png', 10)
  @screen.wait('TorLauncherConnectingWindow.png', 10)
  @screen.waitVanish('TorLauncherConnectingWindow.png', 120)
end
399

400
When /^all Internet traffic has only flowed through the configured pluggable transports$/ do
401
  assert_not_nil(@bridge_hosts, "No bridges has been configured via the " +
402
                 "'I configure some ... bridges in Tor Launcher' step")
anonym's avatar
anonym committed
403
  assert_all_connections(@sniffer.pcap_file) do |c|
404
    @bridge_hosts.include?({ address: c.daddr, port: c.dport })
405
  end
406
end