Skip to content

owasp-array-limit / owasp-string-limit rules never fire ((schema-directionality feature, #680)) #953

Description

@isamauny

Summary

Reactivating owasp-array-limit and owasp-string-limit via a custom ruleset produces zero violations against schemas that clearly violate them — an array-typed property with no maxItems, and a string-typed property with no maxLength/const/enum. Every other OWASP/OAS schema rule I tested (11 others, including the structurally near-identical owasp-integer-limit) fires correctly against the same document in the same invocation.

Bisected across release binaries: works through v0.26.1, broken from v0.26.2 onward (still broken in the current 0.29.9/0.30.0). The v0.26.2 release notes name the change most likely responsible: the new schema-directionality feature (#680), which makes these two rules skip any schema not classified as request-bound. I traced the classification logic (utils.GetSchemaDirection, utils/schema_direction.go) and found a plausible mechanism below — but even a schema referenced directly from a requestBody (unambiguously request-bound) still fails to fire, so the bug looks broader than an edge case in "response-only schemas should be skipped."

Environment

  • vacuum version: reproduced on the actual pinned release binary 0.29.9 (downloaded directly from https://github.com/daveshanley/vacuum/releases/download/v0.29.9/vacuum_0.29.9_darwin_arm64.tar.gz — not a package manager build, not a source build).
  • Also reproduced separately on a Homebrew-installed 0.30.0 binary (self-reported version).
  • Bisection below uses the same official release-binary download pattern for each version tested.
  • OS: macOS (Darwin 24.6.0), arm64.

Minimal reproduction

doc.json:

{
  "openapi": "3.1.0",
  "info": {"title": "x", "version": "1.0"},
  "paths": {},
  "components": {
    "schemas": {
      "Widget": {
        "type": "object",
        "properties": {
          "tags": {"type": "array"},
          "name": {"type": "string"}
        }
      }
    }
  }
}

ruleset.yaml:

extends: [[vacuum:oas, off], [vacuum:owasp, off]]
rules:
  owasp-array-limit: true
  owasp-string-limit: true

Command:

vacuum spectral-report -k -r ruleset.yaml doc.json out.json

Expected

Two violations: tags has no maxItems (owasp-array-limit's own message: schema of type `array` must specify `maxItems`), and name has no maxLength/const/enum (owasp-string-limit's own message: schema of type `string` must specify `maxLength`, `const` or `enum`).

Actual

[]

Zero violations, on every binary from v0.26.2 onward (see bisection).

Positive control — same document, same invocation, adjacent rule fires correctly

Adding an equally-violating integer property and reactivating owasp-integer-limit alongside the same two rules, in one invocation:

doc-with-control.json:

{
  "openapi": "3.1.0",
  "info": {"title": "x", "version": "1.0"},
  "paths": {},
  "components": {
    "schemas": {
      "Widget": {
        "type": "object",
        "properties": {
          "tags": {"type": "array"},
          "name": {"type": "string"},
          "count": {"type": "integer", "format": "int32"}
        }
      }
    }
  }
}

ruleset-with-control.yaml:

extends: [[vacuum:oas, off], [vacuum:owasp, off]]
rules:
  owasp-array-limit: true
  owasp-string-limit: true
  owasp-integer-limit: true

Command:

vacuum spectral-report -k -r ruleset-with-control.yaml doc-with-control.json out-with-control.json

Result (out-with-control.json):

[
  {
    "code": "owasp-integer-limit",
    "path": ["components", "schemas", "Widget", "properties", "count"],
    "message": "schema of type `integer` must specify `minimum` and `maximum` or `exclusiveMinimum` and `exclusiveMaximum`",
    "severity": 0,
    "range": {"start": {"line": 12, "character": 21}, "end": {"line": 12, "character": 27}},
    "source": "doc-with-control.json"
  }
]

Only owasp-integer-limit fires — owasp-array-limit/owasp-string-limit stay silent on tags/name in the exact same document and invocation. This rules out a DrDocument-population issue, a -k/document-validity issue, or a ruleset-loading issue — all three rules go through identical machinery up to the point of their own per-rule logic.

Bisection

Same doc.json/ruleset.yaml above, run against each official release binary (vacuum_<version>_darwin_arm64.tar.gz from the matching GitHub release tag):

version result
v0.25.0 ✅ 2 violations (correct)
v0.26.0 ✅ 2 violations (correct)
v0.26.1 ✅ 2 violations (correct)
v0.26.2 ❌ 0 violations
v0.26.3 – v0.26.8 ❌ 0 violations
v0.27.0 ❌ 0 violations
v0.28.0 ❌ 0 violations
v0.29.9 (pinned) ❌ 0 violations
v0.30.0 (Homebrew) ❌ 0 violations

v0.26.2's release notes (gh release view v0.26.2 / GitHub releases page):

OWASP string-limit and array-limit now respect schema directionality, so response-only schemas are skipped while request/bidirectional schemas are still checked. #680

This is the only changelog entry across the bisected range that mentions string-limit/array-limit, and it lands exactly on the v0.26.1→v0.26.2 boundary found above.

Root cause investigation

functions/owasp/array_limit.go/string_limit.go (as of v0.29.9) gate each schema on:

var direction = utils.GetSchemaDirection(context.DrDocument.V3Document.Document, schema.Name)
if direction != utils.DirectionRequest && direction != utils.DirectionBoth {
    continue
}

GetSchemaDirection (utils/schema_direction.go) walks every path/operation's requestBody/parameters/responses, and for each one calls refMatches(schemaProxy, schemaName) to decide whether that operation's schema is the schema being classified. refMatchesInternal resolves this by taking the $ref string off the SchemaProxy under inspection, splitting on /, and comparing the last segment against name:

reference := schemaProxy.GetReference()
parts := strings.Split(reference, "/")
actualName := parts[len(parts)-1]
...
if name == actualName {
    return true
}

I tested whether an unambiguous request-only schema (directly $ref'd from a POST /widgets requestBody, nothing pointing at it from any response) fixes this:

{
  "openapi": "3.1.0",
  "info": {"title": "x", "version": "1.0"},
  "paths": {
    "/widgets": {
      "post": {
        "requestBody": {
          "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Widget"}}}
        },
        "responses": {"200": {"description": "ok"}}
      }
    }
  },
  "components": {"schemas": {"Widget": {"type": "object", "properties": {"tags": {"type": "array"}, "name": {"type": "string"}}}}}
}

Run with the same owasp-array-limit/owasp-string-limit ruleset, no -k needed (this is a fully valid OAS 3.1 document) — still zero violations, while adding owasp-integer-limit and an integer property to the same schema still fires correctly in the same invocation. So this isn't only "orphaned/unreferenced schemas get miscategorized" — a schema that's about as unambiguously request-bound as it's possible to construct is still being classified as not-request (or the classification result isn't reaching the skip check correctly).

The likeliest mechanism, based on reading the code (not confirmed by instrumenting a build): GetSchemaDirection is called with schema.Name — the Name field off a context.DrDocument.Schemas entry, i.e. whatever the doctor/libopenapi "dr" model populates for a schema discovered by walking the whole document (array_limit.go's for _, schema := range context.DrDocument.Schemas loop). refMatchesInternal, on the other hand, derives its comparison name (actualName) purely from splitting a $ref string on / and taking the last segment. If schema.Name on the DrDocument.Schemas entry isn't in that same bare-trailing-segment form (e.g. it's empty, or a full path/pointer, or uses different casing/normalization), name == actualName never matches for any schema, usedInRequest/usedInResponse never get set, GetSchemaDirection always returns DirectionNone, and the rule skips every schema unconditionally — which matches what I observed (never fires, even for the most unambiguous case).

Suggested fix

Confirm what context.DrDocument.Schemas[i].Name actually holds for a components.schemas entry (and for an inline/nested schema) versus what refMatchesInternal's actualName (last /-segment of a $ref) expects — that mismatch, if it's what I think it is, would explain every observation above: total silence regardless of how unambiguously request-bound the schema is, while every rule not touched by #680 keeps working normally on the same documents.

Impact / workaround

We ported a subset of vacuum's built-in OAS/OWASP schema rules into a downstream tool (via the extends: [[pack, off]] + rules: {id: true} reactivation pattern) and had to exclude owasp-array-limit/owasp-string-limit from that set after finding they silently produce no findings — meaning a consumer would believe unbounded array/string schemas are clean when the check simply isn't running. No workaround beyond exclusion (or pinning to v0.26.1, if that's viable); every other rule we tried (13 total) works correctly.

Activity

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

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions