Query and transfer ACLs for DNS zones

Provide a way to specify BIND allow-query and allow-transfer ACLs
for DNS zones.

IMPORTANT: new bind-dyndb-ldap adds a zone transfer ability. To
avoid zone information leaks to unintended places, allow-transfer
ACL for every zone is by default set to none and has to be
explicitly enabled by an Administrator. This is done both for new
DNS zones and old DNS zones during RPM update via new DNS upgrade
plugin.

https://fedorahosted.org/freeipa/ticket/1211
This commit is contained in:
Martin Kosek 2012-02-24 09:30:39 +01:00
parent 2cf5893761
commit 8605790225
7 changed files with 264 additions and 14 deletions

12
API.txt
View File

@ -1067,7 +1067,7 @@ output: Output('summary', (<type 'unicode'>, <type 'NoneType'>), None)
output: Entry('result', <type 'dict'>, Gettext('A dictionary representing an LDAP entry', domain='ipa', localedir=None))
output: Output('value', <type 'unicode'>, None)
command: dnszone_add
args: 1,19,3
args: 1,21,3
arg: Str('idnsname', attribute=True, cli_name='name', multivalue=False, primary_key=True, required=True)
option: Str('name_from_ip', attribute=False, cli_name='name_from_ip', multivalue=False, required=False)
option: Str('idnssoamname', attribute=True, cli_name='name_server', multivalue=False, required=True)
@ -1081,6 +1081,8 @@ option: Int('dnsttl', attribute=True, cli_name='ttl', multivalue=False, required
option: StrEnum('dnsclass', attribute=True, cli_name='class', multivalue=False, required=False, values=(u'IN', u'CS', u'CH', u'HS'))
option: Str('idnsupdatepolicy', attribute=True, cli_name='update_policy', multivalue=False, required=False)
option: Bool('idnsallowdynupdate', attribute=True, autofill=True, cli_name='dynamic_update', default=False, multivalue=False, required=False)
option: Str('idnsallowquery', attribute=True, autofill=True, cli_name='allow_query', default=u'any;', multivalue=False, required=False)
option: Str('idnsallowtransfer', attribute=True, autofill=True, cli_name='allow_transfer', default=u'none;', multivalue=False, required=False)
option: Str('setattr*', cli_name='setattr', exclude='webui')
option: Str('addattr*', cli_name='addattr', exclude='webui')
option: Flag('force', autofill=True, default=False)
@ -1111,7 +1113,7 @@ output: Output('summary', (<type 'unicode'>, <type 'NoneType'>), None)
output: Output('result', <type 'bool'>, None)
output: Output('value', <type 'unicode'>, None)
command: dnszone_find
args: 1,21,4
args: 1,23,4
arg: Str('criteria?', noextrawhitespace=False)
option: Str('idnsname', attribute=True, autofill=False, cli_name='name', multivalue=False, primary_key=True, query=True, required=False)
option: Str('name_from_ip', attribute=False, autofill=False, cli_name='name_from_ip', multivalue=False, query=True, required=False)
@ -1127,6 +1129,8 @@ option: StrEnum('dnsclass', attribute=True, autofill=False, cli_name='class', mu
option: Str('idnsupdatepolicy', attribute=True, autofill=False, cli_name='update_policy', multivalue=False, query=True, required=False)
option: Bool('idnszoneactive', attribute=True, autofill=False, cli_name='zone_active', multivalue=False, query=True, required=False)
option: Bool('idnsallowdynupdate', attribute=True, autofill=False, cli_name='dynamic_update', default=False, multivalue=False, query=True, required=False)
option: Str('idnsallowquery', attribute=True, autofill=False, cli_name='allow_query', default=u'any;', multivalue=False, query=True, required=False)
option: Str('idnsallowtransfer', attribute=True, autofill=False, cli_name='allow_transfer', default=u'none;', multivalue=False, query=True, required=False)
option: Int('timelimit?', autofill=False, minvalue=0)
option: Int('sizelimit?', autofill=False, minvalue=0)
option: Flag('forward_only', autofill=True, cli_name='forward_only', default=False)
@ -1139,7 +1143,7 @@ output: ListOfEntries('result', (<type 'list'>, <type 'tuple'>), Gettext('A list
output: Output('count', <type 'int'>, None)
output: Output('truncated', <type 'bool'>, None)
command: dnszone_mod
args: 1,19,3
args: 1,21,3
arg: Str('idnsname', attribute=True, cli_name='name', multivalue=False, primary_key=True, query=True, required=True)
option: Str('name_from_ip', attribute=False, autofill=False, cli_name='name_from_ip', multivalue=False, required=False)
option: Str('idnssoamname', attribute=True, autofill=False, cli_name='name_server', multivalue=False, required=False)
@ -1153,6 +1157,8 @@ option: Int('dnsttl', attribute=True, autofill=False, cli_name='ttl', multivalue
option: StrEnum('dnsclass', attribute=True, autofill=False, cli_name='class', multivalue=False, required=False, values=(u'IN', u'CS', u'CH', u'HS'))
option: Str('idnsupdatepolicy', attribute=True, autofill=False, cli_name='update_policy', multivalue=False, required=False)
option: Bool('idnsallowdynupdate', attribute=True, autofill=False, cli_name='dynamic_update', default=False, multivalue=False, required=False)
option: Str('idnsallowquery', attribute=True, autofill=False, cli_name='allow_query', default=u'any;', multivalue=False, required=False)
option: Str('idnsallowtransfer', attribute=True, autofill=False, cli_name='allow_transfer', default=u'none;', multivalue=False, required=False)
option: Str('setattr*', cli_name='setattr', exclude='webui')
option: Str('addattr*', cli_name='addattr', exclude='webui')
option: Str('delattr*', cli_name='delattr', exclude='webui')

View File

@ -30,7 +30,7 @@ from ipalib.plugins.baseldap import *
from ipalib import _, ngettext
from ipalib.util import validate_zonemgr, normalize_zonemgr, validate_hostname
from ipapython import dnsclient
from ipapython.ipautil import valid_ip
from ipapython.ipautil import valid_ip, CheckedIPAddress
from ldap import explode_dn
__doc__ = _("""
@ -48,6 +48,9 @@ EXAMPLES:
ipa dnszone-mod example.com --dynamic-update=TRUE \\
--update-policy="grant EXAMPLE.COM krb5-self * A; grant EXAMPLE.COM krb5-self * AAAA;"
Modify the zone to allow zone transfers for local network only:
ipa dnszone-mod example.com --allow-transfer=10.0.0.0/8
Add new reverse zone specified by network IP address:
ipa dnszone-add --name-from-ip=80.142.15.0/24 \\
--name-server=nameserver.example.com
@ -225,6 +228,68 @@ def _validate_ipnet(ugettext, ipnet):
return _('invalid IP network format')
return None
def _validate_bind_aci(ugettext, bind_acis):
if not bind_acis:
return
bind_acis = bind_acis.split(';')
if bind_acis[-1]:
return _('each ACL element must be terminated with a semicolon')
else:
bind_acis.pop(-1)
for bind_aci in bind_acis:
if bind_aci in ("any", "none"):
continue
if bind_aci in ("localhost", "localnets"):
return _('ACL name "%s" is not supported') % bind_aci
if bind_aci.startswith('!'):
bind_aci = bind_aci[1:]
try:
ip = CheckedIPAddress(bind_aci, parse_netmask=True,
allow_network=True)
except (netaddr.AddrFormatError, ValueError), e:
return unicode(e)
except UnboundLocalError:
return _(u"invalid address format")
def _normalize_bind_aci(bind_acis):
if not bind_acis:
return
bind_acis = bind_acis.split(';')
normalized = []
for bind_aci in bind_acis:
if not bind_aci:
continue
if bind_aci in ("any", "none", "localhost", "localnets"):
normalized.append(bind_aci)
continue
prefix = ""
if bind_aci.startswith('!'):
bind_aci = bind_aci[1:]
prefix = "!"
try:
ip = CheckedIPAddress(bind_aci, parse_netmask=True,
allow_network=True)
if '/' in bind_aci: # addr with netmask
netmask = "/%s" % ip.prefixlen
else:
netmask = ""
normalized.append(u"%s%s%s" % (prefix, str(ip), netmask))
continue
except:
normalized.append(bind_aci)
continue
acis = u';'.join(normalized)
acis += u';'
return acis
def _domain_name_validator(ugettext, value):
try:
# Allow domain name which is not fully qualified. These are supported
@ -1150,7 +1215,7 @@ class dnszone(LDAPObject):
default_attributes = [
'idnsname', 'idnszoneactive', 'idnssoamname', 'idnssoarname',
'idnssoaserial', 'idnssoarefresh', 'idnssoaretry', 'idnssoaexpire',
'idnssoaminimum'
'idnssoaminimum', 'idnsallowquery', 'idnsallowtransfer'
] + _record_attributes
label = _('DNS Zones')
label_singular = _('DNS Zone')
@ -1254,6 +1319,24 @@ class dnszone(LDAPObject):
default=False,
autofill=True
),
Str('idnsallowquery?',
_validate_bind_aci,
normalizer=_normalize_bind_aci,
cli_name='allow_query',
label=_('Allow query'),
doc=_('Semicolon separated list of IP addresses or networks which are allowed to issue queries'),
default=u'any;', # anyone can issue queries by default
autofill=True,
),
Str('idnsallowtransfer?',
_validate_bind_aci,
normalizer=_normalize_bind_aci,
cli_name='allow_transfer',
label=_('Allow transfer'),
doc=_('Semicolon separated list of IP addresses or networks which are allowed to transfer the zone'),
default=u'none;', # no one can issue queries by default
autofill=True,
),
)
api.register(dnszone)

View File

@ -77,7 +77,9 @@ class CheckedIPAddress(netaddr.IPAddress):
# and don't allow IP addresses such as '1.1.1' in the same time
netaddr_ip_flags = netaddr.INET_PTON
def __init__(self, addr, match_local=False, parse_netmask=True):
def __init__(self, addr, match_local=False, parse_netmask=True,
allow_network=False, allow_loopback=False,
allow_broadcast=False, allow_multicast=False):
if isinstance(addr, CheckedIPAddress):
super(CheckedIPAddress, self).__init__(addr, flags=self.netaddr_ip_flags)
self.prefixlen = addr.prefixlen
@ -98,20 +100,23 @@ class CheckedIPAddress(netaddr.IPAddress):
try:
addr = netaddr.IPAddress(addr, flags=self.netaddr_ip_flags)
except ValueError:
net = netaddr.IPNetwork(addr)
net = netaddr.IPNetwork(addr, flags=self.netaddr_ip_flags)
if not parse_netmask:
raise ValueError("netmask and prefix length not allowed here")
addr = net.ip
if addr.version not in (4, 6):
raise ValueError("unsupported IP version")
if addr.is_loopback():
if not allow_loopback and addr.is_loopback():
raise ValueError("cannot use loopback IP address")
if addr.is_reserved() or addr in netaddr.ip.IPV4_6TO4:
if (not addr.is_loopback() and addr.is_reserved()) \
or addr in netaddr.ip.IPV4_6TO4:
raise ValueError("cannot use IANA reserved IP address")
if addr.is_link_local():
raise ValueError("cannot use link-local IP address")
if addr.is_multicast():
if not allow_multicast and addr.is_multicast():
raise ValueError("cannot use multicast IP address")
if match_local:
@ -143,9 +148,9 @@ class CheckedIPAddress(netaddr.IPAddress):
elif addr.version == 6:
net = netaddr.IPNetwork(str(addr) + '/64')
if addr == net.network:
if not allow_network and addr == net.network:
raise ValueError("cannot use IP network address")
if addr.version == 4 and addr == net.broadcast:
if not allow_broadcast and addr.version == 4 and addr == net.broadcast:
raise ValueError("cannot use broadcast IP address")
super(CheckedIPAddress, self).__init__(addr, flags=self.netaddr_ip_flags)

View File

@ -214,7 +214,9 @@ def add_zone(name, zonemgr=None, dns_backup=None, ns_hostname=None, ns_ip_addres
idnssoarname=unicode(zonemgr),
ip_address=unicode(ns_ip_address),
idnsallowdynupdate=True,
idnsupdatepolicy=unicode(update_policy))
idnsupdatepolicy=unicode(update_policy),
idnsallowquery=u'any',
idnsallowtransfer=u'none',)
except (errors.DuplicateEntry, errors.EmptyModlist):
pass
@ -252,7 +254,9 @@ def add_reverse_zone(zone, ns_hostname=None, ns_ip_address=None,
idnssoamname=unicode(ns_main+'.'),
idnsallowdynupdate=True,
ip_address=unicode(ns_ip_address),
idnsupdatepolicy=unicode(update_policy))
idnsupdatepolicy=unicode(update_policy),
idnsallowquery=u'any',
idnsallowtransfer=u'none',)
except (errors.DuplicateEntry, errors.EmptyModlist):
pass

View File

@ -6,6 +6,7 @@ app_PYTHON = \
baseupdate.py \
fix_replica_memberof.py \
rename_managed.py \
dns.py \
updateclient.py \
$(NULL)

View File

@ -0,0 +1,65 @@
# Authors:
# Martin Kosek <mkosek@redhat.com>
#
# Copyright (C) 2012 Red Hat
# see file 'COPYING' for use and warranty information
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from ipaserver.install.plugins import MIDDLE
from ipaserver.install.plugins.baseupdate import PostUpdate
from ipaserver.install.plugins import baseupdate
from ipalib import api, errors
class update_dnszone_acls(PostUpdate):
"""
Set AllowQuery and AllowTransfer ACLs in all zones that may be configured
in an upgraded FreeIPA instance.
Upgrading to new version of bind-dyndb-ldap and having these ACLs empty
would result in a leak of potentially sensitive DNS information as
zone transfers are enabled for all hosts if not disabled in named.conf
or LDAP.
This plugin disables the zone transfer by default so that it needs to be
explicitly enabled by FreeIPA Administrator.
"""
order=MIDDLE
def execute(self, **options):
ldap = self.obj.backend
try:
zones = api.Command.dnszone_find()['result']
except errors.NotFound:
self.log.info('No DNS zone to update found')
return (False, False, [])
for zone in zones:
update = {}
if not zone.get('idnsallowquery'):
# allow query from any client by default
update['idnsallowquery'] = u'any;'
if not zone.get('idnsallowtransfer'):
# do not open zone transfers by default
update['idnsallowtransfer'] = u'none;'
if update:
api.Command.dnszone_mod(zone[u'idnsname'][0], **update)
return (False, False, [])
api.register(update_dnszone_acls)

View File

@ -115,6 +115,8 @@ class test_dns(Declarative):
'idnssoaexpire': [fuzzy_digits],
'idnssoaminimum': [fuzzy_digits],
'idnsallowdynupdate': [u'FALSE'],
'idnsallowtransfer': [u'none;'],
'idnsallowquery': [u'any;'],
'objectclass': [u'top', u'idnsrecord', u'idnszone'],
},
},
@ -169,6 +171,8 @@ class test_dns(Declarative):
'idnssoaexpire': [fuzzy_digits],
'idnssoaminimum': [fuzzy_digits],
'idnsallowdynupdate': [u'FALSE'],
'idnsallowtransfer': [u'none;'],
'idnsallowquery': [u'any;'],
'objectclass': [u'top', u'idnsrecord', u'idnszone'],
},
},
@ -202,6 +206,8 @@ class test_dns(Declarative):
'idnssoaretry': [fuzzy_digits],
'idnssoaexpire': [fuzzy_digits],
'idnssoaminimum': [fuzzy_digits],
'idnsallowtransfer': [u'none;'],
'idnsallowquery': [u'any;'],
},
},
),
@ -224,6 +230,8 @@ class test_dns(Declarative):
'idnssoaretry': [fuzzy_digits],
'idnssoaexpire': [fuzzy_digits],
'idnssoaminimum': [fuzzy_digits],
'idnsallowtransfer': [u'none;'],
'idnsallowquery': [u'any;'],
},
},
),
@ -254,6 +262,8 @@ class test_dns(Declarative):
'idnssoaexpire': [fuzzy_digits],
'idnssoaminimum': [fuzzy_digits],
'idnsallowdynupdate': [u'FALSE'],
'idnsallowtransfer': [u'none;'],
'idnsallowquery': [u'any;'],
'objectclass': [u'top', u'idnsrecord', u'idnszone'],
},
},
@ -279,6 +289,8 @@ class test_dns(Declarative):
'idnssoaretry': [fuzzy_digits],
'idnssoaexpire': [fuzzy_digits],
'idnssoaminimum': [fuzzy_digits],
'idnsallowtransfer': [u'none;'],
'idnsallowquery': [u'any;'],
},
{
'dn': unicode(dnszone1_dn),
@ -292,6 +304,8 @@ class test_dns(Declarative):
'idnssoaretry': [fuzzy_digits],
'idnssoaexpire': [fuzzy_digits],
'idnssoaminimum': [fuzzy_digits],
'idnsallowtransfer': [u'none;'],
'idnsallowquery': [u'any;'],
}],
},
),
@ -316,6 +330,8 @@ class test_dns(Declarative):
'idnssoaretry': [fuzzy_digits],
'idnssoaexpire': [fuzzy_digits],
'idnssoaminimum': [fuzzy_digits],
'idnsallowtransfer': [u'none;'],
'idnsallowquery': [u'any;'],
}],
},
),
@ -361,6 +377,8 @@ class test_dns(Declarative):
'idnssoaretry': [fuzzy_digits],
'idnssoaexpire': [fuzzy_digits],
'idnssoaminimum': [fuzzy_digits],
'idnsallowtransfer': [u'none;'],
'idnsallowquery': [u'any;'],
},
},
),
@ -395,6 +413,8 @@ class test_dns(Declarative):
'idnssoaretry': [fuzzy_digits],
'idnssoaexpire': [fuzzy_digits],
'idnssoaminimum': [fuzzy_digits],
'idnsallowtransfer': [u'none;'],
'idnsallowquery': [u'any;'],
},
},
),
@ -746,6 +766,8 @@ class test_dns(Declarative):
'idnssoaexpire': [fuzzy_digits],
'idnssoaminimum': [fuzzy_digits],
'idnsallowdynupdate': [u'FALSE'],
'idnsallowtransfer': [u'none;'],
'idnsallowquery': [u'any;'],
'objectclass': [u'top', u'idnsrecord', u'idnszone'],
},
},
@ -787,6 +809,70 @@ class test_dns(Declarative):
),
dict(
desc='Try to add invalid allow-query to zone %r' % dnszone1,
command=('dnszone_mod', [dnszone1], {'idnsallowquery': u'localhost'}),
expected=errors.ValidationError(name='idnsallowquery', error=''),
),
dict(
desc='Add allow-query ACL to zone %r' % dnszone1,
command=('dnszone_mod', [dnszone1], {'idnsallowquery': u'!10/8;any'}),
expected={
'value': dnszone1,
'summary': None,
'result': {
'idnsname': [dnszone1],
'idnszoneactive': [u'TRUE'],
'nsrecord': [dnszone1_mname],
'mxrecord': [u'0 ns1.dnszone.test.'],
'locrecord': [u"49 11 42.400 N 16 36 29.600 E 227.64"],
'idnssoamname': [dnszone1_mname],
'idnssoarname': [dnszone1_rname],
'idnssoaserial': [fuzzy_digits],
'idnssoarefresh': [u'5478'],
'idnssoaretry': [fuzzy_digits],
'idnssoaexpire': [fuzzy_digits],
'idnssoaminimum': [fuzzy_digits],
'idnsallowquery': [u'!10.0.0.0/8;any;'],
'idnsallowtransfer': [u'none;'],
},
},
),
dict(
desc='Try to add invalid allow-transfer to zone %r' % dnszone1,
command=('dnszone_mod', [dnszone1], {'idnsallowtransfer': u'10.'}),
expected=errors.ValidationError(name='idnsallowtransfer', error=''),
),
dict(
desc='Add allow-transer ACL to zone %r' % dnszone1,
command=('dnszone_mod', [dnszone1], {'idnsallowtransfer': u'80.142.15.80'}),
expected={
'value': dnszone1,
'summary': None,
'result': {
'idnsname': [dnszone1],
'idnszoneactive': [u'TRUE'],
'nsrecord': [dnszone1_mname],
'mxrecord': [u'0 ns1.dnszone.test.'],
'locrecord': [u"49 11 42.400 N 16 36 29.600 E 227.64"],
'idnssoamname': [dnszone1_mname],
'idnssoarname': [dnszone1_rname],
'idnssoaserial': [fuzzy_digits],
'idnssoarefresh': [u'5478'],
'idnssoaretry': [fuzzy_digits],
'idnssoaexpire': [fuzzy_digits],
'idnssoaminimum': [fuzzy_digits],
'idnsallowquery': [u'!10.0.0.0/8;any;'],
'idnsallowtransfer': [u'80.142.15.80;'],
},
},
),
dict(
desc='Delete zone %r' % dnszone1,
command=('dnszone_del', [dnszone1], {}),