Skip to content

v7.0.2

Latest

Choose a tag to compare

@Ahoo-Wang Ahoo-Wang released this 26 Oct 23:18
0298177

Patch release for the 7.0 line. It stops the policy decoder from accepting malformed policy JSON and compiling it to something that matches more than was written. Exploiting this takes a policy author's mistake, not an outside attacker, but it failed open.

  • What is now rejected. Decoding a policy, app permission or rate limit rule fails with MalformedPolicyException (an IllegalArgumentException) whose message names the JSON path, e.g. Malformed JSON at $.statements[0]: unknown field [conditon] in a statement:
    • unknown fields in a policy, statement, app permission, permission group, permission or rate limit rule. $schema (policy, app permission, rate limit rule) and spaced (permission group) stay accepted and ignored. Example: "conditon": {...} used to be ignored, which made the statement unconditional;
    • condition or action objects without exactly one key. Example: {"all": {}, "inRole": {...}} used to compile to all;
    • scalars where a list belongs: bool's and/or, in's value, time's daysOfWeek, composite, statements, groups, permissions. Example: {"bool": {"and": "inRole"}} used to be an empty and, which matches everything;
    • duplicate keys in any object, where the last one used to win silently.
  • Fail closed. A decode error never skips a policy, so it cannot drop a deny. A malformed local policy, rate limit or shadow file stops startup with Failed to load policy [<file>]: Malformed JSON at .... A malformed value read from Redis fails the requests that need it with 500.

Compatibility

  • Policies with typos, extra keys or scalar lists will now fail to load or fail the request instead of silently matching more. No example or test policy shipped with CoSec is affected.
  • Policies already stored in Redis. 7.0.1 stored each policy as it had decoded it, so:
    • typos and extra keys were dropped before storing. The stored copy reads fine on 7.0.2, but it keeps the widened meaning until you republish it from a fixed source. Only your source documents show the mistake;
    • scalar and/or/in value/daysOfWeek were stored as written. After the upgrade, every request that needs such a policy gets 500: for a global policy that is every request. With local-policy.init-repository and force-refresh=false, startup fails with Init Policy - [<id>] failed.. Republishing the fixed policy with setPolicy replaces the value without reading it.

Before upgrading, find affected policies

  1. Check the files you publish from with this jq script. It prints one line per problem 7.0.2 rejects:

    for f in src/main/resources/cosec-policy/*.json; do jq -r -f cosec-policy-lint.jq "$f" | sed "s|^|$f: |"; done
  2. Check what Redis holds. Each stored value is a binary header followed by the JSON. With the default key prefix cosec:

    redis-cli --scan --pattern 'cosec:policy:*' | grep -v ':policy:history:' | while read -r key; do
      redis-cli --raw GET "$key" | LC_ALL=C sed '1s/^[^{]*//' | grep '^{' | jq -r -f cosec-policy-lint.jq | sed "s|^|$key: |"
    done

    Repeat with cosec:app:permission:*. Only scalar lists can show up here.

  3. Fix each finding at its source, and republish with setPolicy. 7.0.1 reads the fixed version too, so you can do this before upgrading. Also republish the policies whose source had a typo or an extra key.

The authoritative check is loading your policy files with 7.0.2 in a unit test.

cosec-policy-lint.jq
def keys_not_in($allowed; $path):
  keys[] | select(. as $k | $allowed | index($k) | not) | "\($path): unknown field \(.)";
def condition($path):
  if type != "object" then "\($path): a condition must be an object"
  elif length != 1 then "\($path): a condition must have exactly one key, has \(keys)"
  else to_entries[0] as $e
    | if $e.key == "bool" and ($e.value | type) == "object" then
        $e.value | (
          (to_entries[] | select(.key == "and" or .key == "or") as $l
            | if ($l.value | type) != "array" then "\($path).bool.\($l.key): must be an array"
              else $l.value | to_entries[] | .key as $i | .value | condition("\($path).bool.\($l.key)[\($i)]") end))
      elif $e.key == "in" and ($e.value.value | type) != "array" then "\($path).in.value: must be an array"
      elif $e.key == "time" and ($e.value | has("daysOfWeek")) and ($e.value.daysOfWeek | type) != "array" then
        "\($path).time.daysOfWeek: must be an array"
      else empty end
  end;
def action($path):
  if type == "string" then empty
  elif type == "array" then to_entries[] | .key as $i | .value | action("\($path)[\($i)]")
  elif type == "object" then
    if length != 1 then "\($path): an action must have exactly one key, has \(keys)"
    elif has("composite") then
      if (.composite | type) != "array" then "\($path).composite: must be an array"
      else .composite | to_entries[] | .key as $i | .value | action("\($path).composite[\($i)]") end
    else empty end
  else "\($path): an action must be a string, an array or an object" end;
def statement($path; $allowed):
  keys_not_in($allowed; $path),
  (if has("action") then .action | action("\($path).action") else empty end),
  (if has("condition") then .condition | condition("\($path).condition") else empty end);
def items($path):
  if type != "array" then {problem: "\($path): must be an array"}
  else to_entries[] | {path: "\($path)[\(.key)]", value: .value} end;
def document:
  def rule($p): statement($p; ["$schema","id","action","condition","key","permitsPerSecond","windowSeconds","strictFailure"]);
  if type == "array" then to_entries[] | .key as $i | .value | rule("$[\($i)]")
  elif has("groups") then
    keys_not_in(["$schema","id","condition","groups"]; "$"),
    (if has("condition") then .condition | condition("$.condition") else empty end),
    (.groups | items("$.groups") | if has("problem") then .problem else .path as $g | .value
      | keys_not_in(["name","description","permissions","spaced"]; $g),
        (if has("permissions") then .permissions | items("\($g).permissions")
          | if has("problem") then .problem
            else .path as $pp | .value | statement($pp; ["id","name","description","effect","action","condition"]) end
         else empty end) end)
  elif has("permitsPerSecond") then rule("$")
  else
    keys_not_in(["$schema","id","name","category","description","type","tenantId","condition","statements"]; "$"),
    (if has("condition") then .condition | condition("$.condition") else empty end),
    (if has("statements") then .statements | items("$.statements")
      | if has("problem") then .problem else .path as $sp | .value | statement($sp; ["name","effect","action","condition"]) end
     else empty end)
  end;
document

See "7.0.1 to 7.0.2" in the upgrading guide.

implementation("me.ahoo.cosec:cosec-spring-boot-starter:7.0.2")