Skip to content

Validate cache-key shapes before canonicalization #97

Description

@lan17

Priority

P2 — Medium — prevent malformed JavaScript or untyped input from becoming a valid shared cache identity.

Current baseline

At v0.11.0 / e06d833ba245706a499d66056fdad15dc1210b68, the TypeScript CacheKeySpec contract requires either a scalar id or { id, args? }. Correctly typed callers are protected at compile time.

At runtime, however, a structured object without id is accepted. String(spec.id) converts the missing value to the literal string "undefined", so the malformed operation proceeds through normal caching and coalescing rather than the existing key_construction fail-open path.

Problem

An untyped caller, JavaScript consumer, stale generated wrapper, or unsafe cast can supply { args: { locale: "en" } } or { id: undefined, args: ... }. DialCache constructs an id of "undefined", which can collide with a legitimate string id "undefined" under the same namespace, key type, use case, and arguments.

The collision can share request-local or process-scoped in-flight work, local and Redis values, and tracked invalidation identity. Because key construction does not throw, the operation is cached instead of logging/counting key_construction and running its source-of-truth fallback uncached. Direct new DialCacheKey(...) construction has the same missing-id coercion risk.

Pinned evidence

  • The public typed key contract requires id:
    type CacheKeyArgs = Record<string, string | number | boolean | bigint | null | undefined>;
    type Id = string | number | bigint;
    /** A cache-key spec: a bare id, or an id plus extra (secondary) key dimensions. */
    export type CacheKeySpec = Id | { readonly id: Id; readonly args?: CacheKeyArgs };
  • Runtime key construction stringifies spec.id without validating it:

    DialCache/src/dialcache.ts

    Lines 697 to 712 in e06d833

    private buildKey<Value>(
    options: CacheOperationOptions<Value>,
    cacheKey: CacheKeySpec,
    defaultConfig: DialCacheKeyConfig | null,
    ): DialCacheKey {
    const spec = typeof cacheKey === "object" ? cacheKey : { id: cacheKey };
    return new DialCacheKey({
    keyType: options.keyType,
    id: String(spec.id),
    useCase: options.useCase,
    args: normalizeArgs(spec.args ?? {}),
    namespace: this.namespace,
    defaultConfig,
    serializer: (options.serializer as Serializer<unknown> | null | undefined) ?? null,
    trackForInvalidation: options.trackForInvalidation ?? false,
    });
  • Direct DialCacheKey construction also accepts and encodes the supplied id without runtime validation:

    DialCache/src/key.ts

    Lines 27 to 45 in e06d833

    constructor(init: DialCacheKeyInit) {
    if (Object.hasOwn(init, "urnPrefix")) {
    throw new TypeError('DialCacheKeyInit.urnPrefix was renamed to "namespace"');
    }
    this.keyType = init.keyType;
    this.id = init.id;
    this.useCase = init.useCase;
    this.args = init.args ?? [];
    this.defaultConfig = init.defaultConfig ?? null;
    this.serializer = init.serializer ?? null;
    this.trackForInvalidation = init.trackForInvalidation ?? false;
    this.namespace = init.namespace ?? "urn";
    assertValidNamespace(this.namespace);
    const rawPrefix = joinUrnComponents(this.namespace, this.keyType, this.id);
    this.prefix = this.trackForInvalidation ? redisClusterHashTag(invalidationPrefix(this.namespace, this.keyType, this.id)) : rawPrefix;
    const args = this.args.length > 0 ? `?${this.args.map(([name, value]) => `${encodeComponent(name)}=${encodeComponent(value)}`).join("&")}` : "";
    this.urn = `${this.prefix}${args}#${encodeComponent(this.useCase)}`;
  • DialCache already has a safe key-construction failure path that logs, records bounded metrics, and runs uncached:

    DialCache/src/dialcache.ts

    Lines 324 to 335 in e06d833

    let key: DialCacheKey;
    try {
    key = this.buildKey(options, selectKey(), defaultConfig);
    } catch (error) {
    this.logger.error("Could not construct DialCache key", error);
    this.metrics?.error({
    ...noLayerLabels,
    error: "key_construction",
    inFallback: false,
    });
    return await this.callFallback(noLayerLabels, fallback);
    }
  • The README defines the selected/direct key as the value-identity contract:

    DialCache/README.md

    Lines 158 to 160 in e06d833

    For `cached()`, the key comes from the required `cacheKey` selector whose parameters are inferred from `fn`. `getOrLoad()` accepts the same bare id or `{ id, args }` shape directly through `key`:
    The selected or direct key is the value identity contract. It must include every input dimension that can affect the returned value; otherwise distinct calls can reuse the same cached value or share the same in-flight fallback through request coalescing.

A built-artifact reproduction at the pinned commit passed {} twice and returned the first loader's value both times while running the loader only once.

Simplest scope

  • Add one internal runtime assertion for valid ids.
  • Accept only the existing supported scalar domain: string | number | bigint for wrapper/direct-operation key specs.
  • Require direct DialCacheKeyInit.id to be a string, matching its public type.
  • Reject missing, undefined, null, object, boolean, symbol, and function ids instead of stringifying them.
  • Let wrapper-driven failures use the existing key_construction fail-open path.
  • Let direct new DialCacheKey(...) misuse throw a focused TypeError.
  • Do not add a public error class, key mode, normalization option, or identity dimension.
  • Preserve exact key bytes for every currently valid key.

Acceptance criteria

  • An enabled cached() selector returning {} or { id: undefined } through an untyped boundary:
    • records one key_construction error;
    • executes the source-of-truth fallback uncached;
    • does not populate request-local, process-local, or Redis state;
    • does not coalesce with a legitimate "undefined" id.
  • getOrLoad() has the same behavior for malformed structured keys.
  • Direct new DialCacheKey(...) rejects a missing or non-string id clearly.
  • Valid string, number, and bigint wrapper keys retain the existing string-canonical identity contract.
  • A legitimate string id "undefined" remains supported and keeps its existing key bytes.
  • Calls outside an enabled scope remain true pass-through and do not inspect or validate their selected/direct key.
  • Focused tests cover cached, inline, direct-constructor, local, Redis, and coalescing paths.
  • Typecheck, unit tests, build/declarations, packed ESM/CommonJS consumers, and integration tests remain green.

Compatibility

This is runtime hardening for malformed inputs. Correctly typed callers and valid key bytes are unchanged. Untyped callers that previously cached under an accidental "undefined" identity instead fail open and execute uncached.

Related issues

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