commit - 7ae9fd34087e74f14a7cb1d230cf495ab6819245
commit + e233607aa644e0431ee41a6b3f1ecff807ce0072
blob - 6bfe7612d0ca06dc171951787945560e50a14bbd
blob + cd0e6e1e852cc3acca7ffa676c534dfcc90ee5f0
--- regress/sandbox/harness.c
+++ regress/sandbox/harness.c
#include "priv.h"
#include "proto.h"
+#define TEST_BAD_HELLO (-1)
+#define TEST_BAD_HELLO_ID (-2)
+#define TEST_BAD_PING (-3)
+#define TEST_BAD_PING_ID (-4)
+
/* Blocking imsg read of one message of the expected type. */
static int
expect_msg(struct imsgbuf *ibuf, uint32_t want, struct imsg *out)
}
static void
-send_msg(struct imsgbuf *ibuf, uint32_t type, const void *data,
- size_t len)
+send_msg_id(struct imsgbuf *ibuf, uint32_t type, uint32_t id,
+ const void *data, size_t len)
{
- if (imsg_compose(ibuf, type, 0, -1, -1, data, len) == -1)
+ if (imsg_compose(ibuf, type, id, -1, -1, data, len) == -1)
errx(1, "imsg_compose");
if (imsgbuf_flush(ibuf) == -1)
errx(1, "imsgbuf_flush");
}
+static void
+send_msg(struct imsgbuf *ibuf, uint32_t type, const void *data,
+ size_t len)
+{
+ send_msg_id(ibuf, type, 0, data, len);
+}
+
static enum fugu_role
role_of(const char *s)
{
return (SELFTEST_WRITE_OOT);
if (strcmp(s, "write_intree") == 0)
return (SELFTEST_WRITE_INTREE);
+ if (strcmp(s, "bad_hello") == 0)
+ return (TEST_BAD_HELLO);
+ if (strcmp(s, "bad_hello_id") == 0)
+ return (TEST_BAD_HELLO_ID);
+ if (strcmp(s, "bad_ping") == 0)
+ return (TEST_BAD_PING);
+ if (strcmp(s, "bad_ping_id") == 0)
+ return (TEST_BAD_PING_ID);
if (strcmp(s, "none") == 0)
return (0);
errx(1, "unknown action %s", s);
pid_t wpid;
int action, readonly = 0, nowrite = 0, status, alive;
const char *expect, *rolename;
+ char byte = 'x';
log_init(LOG_TO_STDERR, 0, 0);
if (argc != 5)
errx(2, "spawn %s", rolename);
if (imsgbuf_init(&ibuf, kid.fd) == -1)
errx(2, "imsgbuf_init");
+ if (action == TEST_BAD_HELLO || action == TEST_BAD_HELLO_ID) {
+ if (action == TEST_BAD_HELLO)
+ send_msg(&ibuf, FUGU_IMSG_HELLO, &byte, sizeof(byte));
+ else
+ send_msg_id(&ibuf, FUGU_IMSG_HELLO, 7, NULL, 0);
+ alive = (expect_msg(&ibuf, FUGU_IMSG_READY, &imsg) == 0);
+ if (alive) {
+ imsg_free(&imsg);
+ send_msg(&ibuf, FUGU_IMSG_SHUTDOWN, NULL, 0);
+ }
+ if (waitpid(kid.pid, &status, 0) == -1)
+ errx(2, "waitpid bad HELLO");
+ return (!alive && WIFEXITED(status) &&
+ WEXITSTATUS(status) == 1 ? 0 : 1);
+ }
/* drive the worker to its steady pledge/unveil */
memset(&hello, 0, sizeof(hello));
+ hello.cols = 80;
+ hello.rows = 24;
if (role == ROLE_TTY) {
send_msg(&ibuf, FUGU_IMSG_HELLO, &hello, sizeof(hello));
} else if (role == ROLE_API) {
}
if (expect_msg(&ibuf, FUGU_IMSG_READY, &imsg) == -1)
errx(2, "%s never became ready", rolename);
+ if (imsg_get_len(&imsg) != 0 || imsg_get_id(&imsg) != 0)
+ errx(2, "%s sent malformed READY", rolename);
imsg_free(&imsg);
+ if (action == TEST_BAD_PING || action == TEST_BAD_PING_ID) {
+ if (action == TEST_BAD_PING)
+ send_msg(&ibuf, FUGU_IMSG_PING, &byte, sizeof(byte));
+ else
+ send_msg_id(&ibuf, FUGU_IMSG_PING, 7, NULL, 0);
+ alive = (expect_msg(&ibuf, FUGU_IMSG_PONG, &imsg) == 0);
+ if (alive) {
+ imsg_free(&imsg);
+ send_msg(&ibuf, FUGU_IMSG_SHUTDOWN, NULL, 0);
+ }
+ if (waitpid(kid.pid, &status, 0) == -1)
+ errx(2, "waitpid bad PING");
+ return (!alive && WIFEXITED(status) &&
+ WEXITSTATUS(status) == 1 ? 0 : 1);
+ }
+
if (action == 0) {
/* handshake-only: verify liveness then shut down cleanly */
send_msg(&ibuf, FUGU_IMSG_PING, NULL, 0);
if (expect_msg(&ibuf, FUGU_IMSG_PONG, &imsg) == -1)
errx(2, "%s did not pong", rolename);
+ if (imsg_get_len(&imsg) != 0 || imsg_get_id(&imsg) != 0)
+ errx(2, "%s sent malformed PONG", rolename);
imsg_free(&imsg);
send_msg(&ibuf, FUGU_IMSG_SHUTDOWN, NULL, 0);
waitpid(kid.pid, &status, 0);
blob - c52938907c4fabb5d87b143f56237b40c20c673e
blob + f85a09d29f9352c3f7a43139b226c91d8b268187
--- regress/sandbox/run.sh
+++ regress/sandbox/run.sh
run_case fugu-tool none ok -
run_case fugu-editor none ok -
+# I10/I13: startup and liveness frames are exact typed exchanges. The TTY
+# and editor historically accepted malformed HELLO payloads; the common
+# worker runtime must also reject nonzero control correlation IDs and malformed
+# PING frames rather than replying as though they were valid.
+run_case fugu-tty bad_hello reject -
+run_case fugu-editor bad_hello reject -
+run_case fugu-editor bad_hello_id reject -
+run_case fugu-editor bad_ping reject -
+run_case fugu-editor bad_ping_id reject -
+
# I1: the credential custodians cannot exec or open files.
run_case fugu-api exec dead execve
run_case fugu-api open_etc dead open
blob - 8e670dea289657fa382a40a2f9c6d272da945fd6
blob + 468df9fb27bfb87b4a1b0a5814de22a4fbe39a96
--- regress/turn/Makefile
+++ regress/turn/Makefile
DPADD= ${LIBTLS}
REGRESS_TARGETS= run-turn run-agent
-CLEANFILES+= reqcheck
+CLEANFILES+= reqcheck handshake
REQCHECK_SRCS= ${.CURDIR}/reqcheck.c \
${.CURDIR}/../../src/common/json.c \
reqcheck: ${REQCHECK_SRCS}
${CC} ${CFLAGS} -o ${.OBJDIR}/reqcheck ${REQCHECK_SRCS}
+handshake: ${.CURDIR}/handshake.c
+ ${CC} ${CFLAGS} -I${.CURDIR}/../../src/fugu \
+ -o ${.OBJDIR}/handshake ${.CURDIR}/handshake.c -lutil
+
# a pty driver to exercise the curses front end end to end (2.1)
ptydrive: ${.CURDIR}/ptydrive.c
${CC} ${CFLAGS} -o ${.OBJDIR}/ptydrive ${.CURDIR}/ptydrive.c -lutil
-run-turn: stub ptydrive reqcheck
+run-turn: stub ptydrive reqcheck handshake
sh ${.CURDIR}/run.sh ${.OBJDIR} ${.CURDIR}/../..
run-agent: stub
blob - /dev/null
blob + 93c0d1aef1b93dccd380c6ecd7d28e647bc1471f (mode 644)
--- /dev/null
+++ regress/turn/handshake.c
+/*
+ * Copyright (c) 2026 Isaac <isaac@itm.works>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+/*
+ * Hostile fugu-editor replacement for the coordinator handshake regressions.
+ * It receives HELLO, emits the selected malformed startup sequence, then
+ * remains cooperative for SHUTDOWN so a vulnerable coordinator can finish a
+ * normal print Turn instead of making the test pass through an unrelated EOF.
+ */
+
+#include <sys/types.h>
+
+#include <err.h>
+#include <imsg.h>
+#include <poll.h>
+#include <stdlib.h>
+#include <string.h>
+#include <unistd.h>
+
+#include "proto.h"
+
+static int
+next_imsg(struct imsgbuf *ibuf, struct imsg *imsg)
+{
+ struct pollfd pfd;
+ ssize_t n;
+
+ for (;;) {
+ if ((n = imsg_get(ibuf, imsg)) == -1)
+ return (-1);
+ if (n != 0)
+ return (1);
+ pfd.fd = ibuf->fd;
+ pfd.events = POLLIN;
+ pfd.revents = 0;
+ if (poll(&pfd, 1, 5000) <= 0)
+ return (-1);
+ if ((n = imsgbuf_read(ibuf)) <= 0)
+ return (0);
+ }
+}
+
+static void
+send_msg_id(struct imsgbuf *ibuf, uint32_t type, uint32_t id,
+ const void *data, size_t len)
+{
+ if (imsg_compose(ibuf, type, id, -1, -1, data, len) == -1)
+ err(2, "imsg_compose");
+}
+
+static void
+send_msg(struct imsgbuf *ibuf, uint32_t type, const void *data, size_t len)
+{
+ send_msg_id(ibuf, type, 0, data, len);
+}
+
+int
+main(int argc, char *argv[])
+{
+ struct imsgbuf ibuf;
+ struct imsg imsg;
+ const char *attack;
+ char byte = 'x';
+ int n;
+
+ (void)argc;
+ (void)argv;
+ if ((attack = getenv("FUGU_HANDSHAKE_ATTACK")) == NULL)
+ err(2, "FUGU_HANDSHAKE_ATTACK");
+ if (imsgbuf_init(&ibuf, FUGU_CTL_FD) == -1)
+ err(2, "imsgbuf_init");
+ if ((n = next_imsg(&ibuf, &imsg)) != 1 ||
+ imsg_get_type(&imsg) != FUGU_IMSG_HELLO)
+ errx(2, "expected HELLO");
+ imsg_free(&imsg);
+
+ if (strcmp(attack, "duplicate") == 0) {
+ send_msg(&ibuf, FUGU_IMSG_READY, NULL, 0);
+ send_msg(&ibuf, FUGU_IMSG_READY, NULL, 0);
+ } else if (strcmp(attack, "payload") == 0)
+ send_msg(&ibuf, FUGU_IMSG_READY, &byte, sizeof(byte));
+ else if (strcmp(attack, "id") == 0)
+ send_msg_id(&ibuf, FUGU_IMSG_READY, 7, NULL, 0);
+ else if (strcmp(attack, "pong") == 0) {
+ send_msg(&ibuf, FUGU_IMSG_PONG, NULL, 0);
+ send_msg(&ibuf, FUGU_IMSG_READY, NULL, 0);
+ } else if (strcmp(attack, "pong_duplicate") == 0 ||
+ strcmp(attack, "pong_payload") == 0 ||
+ strcmp(attack, "pong_id") == 0) {
+ /* A fixed coordinator sends PING only after all unique READYs.
+ * An old coordinator instead reaches the Turn and eventually
+ * sends SHUTDOWN; returning cleanly keeps that RED outcome tied
+ * to the missing typed PONG validation. */
+ send_msg(&ibuf, FUGU_IMSG_READY, NULL, 0);
+ if (imsgbuf_flush(&ibuf) == -1)
+ err(2, "imsgbuf_flush READY");
+ if ((n = next_imsg(&ibuf, &imsg)) != 1)
+ return (n == 0 ? 0 : 2);
+ if (imsg_get_type(&imsg) == FUGU_IMSG_SHUTDOWN) {
+ imsg_free(&imsg);
+ return (0);
+ }
+ if (imsg_get_type(&imsg) != FUGU_IMSG_PING)
+ errx(2, "expected PING");
+ imsg_free(&imsg);
+ if (strcmp(attack, "pong_duplicate") == 0) {
+ send_msg(&ibuf, FUGU_IMSG_PONG, NULL, 0);
+ send_msg(&ibuf, FUGU_IMSG_PONG, NULL, 0);
+ } else if (strcmp(attack, "pong_payload") == 0)
+ send_msg(&ibuf, FUGU_IMSG_PONG, &byte, sizeof(byte));
+ else
+ send_msg_id(&ibuf, FUGU_IMSG_PONG, 7, NULL, 0);
+ } else
+ errx(2, "unknown handshake attack: %s", attack);
+ if (imsgbuf_flush(&ibuf) == -1)
+ err(2, "imsgbuf_flush");
+
+ while ((n = next_imsg(&ibuf, &imsg)) == 1) {
+ if (imsg_get_type(&imsg) == FUGU_IMSG_SHUTDOWN) {
+ imsg_free(&imsg);
+ return (0);
+ }
+ imsg_free(&imsg);
+ }
+ return (n == 0 ? 0 : 2);
+}
blob - ad425bd327f6fed1b27f8091d0960f111bd718cf
blob + fad71189bc0121f1a51aae272ac685ff851074a8
--- regress/turn/run.sh
+++ regress/turn/run.sh
sessions=$HOME/.fugu/sessions
+# --- 0: startup replies are per-Role, empty, and exactly once (I10/I13) ---
+# Replace only the otherwise-unused print-mode editor. It remains alive for
+# SHUTDOWN, so an unhardened coordinator completes the OK Turn; only rejecting
+# the hostile handshake can make these checks pass.
+start_stub "OK OK OK OK OK OK OK"
+write_conf "$stubport"
+cp "$lx/fugu-editor" "$dir/fugu-editor.real"
+cp "$obj/handshake" "$lx/fugu-editor"
+for attack in duplicate payload id pong pong_duplicate pong_payload pong_id; do
+ rm -rf "$HOME/.fugu"
+ env FUGU_HANDSHAKE_ATTACK="$attack" FUGU_CONF="$dir/fugu.conf" \
+ FUGU_LIBEXEC="$dir/libexec/fugu" FUGU_CA_FILE="$dir/cert.pem" \
+ FUGU_RETRY_BASE_MS=50 "$fugu" -p "handshake probe" \
+ </dev/null >"$dir/out" 2>"$dir/errout"
+ rc=$?
+ ok "handshake $attack: coordinator fails closed" \
+ $([ "$rc" -ne 0 ]; echo $?)
+ case "$attack" in
+ duplicate|payload|id)
+ grep -q "bad READY from fugu-editor" "$dir/errout"
+ ;;
+ pong|pong_duplicate|pong_payload|pong_id)
+ grep -q "bad PONG from fugu-editor" "$dir/errout"
+ ;;
+ esac
+ ok "handshake $attack: typed violation is reported" $?
+done
+cp "$dir/fugu-editor.real" "$lx/fugu-editor"
+stop_stub
+
# --- 1: a clean OK turn ---------------------------------------------
start_stub "OK"
write_conf "$stubport"
blob - 3bec9f667fc5ff3e063fd8573fc16846cb499c22
blob + 60341b8c5acd1592c87a55c9e428785eba8683f2
--- src/common/worker.c
+++ src/common/worker.c
case FUGU_IMSG_HELLO:
if (w->ready)
fatalx("%s: duplicate HELLO", w->title);
+ if (imsg_get_id(&imsg) != 0)
+ fatalx("%s: bad HELLO correlation", w->title);
if (w->lockdown(w, &imsg) == -1)
fatalx("%s: lockdown failed", w->title);
w->ready = 1;
imsgev_send(&w->iev, FUGU_IMSG_READY, 0, NULL, 0);
break;
case FUGU_IMSG_PING:
+ if (imsg_get_len(&imsg) != 0 ||
+ imsg_get_id(&imsg) != 0)
+ fatalx("%s: bad PING", w->title);
imsgev_send(&w->iev, FUGU_IMSG_PONG, 0, NULL, 0);
break;
case FUGU_IMSG_LOG_SYSLOG:
blob - 4472b2143282d3cdebf4abed38f4f8e1e66f820a
blob + 8abfa6306d29944cac454e3872a47d78c06a6dbe
--- src/fugu/coord.c
+++ src/fugu/coord.c
if (!allowed_from(role, type))
fatalx("coordinator: imsg %u from %s", type,
coord->kids[role].title);
+ if (type != FUGU_IMSG_READY && type != FUGU_IMSG_PONG &&
+ !coord->kids[role].ready)
+ fatalx("coordinator: imsg %u before READY from %s", type,
+ coord->kids[role].title);
switch (type) {
- case FUGU_IMSG_READY:
+ case FUGU_IMSG_READY: {
+ struct imsgev_payload payload;
+ int r;
+
+ if (imsgev_get_payload(&imsg, &payload) == -1 ||
+ payload.len != 0 || imsg_get_id(&imsg) != 0 ||
+ coord->kids[role].ready)
+ fatalx("bad READY from %s",
+ coord->kids[role].title);
+ coord->kids[role].ready = 1;
/* READY from fugu-tty means ui_start has completed and
* curses now owns the terminal. This is the exact boundary
* where the Coordinator and every Role leave startup stderr;
* -d suppresses the switch for the whole process mesh. */
if (role == ROLE_TTY && coord->ui && !coord->debug) {
- int r;
-
log_init(LOG_TO_SYSLOG, coord->verbose,
LOG_DAEMON);
for (r = ROLE_TTY; r < ROLE_MAX; r++)
imsgev_send(&coord->iev[r],
FUGU_IMSG_LOG_SYSLOG, 0, NULL, 0);
}
- if (++coord->nready == coord->nkids)
- event_loopexit(NULL);
+ if (++coord->nready == coord->nkids) {
+ /* Every Role proved steady confinement. One typed
+ * liveness round completes the startup exchange. */
+ for (r = ROLE_TTY; r < ROLE_MAX; r++)
+ imsgev_send(&coord->iev[r], FUGU_IMSG_PING,
+ 0, NULL, 0);
+ }
break;
- case FUGU_IMSG_PONG:
+ }
+ case FUGU_IMSG_PONG: {
+ struct imsgev_payload payload;
+
+ if (imsgev_get_payload(&imsg, &payload) == -1 ||
+ payload.len != 0 || imsg_get_id(&imsg) != 0 ||
+ !coord->kids[role].ready ||
+ coord->nready != coord->nkids ||
+ coord->kids[role].pong)
+ fatalx("bad PONG from %s", coord->kids[role].title);
+ coord->kids[role].pong = 1;
if (++coord->npong == coord->nkids)
event_loopexit(NULL);
break;
+ }
case FUGU_IMSG_A_TEXT:
case FUGU_IMSG_A_USAGE:
case FUGU_IMSG_A_TOOL_BEGIN:
send_hello(&c, role);
conf_wipe_secrets(cf);
- event_dispatch(); /* until every worker is READY */
+ event_dispatch(); /* until every worker is READY and PONGed */
/* the in-tree context file is read via fugu-tool (no rpath here) */
context_post_bringup(&c, resume_id != NULL);
blob - 2b0f2f6a9fcc6155df7323c5b8a4e22eb67e1a5d
blob + ee24d4ffa816365de7f2aec47743ffc9455c8891
--- src/fugu/priv.c
+++ src/fugu/priv.c
c->role = role;
c->title = title;
c->ready = 0;
+ c->pong = 0;
c->fd = fds[0];
return (0);
}
blob - 7273ba385a212b7b8456fc0b3d81199d3570d442
blob + 5dd12213c55119b6f7308b081d008c2b4e55d85a
--- src/fugu/priv.h
+++ src/fugu/priv.h
const char *title;
int fd; /* parent end of the imsg channel */
int ready;
+ int pong; /* answered the startup liveness round */
};
/*
blob - 069e63f2ee77a067e43d2b3a5a003715b89581dd
blob + 64cb697e7ff1393b7924d2f6dbc0ff4360892ec7
--- src/fugu-editor/main.c
+++ src/fugu-editor/main.c
static int
edit_lockdown(struct worker *w, struct imsg *imsg)
{
+ struct imsgev_payload payload;
+
(void)w;
- (void)imsg; /* HELLO carries nothing for this room */
+ if (imsgev_get_payload(imsg, &payload) == -1)
+ fatal("get editor HELLO payload");
+ if (payload.len != 0)
+ fatalx("fugu-editor: malformed HELLO");
/*
* Steady state: the temp file (rpath wpath cpath), the shared
blob - ab37d11aefcdb8201041780c97c9a7fe699c1f0a
blob + 28ed2b97652a5bf29e1654f4695b10fae98b8498
--- src/fugu-tty/main.c
+++ src/fugu-tty/main.c
tty_lockdown(struct worker *w, struct imsg *imsg)
{
struct hello_tty h;
+ struct imsgev_payload payload;
- memset(&h, 0, sizeof(h));
- if (imsg_get_len(imsg) == sizeof(h))
- (void)imsg_get_data(imsg, &h, sizeof(h));
+ if (imsgev_get_payload(imsg, &payload) == -1)
+ fatal("get TTY HELLO payload");
+ if (payload.data == NULL || payload.len != sizeof(h))
+ fatalx("fugu-tty: malformed HELLO");
+ memcpy(&h, payload.data, sizeof(h));
+ if ((h.curses != 0 && h.curses != 1) || h.cols <= 0 || h.rows <= 0)
+ fatalx("fugu-tty: malformed HELLO policy");
/*
* In print/line mode the front end is idle and must not touch the