Skip to content

send.sh: validate team names before resolving roster config paths #414

Description

@yui-stingray

Summary

In v1.1.8 and current main (f583856), send.sh resolves the roster config as:

TEAM_CONFIG="$SCRIPT_DIR/../teams/$TEAM/config.json"

and passes that path to SQLite readfile() without first calling agmsg_validate_team_name.

The centralized validator added by #147 for #140 covers the existing registry entry points that turn team names into paths. send.sh did not use a team config path at that time. The roster validation added in #409 (merged as e26e80e) introduced this new path resolution/read and omitted the corresponding validation.

Impact

A local caller who controls <team> and does not use --force can make the roster check resolve a config.json outside the intended teams/ directory.

This is a path-containment issue, but it does not directly disclose arbitrary file contents:

  • the constructed pathname always appends /config.json; filesystem symlinks can still redirect the file that is opened;
  • the initial [ -f "$TEAM_CONFIG" ] check can reveal whether the constructed path resolves to a regular file;
  • for config-shaped JSON, error output can expose keys under agents;
  • if the supplied from and to names match those keys, the message insert can proceed under the caller-supplied team string.

This path does not write to the external config file. --force currently skips the roster read, but it should bypass roster membership only, not team-name safety.

Suggested fix

Source the existing validator and validate TEAM unconditionally before any config-path resolution or database initialization:

# shellcheck disable=SC1091
source "$SCRIPT_DIR/lib/validate.sh"
agmsg_validate_team_name "$TEAM" || exit 1

This preserves --force for safe, not-yet-registered team names while preventing it from bypassing the team-name safety contract.

Regression tests

Please add coverage in tests/test_messaging.bats for:

  • rejecting the same path-dangerous team-name classes covered by agmsg_validate_team_name;
  • rejecting an invalid team name even when --force is supplied;
  • verifying that an external sentinel config.json is not consulted and no message is inserted;
  • preserving --force for a safe, nonexistent team name;
  • preserving normal ASCII and UTF-8 team-name sends.

References

  • Team names can escape teams/ via path traversal #140
  • fix: validate team names to prevent teams/ path traversal (#140) #147
  • fix(send): reject unregistered from/to agents #409
  • Merge commit: e26e80e
  • Current affected code:

    agmsg/scripts/send.sh

    Lines 15 to 60 in f583856

    SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
    source "$SCRIPT_DIR/lib/storage.sh"
    DB="$(agmsg_db_path)"
    [ -f "$DB" ] || bash "$SCRIPT_DIR/internal/init-db.sh" >/dev/null
    # #355: reject a from/to that isn't registered in <team> — an unnoticed typo
    # (e.g. a stray send to "dummy") used to insert successfully with exit 0,
    # landing an undeliverable message and polluting history. Validation lives
    # here (the front door), not in storage.sh, so other entry points (api.sh)
    # can keep their own policy. --force bypasses this for intentional
    # pre-registration sends (e.g. notifying a role before its own join.sh runs).
    if [ "$FORCE" -ne 1 ]; then
    TEAM_CONFIG="$SCRIPT_DIR/../teams/$TEAM/config.json"
    _agmsg_roster_check() {
    local role="$1" name="$2"
    if [ ! -f "$TEAM_CONFIG" ]; then
    echo "Error: team '$TEAM' has no registered agents — cannot send as $role '$name' (use --force to bypass)." >&2
    return 1
    fi
    local cfg_sql name_sql found roster
    cfg_sql=$(agmsg_sql_readfile_path "$TEAM_CONFIG")
    name_sql=$(printf '%s' "$name" | sed "s/'/''/g")
    found=$(agmsg_sqlite_mem "
    WITH raw(json) AS (SELECT CAST(readfile('$cfg_sql') AS TEXT)),
    cfg(json) AS (SELECT CASE WHEN json_valid(json) THEN json END FROM raw)
    SELECT value
    FROM cfg, json_each(json_extract(cfg.json, '\$.agents'))
    WHERE key = '$name_sql';
    ")
    if [ -z "$found" ]; then
    roster=$(agmsg_sqlite_mem "
    WITH raw(json) AS (SELECT CAST(readfile('$cfg_sql') AS TEXT)),
    cfg(json) AS (SELECT CASE WHEN json_valid(json) THEN json END FROM raw)
    SELECT group_concat(key, ', ')
    FROM cfg, json_each(json_extract(cfg.json, '\$.agents'));
    ")
    echo "Error: $role agent '$name' is not registered in team '$TEAM' (registered: ${roster:-none}). Use --force to bypass." >&2
    return 1
    fi
    return 0
    }
    _agmsg_roster_check "from" "$FROM" || exit 1
    _agmsg_roster_check "to" "$TO" || exit 1
  • Existing validator:
    # Return 0 if <name> is safe to use as a single path segment, else print a
    # specific error to stderr and return 1.
    agmsg_validate_team_name() {
    local name="$1"
    if [ -z "$name" ]; then
    echo "agmsg: invalid team name: must not be empty" >&2
    return 1
    fi
    case "$name" in
    .|..)
    echo "agmsg: invalid team name '$name': '.' and '..' are not allowed" >&2
    return 1 ;;
    */*|*\\*)
    echo "agmsg: invalid team name '$name': must not contain '/' or '\\' (path traversal)" >&2
    return 1 ;;
    -*)
    # Leading '-' would be parsed as an option by downstream tools.
    echo "agmsg: invalid team name '$name': must not start with '-'" >&2
    return 1 ;;
    esac
    # Reject control characters (NUL can't reach a shell var, but newline / tab /
    # other C0 + DEL can corrupt paths, configs, and row-counting output).
    case "$name" in
    *[[:cntrl:]]*)
    echo "agmsg: invalid team name: must not contain control characters" >&2
    return 1 ;;
    esac
    return 0
    }

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions