Branch data Line data Source code
1 : : /* -*- mode: C; c-file-style: "gnu"; indent-tabs-mode: nil; -*-
2 : : *
3 : : * Copyright 2025 GNOME Foundation, Inc.
4 : : *
5 : : * SPDX-License-Identifier: GPL-2.0-or-later
6 : : *
7 : : * This program is free software; you can redistribute it and/or modify
8 : : * it under the terms of the GNU General Public License as published by
9 : : * the Free Software Foundation; either version 2 of the License, or
10 : : * (at your option) any later version.
11 : : *
12 : : * This program is distributed in the hope that it will be useful,
13 : : * but WITHOUT ANY WARRANTY; without even the implied warranty of
14 : : * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15 : : * GNU General Public License for more details.
16 : : *
17 : : * You should have received a copy of the GNU General Public License
18 : : * along with this program; if not, see <http://www.gnu.org/licenses/>.
19 : : *
20 : : * Authors:
21 : : * - Philip Withnall <pwithnall@gnome.org>
22 : : */
23 : :
24 : : #include <arpa/inet.h>
25 : : #include <assert.h>
26 : : #include <cdb.h>
27 : : #include <ctype.h>
28 : : #include <errno.h>
29 : : #include <err.h>
30 : : #include <fcntl.h>
31 : : #include <limits.h>
32 : : #include <nss.h>
33 : : #include <netdb.h>
34 : : #include <pwd.h>
35 : : #include <stdbool.h>
36 : : #include <stddef.h>
37 : : #include <stdio.h>
38 : : #include <stdlib.h>
39 : : #include <string.h>
40 : : #include <sys/types.h>
41 : : #include <unistd.h>
42 : :
43 : :
44 : : /**
45 : : * NSS web filtering module
46 : : *
47 : : * This is an NSS module implementing the `gethostbyname()` functions, which
48 : : * applies a user-specific filter list to all name lookup requests to allow
49 : : * filtering websites.
50 : : *
51 : : * Install it in `/etc/nsswitch.conf` using:
52 : : * ```
53 : : * hosts: files myhostname malcontent mdns4_minimal [NOTFOUND=return] resolve [!UNAVAIL=return] dns
54 : : * ```
55 : : *
56 : : * Build a filter list using `malcontent-webd`, or the following commands:
57 : : * ```
58 : : * wget https://v.firebog.net/hosts/Easylist.txt
59 : : * cdb -c -m test.db Easylist.txt
60 : : * sudo chmod o+r test.db
61 : : * sudo mv test.db /var/lib/malcontent-nss/filter-lists/${username}
62 : : * ```
63 : : *
64 : : * The input filter list must be a simple list of hostnames. Regexps or dot
65 : : * prefixes are not allowed.
66 : : *
67 : : * The outputted database is in
68 : : * [cdb format](https://www.corpit.ru/mjt/tinycdb.html), which is effectively a
69 : : * key-value store.
70 : : *
71 : : * The database has several possible key-value formats:
72 : : * - `*` as a key (with an empty value) indicates that all domains should be
73 : : * blocked by default (i.e. the filter list is effectively an allow-list
74 : : * rather than a block-list)
75 : : * - A hostname as a key, with an empty value, indicates that hostname should
76 : : * be blocked.
77 : : * - A hostname as a key, with another hostname as a value, indicates that the
78 : : * first hostname should be redirected to the second one.
79 : : * - A hostname prefixed with `~` as a key, with an empty value, indicates that
80 : : * hostname should be _allowed_ (even if the `*` key is present in the file).
81 : : *
82 : : * The hostname `use-application-dns.net` is *always* blocked to indicate to
83 : : * browsers that DNS-over-HTTPS should be disabled, as it bypasses this filter
84 : : * module. See the
85 : : * [Mozilla documentation](https://support.mozilla.org/en-US/kb/canary-domain-use-application-dnsnet).
86 : : *
87 : : * NSS documentation:
88 : : * - https://www.gnu.org/software/libc/manual/html_node/NSS-Modules-Interface.html
89 : : * - https://www.gnu.org/software/libc/manual/html_node/NSS-Module-Function-Internals.html
90 : : * - https://elixir.bootlin.com/glibc/glibc-2.41/source/nss/getaddrinfo.c
91 : : */
92 : :
93 : : /* Exported module API: */
94 : : enum nss_status _nss_malcontent_gethostbyname3_r (const char *name,
95 : : int af,
96 : : struct hostent *result,
97 : : char *buffer,
98 : : size_t buffer_len,
99 : : int *errnop,
100 : : int *h_errnop,
101 : : int32_t *ttlp,
102 : : char **canonp);
103 : : enum nss_status _nss_malcontent_gethostbyname2_r (const char *name,
104 : : int af,
105 : : struct hostent *result,
106 : : char *buffer,
107 : : size_t buffer_len,
108 : : int *errnop,
109 : : int *h_errnop);
110 : : enum nss_status _nss_malcontent_sethostent (void);
111 : : enum nss_status _nss_malcontent_endhostent (void);
112 : :
113 : :
114 : : const char *filter_list_dir = "/var/lib/malcontent-nss/filter-lists/";
115 : :
116 : : /* https://datatracker.ietf.org/doc/html/rfc2181#section-11 not including trailing nul */
117 : : #define HOSTNAME_MAX 255 /* bytes */
118 : :
119 : : static inline size_t
120 : 156 : align_as_pointer (size_t in)
121 : : {
122 : 156 : const size_t ptr_alignment = __alignof__ (void *);
123 : 156 : return (in + (ptr_alignment - 1)) & ~(ptr_alignment - 1);
124 : : }
125 : :
126 : : static inline void
127 : 51 : clear_fd (int *fd_ptr)
128 : : {
129 : : /* Don't overwrite thread-local errno if closing the fd fails. We want to
130 : : * ignore errors. */
131 : 51 : int errsv = errno;
132 : 51 : int fd = *fd_ptr;
133 : :
134 : 51 : *fd_ptr = -1;
135 : :
136 [ + + ]: 51 : if (fd < 0)
137 : 30 : return;
138 : :
139 : 21 : close (fd);
140 : 21 : errno = errsv;
141 : : }
142 : :
143 : : static inline void
144 : 64 : clear_addrinfo (struct addrinfo **ai_ptr)
145 : : {
146 : : /* Don't overwrite thread-local errno if closing the fd fails. We want to
147 : : * ignore errors. */
148 : 64 : int errsv = errno;
149 : 64 : struct addrinfo *ai = *ai_ptr;
150 : :
151 : 64 : *ai_ptr = NULL;
152 : :
153 [ + + ]: 64 : if (ai == NULL)
154 : 62 : return;
155 : :
156 : 2 : freeaddrinfo (ai);
157 : 2 : errno = errsv;
158 : : }
159 : :
160 : : static int
161 : 49 : lookup_username (uid_t uid,
162 : : char *buf,
163 : : size_t buf_len)
164 : : {
165 : : char buffer[4096];
166 : : struct passwd pwbuf;
167 : : struct passwd *result;
168 : : int pwuid_errno;
169 : :
170 [ - + ]: 49 : assert (buf_len >= LOGIN_NAME_MAX + 1);
171 : :
172 : 49 : pwuid_errno = getpwuid_r (uid, &pwbuf, buffer, sizeof (buffer), &result);
173 : :
174 [ + + ]: 49 : if (result != NULL &&
175 [ + - + + ]: 45 : result->pw_name != NULL && result->pw_name[0] != '\0')
176 : : {
177 : 43 : strlcpy (buf, result->pw_name, buf_len);
178 : 49 : return 0;
179 : : }
180 [ + + ]: 6 : else if (result != NULL)
181 : : {
182 : 2 : snprintf (buf, buf_len, "%d", (int) uid);
183 : 2 : return 0;
184 : : }
185 [ + + ]: 4 : else if (pwuid_errno == 0)
186 : : {
187 : : /* User not found. */
188 : 2 : buf[0] = '\0';
189 : 2 : return ENOENT;
190 : : }
191 : : else
192 : : {
193 : : /* Error calling getpwuid_r(). */
194 : 2 : buf[0] = '\0';
195 : 2 : return pwuid_errno;
196 : : }
197 : :
198 : : /* Should not be reached */
199 : : assert (0);
200 : : }
201 : :
202 : : /* FIXME: This re-opens the database for each request. We could keep it open
203 : : * between requests and use inotify to reload it, which would save a lot of CPU
204 : : * time. */
205 : : static int
206 : 49 : open_filter_list (int *out_filter_list_fd)
207 : : {
208 : : int username_errno;
209 : 49 : char username[LOGIN_NAME_MAX + 1] = { '\0' };
210 : : uid_t uid;
211 : 49 : char filter_list_file[NAME_MAX] = { '\0', };
212 : 49 : int filter_list_fd = -1;
213 : :
214 [ - + ]: 49 : assert (out_filter_list_fd != NULL);
215 : :
216 : : /* Build the filter list filename using the effective user’s username. */
217 : 49 : uid = geteuid ();
218 : 49 : username_errno = lookup_username (uid, username, sizeof (username));
219 [ + + ]: 49 : if (username_errno != 0)
220 : 4 : return username_errno;
221 : :
222 : 45 : strlcpy (filter_list_file, filter_list_dir, sizeof (filter_list_file));
223 : 45 : strlcat (filter_list_file, username, sizeof (filter_list_file));
224 : :
225 : : /* Open the filter_list file and read it using tinycdb */
226 [ - + ]: 45 : assert (filter_list_file[0] != '\0');
227 : :
228 : 45 : filter_list_fd = open (filter_list_file, O_RDONLY | O_CLOEXEC);
229 [ + + ]: 45 : if (filter_list_fd < 0)
230 : : {
231 [ + + ]: 24 : if (errno == ENOENT)
232 : : {
233 : : /* Filter list doesn’t exist for this user, ignore. */
234 : 22 : *out_filter_list_fd = -1;
235 : 22 : return 0;
236 : : }
237 : : else
238 : : {
239 : : /* Other error, bail out. */
240 : 2 : *out_filter_list_fd = -1;
241 : 2 : return errno;
242 : : }
243 : : }
244 : :
245 : : /* Success */
246 : 21 : *out_filter_list_fd = filter_list_fd;
247 : 21 : return 0;
248 : : }
249 : :
250 : : static const char *
251 : 6 : sockaddr_to_inet_addr (const struct sockaddr *sa,
252 : : size_t sa_len)
253 : : {
254 [ + + ]: 6 : if (sa->sa_family == AF_INET)
255 : 3 : return (const char *) &((const struct sockaddr_in *) sa)->sin_addr;
256 [ + - ]: 3 : else if (sa->sa_family == AF_INET6)
257 : 3 : return (const char *) &((const struct sockaddr_in6 *) sa)->sin6_addr;
258 : : else
259 : 0 : assert (0); /* should not be reached */
260 : : }
261 : :
262 : : /* As per https://elixir.bootlin.com/glibc/glibc-2.41/source/nss/getaddrinfo.c,
263 : : * glibc only calls gethostbyname4_r and gethostbyname3_r conditionally. If we
264 : : * want to support the most possible queries (and versions of glibc), provide
265 : : * gethostbyname2_r. glibc will handle the fallbacks for other API versions.
266 : : *
267 : : * We do need to provide a gethostbyname3_r() function, though, as that’s
268 : : * explicitly called when AI_CANONNAME is set in the request flags. */
269 : : enum nss_status
270 : 64 : _nss_malcontent_gethostbyname3_r (const char *name,
271 : : int af,
272 : : struct hostent *result,
273 : : char *buffer,
274 : : size_t buffer_len,
275 : : int *errnop,
276 : : int *h_errnop,
277 : : int32_t *ttlp,
278 : : char **canonp)
279 : : {
280 : : enum
281 : : {
282 : : UNKNOWN,
283 : : ALLOW,
284 : : REDIRECT,
285 : : BLOCK,
286 : : }
287 : 64 : domain_action = UNKNOWN;
288 : 64 : char redirect_hostname[HOSTNAME_MAX + 1] = { '\0' }; /* only used if domain_action == REDIRECT */
289 : 64 : struct addrinfo *redirect_addrinfo __attribute__((__cleanup__(clear_addrinfo))) = NULL;
290 : :
291 : : /* We need to call gethostbyname() recursively for redirects, but want other
292 : : * NSS modules to handle it. */
293 : : static bool recursing = false;
294 : :
295 [ + + ]: 64 : if (recursing)
296 : : {
297 : 9 : *errnop = EINVAL;
298 : 9 : *h_errnop = NO_ADDRESS;
299 : 9 : return NSS_STATUS_NOTFOUND;
300 : : }
301 : :
302 : : /* Is the app querying for a protocol which we support? */
303 [ + + - + ]: 55 : if (af != AF_INET && af != AF_INET6)
304 : : {
305 : 0 : *errnop = EAFNOSUPPORT;
306 : 0 : *h_errnop = HOST_NOT_FOUND;
307 : 0 : return NSS_STATUS_UNAVAIL;
308 : : }
309 : :
310 : : /* Always block the DNS-over-HTTPS canary domain, otherwise browsers may use
311 : : * DNS-over-HTTPS and bypass NSS.
312 : : *
313 : : * See https://support.mozilla.org/en-US/kb/canary-domain-use-application-dnsnet
314 : : */
315 [ + + ]: 55 : if (strcmp (name, "use-application-dns.net") == 0)
316 : 4 : domain_action = BLOCK;
317 : :
318 [ + + ]: 55 : if (domain_action == UNKNOWN)
319 : : {
320 : : /* Open the filter list file and check that next. */
321 [ + + ]: 51 : int filter_list_fd __attribute__((__cleanup__(clear_fd))) = -1;
322 : 51 : size_t name_len = strlen (name);
323 : 51 : char allow_name[HOSTNAME_MAX + 2] = { '\0' };
324 : 51 : size_t allow_name_len = 0;
325 : : cdbi_t vlen;
326 : : int open_filter_list_errno;
327 : :
328 : : /* Build the database key for the allowlist entry for @name by prefixing
329 : : * with a tilde. */
330 [ + + ]: 51 : if (name_len >= sizeof (allow_name) - 1)
331 : : {
332 : 2 : *errnop = ENOMEM;
333 : 2 : *h_errnop = HOST_NOT_FOUND;
334 : 2 : return NSS_STATUS_UNAVAIL;
335 : : }
336 : :
337 : 49 : allow_name[0] = '~';
338 : 49 : strcpy (allow_name + 1, name);
339 : 49 : allow_name_len = name_len + 1;
340 : :
341 : : /* Open the filter list. */
342 : 49 : open_filter_list_errno = open_filter_list (&filter_list_fd);
343 [ + + ]: 49 : if (open_filter_list_errno != 0)
344 : : {
345 : 6 : *errnop = open_filter_list_errno;
346 : 6 : *h_errnop = HOST_NOT_FOUND;
347 : 6 : return NSS_STATUS_UNAVAIL;
348 : : }
349 : :
350 [ + + + + ]: 64 : if (filter_list_fd >= 0 &&
351 : 21 : cdb_seek (filter_list_fd, name, name_len, &vlen) > 0)
352 : : {
353 : : /* @name was found in the database. If the value is non-empty then
354 : : * that’s a redirect destination. */
355 [ + + + - ]: 11 : if (vlen > 0 && vlen <= sizeof (redirect_hostname))
356 : 6 : {
357 : : struct addrinfo hints;
358 : : int retval;
359 : :
360 : 6 : memset (&hints, 0, sizeof (hints));
361 : 6 : hints.ai_family = af;
362 : 6 : hints.ai_flags = AI_V4MAPPED | AI_ADDRCONFIG;
363 : :
364 : 6 : retval = cdb_bread (filter_list_fd, redirect_hostname, vlen);
365 [ + - ]: 6 : if (retval == 0)
366 : : {
367 : 6 : recursing = true;
368 : 6 : retval = getaddrinfo (redirect_hostname, NULL, &hints, &redirect_addrinfo);
369 : 6 : recursing = false;
370 : : }
371 : :
372 [ + + ]: 6 : if (retval == 0)
373 : 2 : domain_action = REDIRECT;
374 : : else
375 : 4 : domain_action = BLOCK;
376 : : }
377 : : else
378 : : {
379 : 5 : domain_action = BLOCK;
380 : : }
381 : : }
382 : :
383 [ + + + + ]: 64 : if (filter_list_fd >= 0 &&
384 : 21 : cdb_seek (filter_list_fd, "*", strlen ("*"), &vlen) > 0)
385 : : {
386 : : /* Wildcard means that all websites are blocked by default. */
387 : 6 : domain_action = BLOCK;
388 : : }
389 : :
390 [ + + + + ]: 64 : if (filter_list_fd >= 0 &&
391 : 21 : cdb_seek (filter_list_fd, allow_name, allow_name_len, &vlen) > 0)
392 : : {
393 : : /* Allow list overrides block lists. */
394 : 2 : domain_action = ALLOW;
395 : : }
396 : : }
397 : :
398 : : /* Return a result to NSS. */
399 [ + + + ]: 47 : switch (domain_action)
400 : : {
401 : 28 : case UNKNOWN:
402 : : case ALLOW:
403 : : default:
404 : : {
405 : : /* Not found in the filter list, so let another module actually resolve it. */
406 : 28 : *errnop = EINVAL;
407 : 28 : *h_errnop = NO_ADDRESS;
408 : 28 : return NSS_STATUS_NOTFOUND;
409 : : }
410 : 17 : case BLOCK:
411 : : {
412 : 17 : const struct in_addr sinkhole_addr = { .s_addr = 0 };
413 : 17 : const struct in6_addr sinkhole_addr6 = { .s6_addr = { 0, } };
414 : :
415 : : /* Found in the filter list, so return a DNS sinkhole. */
416 : 17 : size_t buffer_offset = 0;
417 [ + + ]: 17 : size_t h_length = (af == AF_INET6) ? sizeof (struct in6_addr) : sizeof (struct in_addr);
418 : :
419 : : /* Check the buffer size first. */
420 [ - + ]: 17 : if (buffer_len < align_as_pointer (strlen (name) + 1) + align_as_pointer (sizeof (void *)) + align_as_pointer (sizeof (void *) * 2) + align_as_pointer (h_length))
421 : : {
422 : 0 : *errnop = ERANGE;
423 : 0 : *h_errnop = NO_RECOVERY;
424 : 0 : return NSS_STATUS_TRYAGAIN;
425 : : }
426 : :
427 : : /* Build the result. Even though we never set any h_aliases, tools like
428 : : * `getent` expect a non-NULL (though potentially empty) array. */
429 : 17 : strcpy (buffer, name);
430 : 17 : result->h_name = buffer;
431 : 17 : buffer_offset = align_as_pointer (strlen (name) + 1);
432 : :
433 : 17 : result->h_aliases = (char **) (buffer + buffer_offset);
434 : 17 : buffer_offset += align_as_pointer (sizeof (void *));
435 : 17 : result->h_aliases[0] = NULL;
436 : :
437 : 17 : result->h_addrtype = af;
438 : 17 : result->h_length = h_length;
439 : :
440 : 17 : result->h_addr_list = (char **) (buffer + buffer_offset);
441 : 17 : buffer_offset += align_as_pointer (sizeof (void *) * 2);
442 [ + + ]: 17 : memcpy (buffer + buffer_offset, (af == AF_INET6) ? (char *) &sinkhole_addr6 : (char *) &sinkhole_addr, result->h_length);
443 : 17 : result->h_addr_list[0] = buffer + buffer_offset;
444 : 17 : buffer_offset += align_as_pointer (result->h_length);
445 : 17 : result->h_addr_list[1] = NULL;
446 : :
447 [ - + ]: 17 : assert (buffer_offset <= buffer_len);
448 : :
449 [ - + ]: 17 : if (ttlp != NULL)
450 : 0 : *ttlp = 0;
451 [ + + ]: 17 : if (canonp != NULL)
452 : 8 : *canonp = result->h_name;
453 : :
454 : 17 : *errnop = 0;
455 : 17 : *h_errnop = 0;
456 : 17 : return NSS_STATUS_SUCCESS;
457 : : }
458 : 2 : case REDIRECT:
459 : : {
460 : : /* Convert redirect_addrinfo to a hostent result */
461 : 2 : size_t buffer_offset = 0;
462 [ + + ]: 2 : size_t h_length = (af == AF_INET6) ? sizeof (struct in6_addr) : sizeof (struct in_addr);
463 : 2 : size_t n_addrinfos = 0, i = 0;
464 : :
465 : : /* Count how many results there are. */
466 [ + + ]: 8 : for (struct addrinfo *ai = redirect_addrinfo; ai != NULL; ai = ai->ai_next)
467 : 6 : n_addrinfos++;
468 : :
469 : : /* Check the buffer size first. */
470 [ - + ]: 2 : if (buffer_len < align_as_pointer (strlen (redirect_hostname) + 1) + align_as_pointer (sizeof (void *)) + align_as_pointer (sizeof (void *) * (n_addrinfos + 1)) + n_addrinfos * align_as_pointer (h_length))
471 : : {
472 : 0 : *errnop = ERANGE;
473 : 0 : *h_errnop = NO_RECOVERY;
474 : 0 : return NSS_STATUS_TRYAGAIN;
475 : : }
476 : :
477 : : /* Build the result. Even though we never set any h_aliases, tools like
478 : : * `getent` expect a non-NULL (though potentially empty) array.
479 : : *
480 : : * Note that we set result->h_name to `redirect_hostname` rather than to
481 : : * `name`. This simulates a CNAME response and allows (for example)
482 : : * browser location bars to update to use the redirected hostname. */
483 : 2 : strcpy (buffer, redirect_hostname);
484 : 2 : result->h_name = buffer;
485 : 2 : buffer_offset = align_as_pointer (strlen (redirect_hostname) + 1);
486 : :
487 : 2 : result->h_aliases = (char **) (buffer + buffer_offset);
488 : 2 : buffer_offset += align_as_pointer (sizeof (void *));
489 : 2 : result->h_aliases[0] = NULL;
490 : :
491 : 2 : result->h_addrtype = af;
492 : 2 : result->h_length = h_length;
493 : :
494 : 2 : result->h_addr_list = (char **) (buffer + buffer_offset);
495 : 2 : buffer_offset += align_as_pointer (sizeof (void *) * (n_addrinfos + 1));
496 : :
497 [ + + ]: 8 : for (struct addrinfo *ai = redirect_addrinfo; ai != NULL; ai = ai->ai_next)
498 : : {
499 : : /* Should be guaranteed by the `hints` in the query */
500 [ - + ]: 6 : assert (ai->ai_family == af);
501 [ - + ]: 6 : assert (ai->ai_addr->sa_family == af);
502 : :
503 : 6 : memcpy (buffer + buffer_offset, sockaddr_to_inet_addr (ai->ai_addr, ai->ai_addrlen), result->h_length);
504 : 6 : result->h_addr_list[i++] = buffer + buffer_offset;
505 : 6 : buffer_offset += align_as_pointer (result->h_length);
506 : : }
507 : :
508 : 2 : result->h_addr_list[i] = NULL;
509 : :
510 [ - + ]: 2 : assert (buffer_offset <= buffer_len);
511 : :
512 [ - + ]: 2 : if (ttlp != NULL)
513 : 0 : *ttlp = 0;
514 [ + - ]: 2 : if (canonp != NULL)
515 : 2 : *canonp = result->h_name;
516 : :
517 : 2 : *errnop = 0;
518 : 2 : *h_errnop = 0;
519 : 2 : return NSS_STATUS_SUCCESS;
520 : : }
521 : : }
522 : : }
523 : :
524 : : enum nss_status
525 : 54 : _nss_malcontent_gethostbyname2_r (const char *name,
526 : : int af,
527 : : struct hostent *result,
528 : : char *buffer,
529 : : size_t buffer_len,
530 : : int *errnop,
531 : : int *h_errnop)
532 : : {
533 : 54 : return _nss_malcontent_gethostbyname3_r (name, af, result, buffer, buffer_len,
534 : : errnop, h_errnop, NULL, NULL);
535 : : }
|