diff --git a/man/journal-remote.conf.xml b/man/journal-remote.conf.xml index a9d92c33720..709c9ba198d 100644 --- a/man/journal-remote.conf.xml +++ b/man/journal-remote.conf.xml @@ -100,6 +100,18 @@ + + AddClientCertificateSubject= + + Takes a boolean. If enabled, records the verified HTTPS client's full certificate + subject in _REMOTE_CERTIFICATE_SUBJECT=. All incoming copies of this field are + removed, including from non-HTTPS inputs and imported files. Without a verified client certificate, + the field is omitted. Certificate verification and connection acceptance are unchanged. + Defaults to no, leaving incoming fields unchanged. + + + + MaxUse= KeepFree= diff --git a/man/systemd.journal-fields.xml b/man/systemd.journal-fields.xml index 9f7f2c5cfa0..6c72d22126f 100644 --- a/man/systemd.journal-fields.xml +++ b/man/systemd.journal-fields.xml @@ -456,6 +456,16 @@ + + _REMOTE_CERTIFICATE_SUBJECT= + The full distinguished name of the verified HTTPS uploader's certificate. + Added by systemd-journal-remote with AddClientCertificateSubject=yes; + see journal-remote.conf5. + Trust this field only from a receiver with this setting enabled. It identifies the immediate uploader, + not the original producer of forwarded entries, and does not authenticate other entry fields. + + + diff --git a/src/journal-remote/fuzz-journal-remote.c b/src/journal-remote/fuzz-journal-remote.c index 9119142e82e..25dcdf8fa75 100644 --- a/src/journal-remote/fuzz-journal-remote.c +++ b/src/journal-remote/fuzz-journal-remote.c @@ -1,6 +1,7 @@ /* SPDX-License-Identifier: LGPL-2.1-or-later */ #include +#include #include #include "sd-journal.h" @@ -23,7 +24,9 @@ int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) { _cleanup_(rm_rf_physical_and_freep) char *tmp = NULL; _cleanup_(unlink_and_freep) char *name = NULL; _cleanup_(sd_journal_closep) sd_journal *j = NULL; - _cleanup_(journal_remote_server_destroy) RemoteServer s = {}; + _cleanup_(journal_remote_server_destroy) RemoteServer s = { + .add_client_certificate_subject = size % 3 != 0, + }; int fdin, r; if (outside_size_range(size, 3, 65536)) @@ -53,6 +56,9 @@ int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) { ASSERT_OK_POSITIVE(journal_remote_add_source(&s, fdin, (char*) "fuzz-data", false)); TAKE_FD(fdin_close); + if (size % 3 == 2) + assert_se(s.sources[fdin]->client_certificate_subject = strdup(REMOTE_CERTIFICATE_SUBJECT_FIELD "CN=fuzz")); + while (s.active) ASSERT_OK(journal_remote_handle_raw_source(NULL, fdin, 0, &s)); diff --git a/src/journal-remote/journal-remote-main.c b/src/journal-remote/journal-remote-main.c index e3d03dd496d..3f12db6a491 100644 --- a/src/journal-remote/journal-remote-main.c +++ b/src/journal-remote/journal-remote-main.c @@ -48,6 +48,7 @@ static char *arg_listen_https = NULL; static char **arg_files = NULL; static bool arg_compress = true; static bool arg_seal = false; +static bool arg_add_client_certificate_subject = false; static int http_socket = -1, https_socket = -1; static char **arg_gnutls_log = NULL; @@ -250,6 +251,12 @@ static int request_meta(void **connection_cls, int fd, char *_hostname) { if (!source) return log_oom(); + if (journal_remote_server_global->add_client_certificate_subject && journal_remote_server_global->check_trust) { + source->client_certificate_subject = strjoin(REMOTE_CERTIFICATE_SUBJECT_FIELD, source->importer.name); + if (!source->client_certificate_subject) + return log_oom(); + } + log_debug("Added RemoteSource as connection metadata %p", source); r = build_accept_encoding(&source->encoding); @@ -849,6 +856,7 @@ static int parse_config(void) { { "Remote", "MaxFiles", config_parse_uint64, 0, &arg_n_max_files }, { "Remote", "KeepFree", config_parse_iec_uint64, 0, &arg_keep_free }, { "Remote", "Compression", config_parse_compression, 0, &arg_compression }, + { "Remote", "AddClientCertificateSubject", config_parse_bool, 0, &arg_add_client_certificate_subject }, {} }; @@ -1184,6 +1192,11 @@ static int run(int argc, char **argv) { s.check_trust = !arg_trust_all; } + s.add_client_certificate_subject = arg_add_client_certificate_subject; + if (s.add_client_certificate_subject && !s.check_trust) + log_notice("Client certificate verification is unavailable or disabled; " + "removing incoming certificate subjects without adding a verified subject."); + journal_reset_metrics(&s.metrics); s.metrics.max_use = arg_max_use; s.metrics.max_size = arg_max_size; diff --git a/src/journal-remote/journal-remote-parse.c b/src/journal-remote/journal-remote-parse.c index 00f14c765aa..32a3e775648 100644 --- a/src/journal-remote/journal-remote-parse.c +++ b/src/journal-remote/journal-remote-parse.c @@ -1,10 +1,14 @@ /* SPDX-License-Identifier: LGPL-2.1-or-later */ +#include + #include "sd-event.h" #include "alloc-util.h" #include "journal-remote-parse.h" +#include "journal-remote.h" #include "log.h" +#include "string-util.h" RemoteSource* source_free(RemoteSource *source) { if (!source) @@ -19,6 +23,7 @@ RemoteSource* source_free(RemoteSource *source) { sd_event_source_unref(source->buffer_event); free(source->encoding); + free(source->client_certificate_subject); return mfree(source); } @@ -71,6 +76,35 @@ int process_source(RemoteSource *source, JournalFileFlags file_flags) { assert(source->importer.iovw.iovec); + if (source->writer->server->add_client_certificate_subject) { + struct iovec_wrapper *iovw = &source->importer.iovw; + size_t n = 0; + + /* Drop all supplied copies, including binary fields and metadata from earlier hops. */ + FOREACH_ARRAY(i, iovw->iovec, iovw->count) { + if (memory_startswith(i->iov_base, i->iov_len, REMOTE_CERTIFICATE_SUBJECT_FIELD)) + continue; + iovw->iovec[n++] = *i; + } + iovw->count = n; + + if (source->client_certificate_subject) { + r = iovw_put(iovw, source->client_certificate_subject, strlen(source->client_certificate_subject)); + if (r == -E2BIG) { + log_warning("Entry has no room for the client certificate subject, skipping."); + r = 1; + goto freeing; + } + if (r < 0) + goto freeing; + } + + if (iovw_isempty(iovw)) { + log_warning("Entry with no payload after removing certificate subjects, skipping."); + goto freeing; + } + } + r = writer_write(source->writer, &source->importer.iovw, &source->importer.ts, diff --git a/src/journal-remote/journal-remote-parse.h b/src/journal-remote/journal-remote-parse.h index 54b984431eb..6800b46a042 100644 --- a/src/journal-remote/journal-remote-parse.h +++ b/src/journal-remote/journal-remote-parse.h @@ -15,6 +15,7 @@ typedef struct RemoteSource { sd_event_source *buffer_event; Compression compression; char *encoding; + char *client_certificate_subject; } RemoteSource; RemoteSource* source_new(int fd, bool passive_fd, char *name, Writer *writer); diff --git a/src/journal-remote/journal-remote.conf.in b/src/journal-remote/journal-remote.conf.in index 595b93ca89d..573453f749e 100644 --- a/src/journal-remote/journal-remote.conf.in +++ b/src/journal-remote/journal-remote.conf.in @@ -22,6 +22,7 @@ # ServerKeyFile={{CERTIFICATE_ROOT}}/private/journal-remote.pem # ServerCertificateFile={{CERTIFICATE_ROOT}}/certs/journal-remote.pem # TrustedCertificateFile={{CERTIFICATE_ROOT}}/ca/trusted.pem +# AddClientCertificateSubject=false # MaxUse= # KeepFree= # MaxFileSize= diff --git a/src/journal-remote/journal-remote.h b/src/journal-remote/journal-remote.h index bcf619332c2..3b54934dd0b 100644 --- a/src/journal-remote/journal-remote.h +++ b/src/journal-remote/journal-remote.h @@ -5,6 +5,8 @@ #include "journal-remote-parse.h" #include "journal-remote-write.h" +#define REMOTE_CERTIFICATE_SUBJECT_FIELD "_REMOTE_CERTIFICATE_SUBJECT=" + typedef struct RemoteServer { RemoteSource **sources; size_t active; @@ -22,6 +24,7 @@ typedef struct RemoteServer { JournalWriteSplitMode split_mode; JournalFileFlags file_flags; bool check_trust; + bool add_client_certificate_subject; JournalMetrics metrics; } RemoteServer; extern RemoteServer *journal_remote_server_global; diff --git a/test/units/TEST-04-JOURNAL.journal-remote.sh b/test/units/TEST-04-JOURNAL.journal-remote.sh index 3772ffc3808..15eb711474a 100755 --- a/test/units/TEST-04-JOURNAL.journal-remote.sh +++ b/test/units/TEST-04-JOURNAL.journal-remote.sh @@ -309,6 +309,66 @@ systemctl stop systemd-journal-upload systemctl stop systemd-journal-remote.{socket,service} rm -rf /var/log/journal/remote/* rm /run/systemd/journal-upload.conf.d/99-test.conf + +# Reuse the trusted HTTPS receiver to check attribution and replacement of supplied fields. +max_fields="$(getconf IOV_MAX)" +for setting in '' yes; do + if [[ -n "$setting" ]]; then + echo "AddClientCertificateSubject=$setting" >>/run/systemd/journal-remote.conf.d/99-test.conf + fi + { + printf '%s\n' '__REALTIME_TIMESTAMP=1700000000000000' '__MONOTONIC_TIMESTAMP=1000000' \ + '_BOOT_ID=0123456789abcdef0123456789abcdef' 'MESSAGE=field-limit-test' + for ((i = 2; i < max_fields; i++)); do + printf 'FIELD_%s=value\n' "$i" + done + printf '\n' + printf '%s\n\n' '_REMOTE_CERTIFICATE_SUBJECT=CN=forged-only' + printf '%s\n' '__REALTIME_TIMESTAMP=1700000000000000' '__MONOTONIC_TIMESTAMP=1000000' \ + '_BOOT_ID=0123456789abcdef0123456789abcdef' \ + '_REMOTE_CERTIFICATE_SUBJECT=CN=forged-one' 'MESSAGE=certificate-subject-test' \ + '_REMOTE_CERTIFICATE_SUBJECT=CN=forged-two' '_HOSTNAME=sender-claimed-host' + printf '_REMOTE_CERTIFICATE_SUBJECT\n\x03\0\0\0\0\0\0\0a\0b\n\n' + } >/run/systemd/remote-pki/subject.export + systemctl restart systemd-journal-remote.socket + for _ in 1 2; do + curl --silent --show-error --fail --max-time 15 \ + --cacert /run/systemd/remote-pki/ca.crt \ + --cert /run/systemd/remote-pki/client.crt \ + --key /run/systemd/remote-pki/client.key \ + --header 'Content-Type: application/vnd.fdo.journal' \ + --data-binary @/run/systemd/remote-pki/subject.export https://localhost:19532/upload + done + expected_entries=6 + expected='["CN=forged-one","CN=forged-two",[97,0,98]]' + if [[ "$setting" == yes ]]; then + expected_entries=4 + expected=null + if /usr/lib/systemd/systemd-journal-remote --version | grep -F +GNUTLS >/dev/null; then + expected='"C=CZ,L=Brno,O=Foo,OU=Bar,CN=Test Client"' + fi + fi + journalctl --directory=/var/log/journal/remote -o json --all | + jq -se --argjson expected "$expected" --argjson count "$expected_entries" ' + length == $count and ([.[] | select(.MESSAGE == "certificate-subject-test")] | + length == 2 and all(.[]; + ._REMOTE_CERTIFICATE_SUBJECT == $expected and ._HOSTNAME == "sender-claimed-host"))' + systemctl stop systemd-journal-remote.{socket,service} + rm -rf /var/log/journal/remote/* + + /usr/lib/systemd/systemd-journal-remote --split-mode=none \ + --output=/run/systemd/remote-pki/import.journal /run/systemd/remote-pki/subject.export + expected_entries=3 + if [[ "$setting" == yes ]]; then + expected_entries=2 + expected=null + fi + journalctl --file='/run/systemd/remote-pki/import*.journal' -o json --all | + jq -se --argjson expected "$expected" --argjson count "$expected_entries" 'length == $count and + ([.[] | select(.MESSAGE == "certificate-subject-test")] | length == 1 and + .[0]._REMOTE_CERTIFICATE_SUBJECT == $expected and .[0]._HOSTNAME == "sender-claimed-host")' + rm /run/systemd/remote-pki/import*.journal +done rm /run/systemd/journal-remote.conf.d/99-test.conf DOS_DIR="$(mktemp -d)"