i18n: interpolate leaves "{{name}}" in the message when the context value is falsy (0, "", false), e.g. "Invalid parameter: {{detail}}" in error responses

Open Beginner friendly
#114 0 comments 0 reactions 0 assignees View on GitHub

Maintainers usually reply within 1 day

Nobody has claimed this yet.

Assessment

Difficulty
2/5
Estimated time
1-3 hours
Newbie friendliness
78/100
Issue type
Bug
Clarity
Clearly specified
Activity status
Active
Tech stack
bun, typescript

Research direction

The bug is in the interpolation step at src/i18n/index.ts line 84, where the truthiness test on vars[key] treats 0, empty string and false as missing. Read resolveMessage and the surrounding interpolation code, then add the regression cases from the issue to src/i18n/index.test.ts and run it with bun test. Done when falsy values are stringified and only absent or null keys keep the {{key}} placeholder.

Written by the indexing model from the issue text.

Description

ai-driven-fuzzing bug claude Minor

Steps to reproduce

At the API level: any route that throws new DomainError("M_INVALID_PARAM", "M_INVALID_PARAM", { detail: "" }) (or { detail: 0 }) answers:

400 {"errcode":"M_INVALID_PARAM","error":"Invalid parameter: {{detail}}"}

The same happens with translate("log.net.status", { status: 0 }), which gives "status {{status}}" in the logs.

Fuzzer input (scenario 35, binary, hex), saved as inputs/i18n-interpolate-keeps-placeholder-for-falsy-value.hex:

e1e1bfe1e1e100005bffff27e1001800ffffffffffff5bffff27e1000600ffff
ffffffffffffffff27ffffffffffffff fffffffff700ffffffff27ffffffffff
ff00ffffffffff00ff4aff617a5f7a6572

The harness decodes it to several requests. The failing one hits a route throwing DomainError("M_INVALID_PARAM", {detail: ""}), and the answer is "Invalid parameter: {{detail}}".

Regression test (bun:test, for example src/i18n/index.test.ts):

import { beforeAll, describe, expect, it } from "bun:test";
import { loadMessages, resolveMessage } from "./index";

describe("resolveMessage interpolation", () => {
  beforeAll(() => {
    loadMessages("assets/i18n", { debug() {}, info() {}, warn() {}, error() {} } as any);
  });

  it.each([["", "Invalid parameter: "], [0, "Invalid parameter: 0"], [false, "Invalid parameter: false"]])(
    "fills {{detail}} with %p",
    (detail, expected) => {
      expect(resolveMessage("en", "M_INVALID_PARAM", { detail })).toBe(expected);
    },
  );

  it("keeps the placeholder only when the key is absent", () => {
    expect(resolveMessage("en", "M_INVALID_PARAM", {})).toBe("Invalid parameter: {{detail}}");
  });
});

Expected

A placeholder whose key the context supplies is replaced by String(value), whatever the value (0 gives "0", "" gives an empty string, false gives "false"). Only a key that is absent (or undefined/null) may keep {{key}}.

Observed

Scenario 35 oracle (reproduced):

Error: a placeholder whose value the context gives was left in "Invalid parameter: {{detail}}" (context {"detail":""})

Possible cause

src/i18n/index.ts:84:

return vars[key] ? String(vars[key]) : `{{${key}}}`;

The truthiness test treats 0, "" and false as missing. Suggested fix: test for presence instead, e.g. Object.hasOwn(vars, key) && vars[key] != null ? String(vars[key]) : \{{${key}}}`. Object.hasOwnalso stops keys such as{{constructor}}` from resolving to inherited properties of the context object.

Introduced by

Not found: interpolate comes from 4d406b7 (2026-09-15, "chore: initial import"), which squashes the earlier history. No later commit changed line 84.

Impact

The context values come from the server's own code. A client can reach this only when a route copies client-controlled data that can be empty or zero into a DomainError context (for example an empty parameter name or value reported in detail). The visible effect is an error message with a literal {{detail}}, or a log line such as status {{status}} that hides a real value (a status of 0 for a network failure is exactly what an operator needs to see). Status codes and errcode are unaffected. There is no availability, integrity or confidentiality impact.

Fuzzing scenario

35-cors-and-error-responses (CORS decision, errorMiddleware / i18n error messages and requestId behind the middleware chain of createApp). Found during the first run on commit e2e64af072bd45835ff6738234014135b4f3793d.


Found by twake-fuzz (coverage-guided fuzzing), scenario 35-cors-and-error-responses, commit e2e64af.

Dominant language
TypeScript
Stars
2
Forks
3
Avg merge
2d 3h
Merged PRs (30d)
19

Getting set up

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

More from linagora/ToM-Server

All issues in linagora/ToM-Server

Similar issues

More TypeScript issues

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.