firewall.py 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618
  1. # pylint: disable=too-few-public-methods
  2. #
  3. # The Qubes OS Project, https://www.qubes-os.org/
  4. #
  5. # Copyright (C) 2016
  6. # Marek Marczykowski-Górecki <marmarek@invisiblethingslab.com>
  7. #
  8. # This library is free software; you can redistribute it and/or
  9. # modify it under the terms of the GNU Lesser General Public
  10. # License as published by the Free Software Foundation; either
  11. # version 2.1 of the License, or (at your option) any later version.
  12. #
  13. # This library is distributed in the hope that it will be useful,
  14. # but WITHOUT ANY WARRANTY; without even the implied warranty of
  15. # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
  16. # Lesser General Public License for more details.
  17. #
  18. # You should have received a copy of the GNU Lesser General Public
  19. # License along with this library; if not, see <https://www.gnu.org/licenses/>.
  20. #
  21. import datetime
  22. import string
  23. import itertools
  24. import os
  25. import socket
  26. import asyncio
  27. import lxml.etree
  28. import qubes
  29. import qubes.vm.qubesvm
  30. class RuleOption:
  31. def __init__(self, untrusted_value):
  32. # subset of string.punctuation
  33. safe_set = string.ascii_letters + string.digits + \
  34. ':;,./-_[]'
  35. untrusted_value = str(untrusted_value)
  36. if not all(x in safe_set for x in untrusted_value):
  37. raise ValueError('strange characters in rule')
  38. self._value = untrusted_value
  39. @property
  40. def rule(self):
  41. raise NotImplementedError
  42. @property
  43. def api_rule(self):
  44. return self.rule
  45. def __str__(self):
  46. return self._value
  47. def __eq__(self, other):
  48. return str(self) == other
  49. # noinspection PyAbstractClass
  50. class RuleChoice(RuleOption):
  51. # pylint: disable=abstract-method
  52. def __init__(self, untrusted_value):
  53. # preliminary validation
  54. super(RuleChoice, self).__init__(untrusted_value)
  55. self.allowed_values = \
  56. [v for k, v in self.__class__.__dict__.items()
  57. if not k.startswith('__') and isinstance(v, str) and
  58. not v.startswith('__')]
  59. if untrusted_value not in self.allowed_values:
  60. raise ValueError(untrusted_value)
  61. class Action(RuleChoice):
  62. accept = 'accept'
  63. drop = 'drop'
  64. @property
  65. def rule(self):
  66. return 'action=' + str(self)
  67. class Proto(RuleChoice):
  68. tcp = 'tcp'
  69. udp = 'udp'
  70. icmp = 'icmp'
  71. @property
  72. def rule(self):
  73. return 'proto=' + str(self)
  74. class DstHost(RuleOption):
  75. '''Represent host/network address: either IPv4, IPv6, or DNS name'''
  76. def __init__(self, untrusted_value, prefixlen=None):
  77. if untrusted_value.count('/') > 1:
  78. raise ValueError('Too many /: ' + untrusted_value)
  79. if not untrusted_value.count('/'):
  80. # add prefix length to bare IP addresses
  81. try:
  82. socket.inet_pton(socket.AF_INET6, untrusted_value)
  83. value = untrusted_value
  84. self.prefixlen = prefixlen or 128
  85. if self.prefixlen < 0 or self.prefixlen > 128:
  86. raise ValueError(
  87. 'netmask for IPv6 must be between 0 and 128')
  88. value += '/' + str(self.prefixlen)
  89. self.type = 'dst6'
  90. except socket.error:
  91. try:
  92. socket.inet_pton(socket.AF_INET, untrusted_value)
  93. if untrusted_value.count('.') != 3:
  94. raise ValueError(
  95. 'Invalid number of dots in IPv4 address')
  96. value = untrusted_value
  97. self.prefixlen = prefixlen or 32
  98. if self.prefixlen < 0 or self.prefixlen > 32:
  99. raise ValueError(
  100. 'netmask for IPv4 must be between 0 and 32')
  101. value += '/' + str(self.prefixlen)
  102. self.type = 'dst4'
  103. except socket.error:
  104. self.type = 'dsthost'
  105. self.prefixlen = 0
  106. safe_set = string.ascii_lowercase + string.digits + '-._'
  107. if not all(c in safe_set for c in untrusted_value):
  108. raise ValueError('Invalid hostname')
  109. value = untrusted_value
  110. else:
  111. untrusted_host, untrusted_prefixlen = untrusted_value.split('/', 1)
  112. prefixlen = int(untrusted_prefixlen)
  113. if prefixlen < 0:
  114. raise ValueError('netmask must be non-negative')
  115. self.prefixlen = prefixlen
  116. try:
  117. socket.inet_pton(socket.AF_INET6, untrusted_host)
  118. value = untrusted_value
  119. if prefixlen > 128:
  120. raise ValueError('netmask for IPv6 must be <= 128')
  121. self.type = 'dst6'
  122. except socket.error:
  123. try:
  124. socket.inet_pton(socket.AF_INET, untrusted_host)
  125. if prefixlen > 32:
  126. raise ValueError('netmask for IPv4 must be <= 32')
  127. self.type = 'dst4'
  128. if untrusted_host.count('.') != 3:
  129. raise ValueError(
  130. 'Invalid number of dots in IPv4 address')
  131. value = untrusted_value
  132. except socket.error:
  133. raise ValueError('Invalid IP address: ' + untrusted_host)
  134. super(DstHost, self).__init__(value)
  135. @property
  136. def rule(self):
  137. return self.type + '=' + str(self)
  138. class DstPorts(RuleOption):
  139. def __init__(self, untrusted_value):
  140. if isinstance(untrusted_value, int):
  141. untrusted_value = str(untrusted_value)
  142. if untrusted_value.count('-') == 1:
  143. self.range = [int(x) for x in untrusted_value.split('-', 1)]
  144. elif not untrusted_value.count('-'):
  145. self.range = [int(untrusted_value), int(untrusted_value)]
  146. else:
  147. raise ValueError(untrusted_value)
  148. if any(port < 0 or port > 65536 for port in self.range):
  149. raise ValueError('Ports out of range')
  150. if self.range[0] > self.range[1]:
  151. raise ValueError('Invalid port range')
  152. super(DstPorts, self).__init__(
  153. str(self.range[0]) if self.range[0] == self.range[1]
  154. else '-'.join(map(str, self.range)))
  155. @property
  156. def rule(self):
  157. return 'dstports=' + '{!s}-{!s}'.format(*self.range)
  158. class IcmpType(RuleOption):
  159. def __init__(self, untrusted_value):
  160. untrusted_value = int(untrusted_value)
  161. if untrusted_value < 0 or untrusted_value > 255:
  162. raise ValueError('ICMP type out of range')
  163. super(IcmpType, self).__init__(untrusted_value)
  164. @property
  165. def rule(self):
  166. return 'icmptype=' + str(self)
  167. class SpecialTarget(RuleChoice):
  168. dns = 'dns'
  169. @property
  170. def rule(self):
  171. return 'specialtarget=' + str(self)
  172. class Expire(RuleOption):
  173. def __init__(self, untrusted_value):
  174. super(Expire, self).__init__(untrusted_value)
  175. self.datetime = datetime.datetime.fromtimestamp(int(untrusted_value))
  176. @property
  177. def rule(self):
  178. pass
  179. @property
  180. def api_rule(self):
  181. return 'expire=' + str(self)
  182. @property
  183. def expired(self):
  184. return self.datetime < datetime.datetime.now()
  185. class Comment(RuleOption):
  186. # noinspection PyMissingConstructor
  187. def __init__(self, untrusted_value):
  188. # pylint: disable=super-init-not-called
  189. # subset of string.punctuation
  190. safe_set = string.ascii_letters + string.digits + \
  191. ':;,./-_[] '
  192. untrusted_value = str(untrusted_value)
  193. if not all(x in safe_set for x in untrusted_value):
  194. raise ValueError('strange characters comment')
  195. self._value = untrusted_value
  196. @property
  197. def rule(self):
  198. pass
  199. @property
  200. def api_rule(self):
  201. return 'comment=' + str(self)
  202. class Rule(qubes.PropertyHolder):
  203. def __init__(self, xml=None, **kwargs):
  204. '''Single firewall rule
  205. :param xml: XML element describing rule, or None
  206. :param kwargs: rule elements
  207. '''
  208. super(Rule, self).__init__(xml, **kwargs)
  209. self.load_properties()
  210. self.events_enabled = True
  211. # validate dependencies
  212. if self.dstports:
  213. self.on_set_dstports('property-set:dstports', 'dstports',
  214. self.dstports, None)
  215. if self.icmptype:
  216. self.on_set_icmptype('property-set:icmptype', 'icmptype',
  217. self.icmptype, None)
  218. self.property_require('action', False, True)
  219. action = qubes.property('action',
  220. type=Action,
  221. order=0,
  222. doc='rule action')
  223. proto = qubes.property('proto',
  224. type=Proto,
  225. default=None,
  226. order=1,
  227. doc='protocol to match')
  228. dsthost = qubes.property('dsthost',
  229. type=DstHost,
  230. default=None,
  231. order=1,
  232. doc='destination host/network')
  233. dstports = qubes.property('dstports',
  234. type=DstPorts,
  235. default=None,
  236. order=2,
  237. doc='Destination port(s) (for \'tcp\' and \'udp\' protocol only)')
  238. icmptype = qubes.property('icmptype',
  239. type=IcmpType,
  240. default=None,
  241. order=2,
  242. doc='ICMP packet type (for \'icmp\' protocol only)')
  243. specialtarget = qubes.property('specialtarget',
  244. type=SpecialTarget,
  245. default=None,
  246. order=1,
  247. doc='Special target, for now only \'dns\' supported')
  248. expire = qubes.property('expire',
  249. type=Expire,
  250. default=None,
  251. doc='Timestamp (UNIX epoch) on which this rule expire')
  252. comment = qubes.property('comment',
  253. type=Comment,
  254. default=None,
  255. doc='User comment')
  256. # noinspection PyUnusedLocal
  257. @qubes.events.handler('property-pre-set:dstports')
  258. def on_set_dstports(self, event, name, newvalue, oldvalue=None):
  259. # pylint: disable=unused-argument
  260. if self.proto not in ('tcp', 'udp'):
  261. raise ValueError(
  262. 'dstports valid only for \'tcp\' and \'udp\' protocols')
  263. # noinspection PyUnusedLocal
  264. @qubes.events.handler('property-pre-set:icmptype')
  265. def on_set_icmptype(self, event, name, newvalue, oldvalue=None):
  266. # pylint: disable=unused-argument
  267. if self.proto not in ('icmp',):
  268. raise ValueError('icmptype valid only for \'icmp\' protocol')
  269. # noinspection PyUnusedLocal
  270. @qubes.events.handler('property-set:proto')
  271. def on_set_proto(self, event, name, newvalue, oldvalue=None):
  272. # pylint: disable=unused-argument
  273. if newvalue not in ('tcp', 'udp'):
  274. self.dstports = qubes.property.DEFAULT
  275. if newvalue not in ('icmp',):
  276. self.icmptype = qubes.property.DEFAULT
  277. @qubes.events.handler('property-del:proto')
  278. def on_del_proto(self, event, name, oldvalue):
  279. # pylint: disable=unused-argument
  280. self.dstports = qubes.property.DEFAULT
  281. self.icmptype = qubes.property.DEFAULT
  282. @property
  283. def rule(self):
  284. if self.expire and self.expire.expired:
  285. return None
  286. values = []
  287. for prop in self.property_list():
  288. value = getattr(self, prop.__name__)
  289. if value is None:
  290. continue
  291. if value.rule is None:
  292. continue
  293. values.append(value.rule)
  294. return ' '.join(values)
  295. @property
  296. def api_rule(self):
  297. values = []
  298. if self.expire and self.expire.expired:
  299. return None
  300. # put comment at the end
  301. for prop in sorted(self.property_list(),
  302. key=(lambda p: p.__name__ == 'comment')):
  303. value = getattr(self, prop.__name__)
  304. if value is None:
  305. continue
  306. if value.api_rule is None:
  307. continue
  308. values.append(value.api_rule)
  309. return ' '.join(values)
  310. @classmethod
  311. def from_xml_v1(cls, node, action):
  312. netmask = node.get('netmask')
  313. if netmask is None:
  314. netmask = 32
  315. else:
  316. netmask = int(netmask)
  317. address = node.get('address')
  318. if address:
  319. dsthost = DstHost(address, netmask)
  320. else:
  321. dsthost = None
  322. proto = node.get('proto')
  323. port = node.get('port')
  324. toport = node.get('toport')
  325. if port and toport:
  326. dstports = port + '-' + toport
  327. elif port:
  328. dstports = port
  329. else:
  330. dstports = None
  331. # backward compatibility: protocol defaults to TCP if port is specified
  332. if dstports and not proto:
  333. proto = 'tcp'
  334. if proto == 'any':
  335. proto = None
  336. expire = node.get('expire')
  337. kwargs = {
  338. 'action': action,
  339. }
  340. if dsthost:
  341. kwargs['dsthost'] = dsthost
  342. if dstports:
  343. kwargs['dstports'] = dstports
  344. if proto:
  345. kwargs['proto'] = proto
  346. if expire:
  347. kwargs['expire'] = expire
  348. return cls(**kwargs)
  349. @classmethod
  350. def from_api_string(cls, untrusted_rule):
  351. '''Parse a single line of firewall rule'''
  352. # comment is allowed to have spaces
  353. untrusted_options, _, untrusted_comment = untrusted_rule.partition(
  354. 'comment=')
  355. # appropriate handlers in __init__ of individual options will perform
  356. # option-specific validation
  357. kwargs = {}
  358. if untrusted_comment:
  359. kwargs['comment'] = Comment(untrusted_value=untrusted_comment)
  360. for untrusted_option in untrusted_options.strip().split(' '):
  361. untrusted_key, untrusted_value = untrusted_option.split('=', 1)
  362. if untrusted_key in kwargs:
  363. raise ValueError('Option \'{}\' already set'.format(
  364. untrusted_key))
  365. if untrusted_key in [str(prop) for prop in cls.property_list()]:
  366. kwargs[untrusted_key] = cls.property_get_def(
  367. untrusted_key).type(untrusted_value=untrusted_value)
  368. elif untrusted_key in ('dst4', 'dst6', 'dstname'):
  369. if 'dsthost' in kwargs:
  370. raise ValueError('Option \'{}\' already set'.format(
  371. 'dsthost'))
  372. kwargs['dsthost'] = DstHost(untrusted_value=untrusted_value)
  373. else:
  374. raise ValueError('Unknown firewall option')
  375. return cls(**kwargs)
  376. def __eq__(self, other):
  377. if isinstance(other, Rule):
  378. return self.api_rule == other.api_rule
  379. return self.api_rule == str(other)
  380. def __hash__(self):
  381. return hash(self.api_rule)
  382. class Firewall:
  383. def __init__(self, vm, load=True):
  384. assert hasattr(vm, 'firewall_conf')
  385. self.vm = vm
  386. #: firewall rules
  387. self.rules = []
  388. if load:
  389. self.load()
  390. @property
  391. def policy(self):
  392. ''' Default action - always 'drop' '''
  393. return Action('drop')
  394. def __eq__(self, other):
  395. if isinstance(other, Firewall):
  396. return self.rules == other.rules
  397. return NotImplemented
  398. def load_defaults(self):
  399. '''Load default firewall settings'''
  400. self.rules = [Rule(None, action='accept')]
  401. def clone(self, other):
  402. '''Clone firewall settings from other instance.
  403. This method discards pre-existing firewall settings.
  404. :param other: other :py:class:`Firewall` instance
  405. '''
  406. rules = []
  407. for rule in other.rules:
  408. # Rule constructor require some action, will be overwritten by
  409. # clone_properties below
  410. new_rule = Rule(action='drop')
  411. new_rule.clone_properties(rule)
  412. rules.append(new_rule)
  413. self.rules = rules
  414. def load(self):
  415. '''Load firewall settings from a file'''
  416. firewall_conf = os.path.join(self.vm.dir_path, self.vm.firewall_conf)
  417. if os.path.exists(firewall_conf):
  418. self.rules = []
  419. tree = lxml.etree.parse(firewall_conf)
  420. root = tree.getroot()
  421. version = root.get('version', '1')
  422. if version == '1':
  423. self.load_v1(root)
  424. elif version == '2':
  425. self.load_v2(root)
  426. else:
  427. raise qubes.exc.QubesVMError(self.vm,
  428. 'Unsupported firewall.xml version: {}'.format(version))
  429. else:
  430. self.load_defaults()
  431. def load_v1(self, xml_root):
  432. '''Load old (Qubes < 4.0) firewall XML format'''
  433. policy_v1 = xml_root.get('policy')
  434. assert policy_v1 in ('allow', 'deny')
  435. default_policy_is_accept = (policy_v1 == 'allow')
  436. def _translate_action(key):
  437. if xml_root.get(key, policy_v1) == 'allow':
  438. return Action.accept
  439. return Action.drop
  440. self.rules.append(Rule(None,
  441. action=_translate_action('dns'),
  442. specialtarget=SpecialTarget('dns')))
  443. self.rules.append(Rule(None,
  444. action=_translate_action('icmp'),
  445. proto=Proto.icmp))
  446. if default_policy_is_accept:
  447. rule_action = Action.drop
  448. else:
  449. rule_action = Action.accept
  450. for element in xml_root:
  451. rule = Rule.from_xml_v1(element, rule_action)
  452. self.rules.append(rule)
  453. if default_policy_is_accept:
  454. self.rules.append(Rule(None, action='accept'))
  455. def load_v2(self, xml_root):
  456. '''Load new (Qubes >= 4.0) firewall XML format'''
  457. xml_rules = xml_root.find('rules')
  458. for xml_rule in xml_rules:
  459. rule = Rule(xml_rule)
  460. self.rules.append(rule)
  461. def _expire_rules(self):
  462. '''Function called to reload expired rules'''
  463. self.load()
  464. # this will both save rules skipping those expired and trigger
  465. # QubesDB update; and possibly schedule another timer
  466. self.save()
  467. def save(self):
  468. '''Save firewall rules to a file'''
  469. firewall_conf = os.path.join(self.vm.dir_path, self.vm.firewall_conf)
  470. nearest_expire = None
  471. xml_root = lxml.etree.Element('firewall', version=str(2))
  472. xml_rules = lxml.etree.Element('rules')
  473. for rule in self.rules:
  474. if rule.expire:
  475. if rule.expire and rule.expire.expired:
  476. continue
  477. if nearest_expire is None or rule.expire.datetime < \
  478. nearest_expire:
  479. nearest_expire = rule.expire.datetime
  480. xml_rule = lxml.etree.Element('rule')
  481. xml_rule.append(rule.xml_properties())
  482. xml_rules.append(xml_rule)
  483. xml_root.append(xml_rules)
  484. xml_tree = lxml.etree.ElementTree(xml_root)
  485. try:
  486. old_umask = os.umask(0o002)
  487. with open(firewall_conf, 'wb') as firewall_xml:
  488. xml_tree.write(firewall_xml, encoding="UTF-8",
  489. pretty_print=True)
  490. os.umask(old_umask)
  491. except EnvironmentError as err:
  492. self.vm.log.error("save error: {}".format(err))
  493. raise qubes.exc.QubesException('save error: {}'.format(err))
  494. self.vm.fire_event('firewall-changed')
  495. if nearest_expire and not self.vm.app.vmm.offline_mode:
  496. loop = asyncio.get_event_loop()
  497. # by documentation call_at use loop.time() clock, which not
  498. # necessary must be the same as time module; calculate delay and
  499. # use call_later instead
  500. expire_when = nearest_expire - datetime.datetime.now()
  501. loop.call_later(expire_when.total_seconds(), self._expire_rules)
  502. def qdb_entries(self, addr_family=None):
  503. '''Return firewall settings serialized for QubesDB entries
  504. :param addr_family: include rules only for IPv4 (4) or IPv6 (6); if
  505. None, include both
  506. '''
  507. entries = {
  508. 'policy': str(self.policy)
  509. }
  510. exclude_dsttype = None
  511. if addr_family is not None:
  512. exclude_dsttype = 'dst4' if addr_family == 6 else 'dst6'
  513. for ruleno, rule in zip(itertools.count(), self.rules):
  514. if rule.expire and rule.expire.expired:
  515. continue
  516. # exclude rules for another address family
  517. if rule.dsthost and rule.dsthost.type == exclude_dsttype:
  518. continue
  519. entries['{:04}'.format(ruleno)] = rule.rule
  520. return entries