Skip to content

[Security] Actor names accept control chars, allowing log injection #134

Description

@pathosDev

Severity / Size

  • Severity: LOW
  • Size: S
  • Threat model: closed-group cluster where actor names are derived from user input (entity IDs, tenant IDs, user-supplied names). Risk is log injection — actor names land in nearly every log line via system.log.withSource(this.path.toString()).

Affected files

  • src/ActorPath.ts:5-15ActorPath constructor accepts any string name; no validation.
  • src/internal/ActorCell.ts:99-109ActorCell constructor concatenates the name into path then sets this.log = system.log.withSource(this.path.toString()). Every log line from this actor now contains the name.
  • src/internal/ActorCell.ts:123-134actorOf accepts any name?: string and registers it as _children key.
  • src/Logger.ts:42-45 — log emission merges the source string (which contains the name) into every record without escaping.

Relationship to #126 (A6.2 — ActorPath path-traversal)

This issue and #126 address the same code site (ActorPath/ActorCell name validation) from two angles:

The implementation in #126 already covers \0, \r, \n (in its validator). If #126 is implemented first, this issue is largely resolved as a side-effect. It's kept as a separate issue because:

  1. The threat model is different (log integrity vs route bypass), and someone reviewing only "log injection" findings should find it.
  2. The fix design here adds a narrower scope (control chars only) for deployments that do want / in actor names (e.g. legacy compat) but still need log integrity.
  3. The acceptance criteria diverge — [Security] ActorPath.child(name) doesn't reject path-traversal segments #126's tests focus on routing bypass; this issue's tests focus on log output.

If #126 ships first, close this as "fixed by #126" and add the log-injection test cases there.

Background

Actor names flow into:

  1. ActorPath.toString()actor-ts://default/user/<name>/...
  2. system.log.withSource(this.path.toString()) — every log line from this actor includes the path string in its source field.
  3. this._children map key in ActorCell — used in error messages, debug output.
  4. Logger record fields (level, time, source, msg, ctx).

Every common logger (the built-in Pretty/JSON formatters, pino, winston) emits records as newline-delimited entries. A newline embedded in the source name produces a fake log line:

{"level":"info","time":"2026-05-11T...","source":"actor-ts://default/user/foo
ERROR injected log","msg":"normal log line"}

The log aggregator (Loki/ELK/Splunk) sees two records — the second one being the attacker's injected line, parseable as a fresh entry.

Same vulnerability shape as CVE-2018-1000805 (Python's logging module), CVE-2021-44228 (Log4j's surface area was larger but newline-injection was one vector), countless logging-related CVEs across all stacks. The OWASP class is A09 (Security Logging Failures).

Exploit walkthrough

Step 1 — App spawns actors per user-supplied identifier:

post(async (req) => {
  const userId = queryParam(req, 'userId') ?? 'anon';
  const ref = system.actorOf(SessionActorProps, `session-${userId}`);
  // ...
});

Step 2 — Attacker calls POST /session?userId=victim%0AERROR%20user%20admin%20deleted%20by%20attacker%20-%20remember%20to%20investigate. URL-decoded: userId = 'victim\nERROR user admin deleted by attacker - remember to investigate'.

Step 3 — Actor is spawned, name becomes 'session-victim\nERROR user admin deleted by attacker - remember to investigate'. First log line from the actor:

{"level":"debug","time":"...","source":"actor-ts://default/user/session-victim
ERROR user admin deleted by attacker - remember to investigate","msg":"actor started"}

Step 4 — Log aggregator parses as two events:

  • Event 1: {"level":"debug", ..., "source":"actor-ts://default/user/session-victim", ...} (truncated)
  • Event 2: ERROR user admin deleted by attacker - remember to investigate", "msg":"actor started"} — parses as a malformed JSON line, may be promoted to ERROR severity by some pipelines, alerts a SOC analyst.

Step 5 — SOC analyst investigates the injected ERROR line. Cost: 30-60 minutes of analyst time per injection. At scale (thousands of injections), SOC drowns in noise; real alerts get missed.

Realistic worst case: alert fatigue + audit trail forensic confusion. Combined with #133 (pid log injection) and #126 (path traversal), this is the third site in the same family.

How the 8 already-landed security fixes inform this

  • Memcached CRLF guard — exact same shape: reject \r/\n in keys at the wrapper. Drop in the same check here.
  • FS path-traversal (assertSafeKey) — same boundary-validation pattern.
  • Hello-handshake hijack defence — validated identity at the connection boundary; rejected mismatched values. Same shape: reject malformed names at construction.

The shape: the constructor of an addressable type is the one place to validate; trying to escape at log-emission time is hopeless because not every log path goes through framework-controlled code.

Fix design

Track 1 — assertSafeActorName validator in ActorPath constructor (primary). If #126 ships first, this is already done. If standalone, the narrower scope is:

function assertLogSafeName(name: string): void {
  if (typeof name !== 'string' || name.length === 0) {
    throw new Error('ActorPath: name must be a non-empty string');
  }
  // Control chars: NUL, CR, LF, vertical tab, form feed, backspace, etc.
  if (/[\x00-\x1f\x7f]/.test(name)) {
    throw new Error('ActorPath: name contains control characters');
  }
}

Apply in ActorPath constructor when parent !== null (root paths excepted).

Track 2 — Defence-in-depth at ActorCell.actorOf. Re-validate the childName (which may have been auto-generated as $<counter>) before adding to _children.

Track 3 — Log-source sanitiser as fallback. Even if name validation passes, the log emitter can replace control chars with (Unicode replacement) before emission. This catches the case where:

  • A name is constructed legitimately but contains a Unicode char that renders as a newline in some terminals (rare).
  • A future code path bypasses ActorPath constructor (unlikely but possible).

In Logger.ts's emit path:

function sanitiseSource(s: string): string {
  return s.replace(/[\x00-\x1f\x7f]/g, '�');
}

This is belt-and-braces; the constructor check is the primary defence.

Track 4 — Documentation. README "Known security caveats" gets a bullet (consolidate with #126 if both ship):

- Actor names + persistence ids: framework rejects control chars
  (\0, \r, \n, etc.) at construction.  Apps deriving names from
  user input still need their own allow-list for character class
  (we only enforce log-safety, not URL-safety or DB-safety).

API surface

No new public API. Behaviour change: system.actorOf(props, 'foo\nbar') throws.

If implemented via assertLogSafeName alone (standalone of #126), names with / continue to work (the routing-correctness angle is separate).

If implemented via #126's assertSafeActorSegment, names with / are also rejected; this is the stricter / preferred path.

Backward compatibility

Breaking for any code that puts control chars in actor names. Audit:

$ grep -rE "actorOf\([^,]+,\s*['\"][^'\"]*[\\\\\\x00-\\x1f][^'\"]*['\"]\)" tests/ examples/
(no matches)

Document in CHANGELOG under "Security defaults: actor name validation". Note relationship to #126 (single coordinated change, two issue closures).

Test plan

  1. Newline rejectionactorOf(props, 'foo\nbar') throws with clear message.
  2. CRLF rejection'foo\r\nbar' throws.
  3. NUL rejection'foo\0bar' throws.
  4. All control chars rejected — parameterised test over \x00..\x1f and \x7f.
  5. Log-source sanitiser test — directly call the sanitiser with a control-char string, verify replacement.
  6. Log-output integration test — spawn an actor with a "legitimate" name, capture log output, verify it's parsable as exactly N JSON records (not more — proves no injection).
  7. Happy path'foo', 'foo-bar', 'session-123', '$1' all work.
  8. Regression — full 1 720+ tests pass.

Acceptance criteria

  • ActorPath constructor rejects control chars in name (when parent !== null).
  • ActorCell.actorOf re-validates childName before registration.
  • (Optional) Logger source-field sanitiser replaces control chars with .
  • CHANGELOG entry; cross-reference [Security] ActorPath.child(name) doesn't reject path-traversal segments #126 if implemented in coordination.
  • Test suite covers control-char rejection + log-output integrity.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingpriority: lowNice-to-have / niche / demand-drivensecuritySecurity-relevant — see severity label for impact tierseverity: lowMinor / informational / mitigated-by-design

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions