Skip to content

Subject validation accepts empty tokens and invalid wildcard usage #88

Open

Description

@matkomat

Observed on main at 4f16540fff91b4b021427a1c7696ae1b8c8c6025.

Bug

Sofascore\Nats\Connection::validateSubject() only rejects empty strings and whitespace/control characters. It does not reject malformed token structure or context-invalid wildcards.

The same validator is used by publish, request, subscribe, and queue subscribe paths, so publish/request subjects can pass client validation with wildcard tokens, and subscription subjects can pass with malformed wildcard placement.

Repro

<?php
require __DIR__ . '/vendor/autoload.php';

use Sofascore\Nats\Connection;

foreach (['foo..bar', 'foo.*', 'foo.>.bar'] as $subject) {
    Connection::validateSubject($subject);
    echo "accepted {$subject}\n";
}

Actual result:

accepted foo..bar
accepted foo.*
accepted foo.>.bar

Expected result:

  • Empty subject tokens such as foo..bar should be rejected.
  • Publish/request subjects should reject * and > wildcard tokens.
  • Subscribe subjects should only allow wildcards as whole tokens, and > should be the final token.

Impact

Invalid subjects can pass local validation and get serialized into protocol commands. Depending on server settings and timing, errors then happen later as server-side protocol errors instead of deterministic client-side validation failures. During reconnect, invalid publish commands can also enter the reconnect buffer before the server rejects them.

Likely fix

Make subject validation context-aware, e.g. validateSubject($subject, allowWildcards: false) for publish/request and allowWildcards: true for subscribe paths. The validator should split on . and reject empty tokens, partial wildcard tokens, and non-final > tokens.

Activity

  1. matkomat commented on May 12, 2026

    @matkomat
    ContributorAuthor

    Quick clarification on what "match reference behavior" means here, because the answer changed how I'd frame the fix:

    What nats.go actually does

    Checked nats.go directly. The client-side validateSubject() (nats.go:4390) only rejects empty strings and whitespace/control chars — same as sofascore/nats.php does today. There is no client-side tokenization or wildcard-position check in the Go client.

    // nats.go validateSubject (simplified)
    func validateSubject(subj string) error {
        if subj == "" { return ErrBadSubject }
        for i := 0; i < len(subj); i++ {
            c := subj[i]
            if c == ' ' || c == '\t' || c == '\r' || c == '\n' {
                return ErrBadSubject
            }
        }
        return nil
    }

    The strict, position-aware validation lives in nats-server, not in any client:

    // nats-server/server/sublist.go (paraphrased)
    // IsValidSubject — used for subscribe paths (wildcards allowed in proper positions)
    // IsValidLiteralSubject — used for publish paths (no wildcards)
    func isValidSubject(subject string, checkRunes bool) bool {
        if subject == "" { return false }
        sfwc := false  // "seen full wildcard"
        for _, t := range strings.Split(subject, ".") {
            if len(t) == 0 || sfwc { return false }     // no empty tokens, nothing after `>`
            if len(t) > 1 {
                if strings.ContainsAny(t, "\t\n\f\r ") { return false }
                continue                                 // multi-char literal token is fine
            }
            if t[0] == '>' { sfwc = true }              // `>` only valid as last token
        }
        return true
    }
    
    func IsValidLiteralSubject(subject string) bool {
        for _, t := range strings.Split(subject, ".") {
            if len(t) == 0 { return false }
            if len(t) == 1 && (t[0] == '*' || t[0] == '>') { return false }
        }
        return true
    }

    Implication for this issue

    The current sofascore/nats.php behavior actually matches nats.go client behavior — both are deliberately minimal and rely on the server to reject malformed subjects. So this issue is best framed as an enhancement ("be stricter than nats.go for better client-side ergonomics"), not a "bug vs reference parity."

    Two reasonable resolutions:

    • Keep parity with nats.go client → close as "won't fix," document that the server does the strict check, and rely on the server's error reply.
    • Stricter, eager validation in the client (what the original issue body proposed) → port the nats-server algorithm into PHP. Same fail-fast-at-the-boundary spirit as fix: validate stream name in createStream/updateStream #82 for stream names — turns an opaque server-side error or buffered-then-rejected publish into a clear local exception.

    Concrete PHP port if going the stricter route

    public static function validateSubject(string $subject, bool $allowWildcards = false): void
    {
        if ($subject === '') {
            throw new BadSubjectException('Subject cannot be empty');
        }
        if (preg_match('/[\s\x00-\x1f\x7f]/', $subject)) {
            throw new BadSubjectException("Invalid subject: '{$subject}'");
        }
    
        $tokens = explode('.', $subject);
        $seenFullWildcard = false;
    
        foreach ($tokens as $token) {
            if ($token === '' || $seenFullWildcard) {
                // empty token (".foo", "foo..bar", "foo.") or anything after `>`
                throw new BadSubjectException("Invalid subject: '{$subject}'");
            }
            if ($token === '*' || $token === '>') {
                if (!$allowWildcards) {
                    throw new BadSubjectException("Wildcards not allowed in publish subject: '{$subject}'");
                }
                if ($token === '>') {
                    $seenFullWildcard = true; // server rejects if not the last token
                }
            }
        }
    }

    Call sites: publish/request/publishMessage/requestMessage → allowWildcards: false; subscribe/queueSubscribe/subscribeSync/queueSubscribeSync → allowWildcards: true.

    One subtle nats-server quirk worth an explicit decision

    nats-server accepts foo*bar and foo>bar as literal tokens (the */> are only special when they are the entire token). A strict reading of the subject grammar (or a stricter client policy) rejects them. Whichever stance is taken, it's worth a comment in the validator explaining the choice — it surprises people on both sides.

    Test cases that exercise the algorithm

    Subject publish (allowWildcards: false) subscribe (allowWildcards: true)
    foo accept accept
    foo.bar.baz accept accept
    .foo reject (empty leading token) reject
    foo. reject (empty trailing token) reject
    foo..bar reject (empty middle token) reject
    * reject accept
    foo.* reject accept
    foo.*.bar reject accept (* mid-subject OK)
    > reject accept
    foo.> reject accept
    >.bar reject reject (> not last)
    foo.>.bar reject reject (> not last)
    foo bar reject (whitespace) reject
    foo*bar accept (literal, per nats-server) accept
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions