firewall.py 23 KB

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