Security: Q-Feeds Connect - add new options as available in integrated blocklists, closes https://github.com/opnsense/plugins/issues/5197

This adds allowlists (regex patterns), source_nets Q-Feeds applies on, address to return and optional NXDOMAIN responses.

Please note this version is only compatible with current community versions, business edition installs will have to wait for 26.4.
This commit is contained in:
Ad Schellevis 2026-02-15 15:52:15 +01:00
parent 449323e6a5
commit cd23399ea4
5 changed files with 112 additions and 6 deletions

View file

@ -15,4 +15,42 @@
<type>checkbox</type>
<help>Use domain feeds in Unbound DNS blocklist, requires blocklists to be enabled in order to have effect</help>
</field>
<field>
<type>header</type>
<label>Unbound blocklist settings</label>
<style>unbound_options</style>
</field>
<field>
<id>connect.unbound.allowlists</id>
<label>Allowlist Domains</label>
<type>select_multiple</type>
<style>tokenize</style>
<allownew>true</allownew>
<help>List of domains to allow. You can use regular expressions. This allow list only applies to blocklist matches on items in this policy.</help>
</field>
<field>
<id>connect.unbound.source_nets</id>
<label>Source Net(s)</label>
<type>select_multiple</type>
<style>tokenize</style>
<allownew>true</allownew>
<help>Source networks to apply policy on. Examples are 192.168.1.0/24 or 192.168.1.1. Leave empty to apply on everything. All specified networks should use the same protocol family and have equal sizes to avoid priority issues. </help>
</field>
<field>
<id>connect.unbound.address</id>
<label>Destination Address</label>
<type>text</type>
<advanced>true</advanced>
<help>
Destination ip address for entries in the blocklist (leave empty to use default: 0.0.0.0).
Not used when "Return NXDOMAIN" is checked.
</help>
</field>
<field>
<id>connect.unbound.nxdomain</id>
<label>Return NXDOMAIN</label>
<type>checkbox</type>
<advanced>true</advanced>
<help>Use the DNS response code NXDOMAIN instead of a destination address.</help>
</field>
</form>

View file

@ -7,5 +7,21 @@
<apikey type="TextField"/>
<enable_unbound_bl type="BooleanField"/>
</general>
<unbound>
<allowlists type="CSVListField"/>
<source_nets type="NetworkField">
<Multiple>Y</Multiple>
<Strict>Y</Strict>
<ValidationMessage>Please specify a valid network segment or address (IPv4/IPv6). If a mask is provided, please omit the host bits.</ValidationMessage>
<WildcardEnabled>N</WildcardEnabled>
<NetMaskRequired>N</NetMaskRequired>
<AsList>Y</AsList>
</source_nets>
<address type="NetworkField">
<NetMaskAllowed>N</NetMaskAllowed>
<AddressFamily>ipv4</AddressFamily>
</address>
<nxdomain type="BooleanField"/>
</unbound>
</items>
</model>

View file

@ -99,6 +99,13 @@ POSSIBILITY OF SUCH DAMAGE.
}
});
$("#connect\\.general\\.enable_unbound_bl").change(function(){
if ($(this).is(':checked')) {
$(".unbound_options").closest('table').show();
} else {
$(".unbound_options").closest('table').hide();
}
});
let selected_tab = window.location.hash != "" ? window.location.hash : "#settings";
$('a[href="' +selected_tab + '"]').tab('show');

View file

@ -27,12 +27,19 @@
"""
import os
import re
import syslog
import uuid
from . import BaseBlocklistHandler
class DefaultBlocklistHandler(BaseBlocklistHandler):
class QFeedsBlocklistHandler(BaseBlocklistHandler):
def __init__(self):
super().__init__('/usr/local/etc/unbound/qfeeds-blocklists.conf')
self.priority = 50
self._compat_id = str(uuid.uuid4())
def _is_enabled(self):
return self.cnf and self.cnf.has_section('settings') and self.cnf.has_option('settings', 'filenames')
def get_config(self):
# do not use, unbound worker settings
@ -41,11 +48,10 @@ class DefaultBlocklistHandler(BaseBlocklistHandler):
def get_blocklist(self):
# Only return domains if integration is enabled (filenames are offered)
qfeeds_filenames = []
if self.cnf and self.cnf.has_section('settings'):
if self.cnf.has_option('settings', 'filenames'):
qfeeds_filenames = self.cnf.get('settings', 'filenames').split(',')
# touch a file to help qfeedsctl detect the current instance uses its list
open('/tmp/qfeeds-unbound-bl.stat', 'w').write('')
if self._is_enabled():
qfeeds_filenames = self.cnf.get('settings', 'filenames').split(',')
# touch a file to help qfeedsctl detect the current instance uses its list
open('/tmp/qfeeds-unbound-bl.stat', 'w').write('')
result = {}
for filename in qfeeds_filenames:
@ -58,3 +64,35 @@ class DefaultBlocklistHandler(BaseBlocklistHandler):
def get_passlist_patterns(self):
return []
def get_policies(self):
if not self._is_enabled():
return []
cfg = {
'source_nets': [],
'address': '',
'rcode': '',
'id': self._compat_id,
'allowlists': []
}
for k,v in self.cnf['settings'].items():
if k in cfg and v.strip() != '':
if type(cfg[k]) is list:
cfg[k] = v.split(',')
else:
cfg[k] = v.strip()
if cfg['allowlists']:
compiled_passlist = set()
for pattern in cfg['allowlists']:
try:
re.compile(pattern, re.IGNORECASE)
compiled_passlist.add(pattern)
except re.error:
syslog.syslog(syslog.LOG_ERR,'Q-Feeds : skip invalid whitelist exclude pattern "%s"' % pattern)
cfg['passlist'] = '|'.join(compiled_passlist)
del cfg['allowlists']
return [cfg]

View file

@ -2,4 +2,11 @@
not helpers.empty('OPNsense.QFeedsConnector.general.enable_unbound_bl') %}
[settings]
filenames=/var/db/qfeeds-tables/malware_domains.txt
{% if not helpers.empty('OPNsense.QFeedsConnector.unbound') %}
allowlists={{OPNsense.QFeedsConnector.unbound.allowlists|default('')}}
source_nets={{OPNsense.QFeedsConnector.unbound.source_nets|default('')}}
address={{OPNsense.QFeedsConnector.unbound.address|default('0.0.0.0')}}
rcode={% if OPNsense.QFeedsConnector.unbound.nxdomain|default('0') == '1' %}NXDOMAIN{%else%}NOERROR{%endif +%}
cache_ttl={{OPNsense.QFeedsConnector.unbound.cache_ttl|default('72000')}}
{% endif %}
{% endif %}