Security 13 min read

JSONPath, Pointer & Patch Security: Safe Querying and Partial Updates Without Footguns

Learn the security pitfalls of JSONPath (RFC 9535), JSON Pointer, and JSON Patch. Includes safe patterns for PATCH endpoints, allowlist strategies, and common exploit paths.

#jsonpath #json-pointer #json-patch #rfc-9535 #api-security #partial-updates

JSONPath is now standardized (RFC 9535), and more teams are using JSON Pointer for stable references and JSON Patch for "efficient updates." But every time you accept expressive strings that influence evaluation, you've created a mini interpreter in your stack. Here's how to do it safely.

TL;DR

  • Treat JSONPath/Pointer/Patch strings as query languages—they need allowlists and budgets
  • For PATCH endpoints: apply patch → validate full resource → authorize → persist
  • The most common bypass: validating only the patch document, not the resulting state
  • If you must accept user-defined JSONPath, restrict the grammar and cap runtime

The Primitives (Quick Refresher)

JSONPath (RFC 9535)

JSONPath selects nodes from a JSON document. It can include field selection, array selection, and filters. With RFC 9535, we finally have standardized semantics.

jsonpath-examples.txt
text
$.store.book[*].author      # All authors
$.store.book[?@.price<10]   # Books under $10
$..author                   # All authors (recursive)

JSON Pointer (RFC 6901)

JSON Pointer is a string syntax to reference a specific location in a JSON document:

json-pointer-examples.txt
text
/a/b/0        # obj.a.b[0]
/foo~1bar     # key "foo/bar" (escaped)
/foo~0bar     # key "foo~bar" (escaped)

JSON Patch (RFC 6902)

JSON Patch is an array of operations: add, remove, replace, move, copy, test.

json-patch-example.json
json
[
  {"op": "replace", "path": "/name", "value": "Alice"},
  {"op": "add", "path": "/tags/-", "value": "new-tag"},
  {"op": "remove", "path": "/deprecated"}
]

JSON Merge Patch (RFC 7386)

Merge Patch is a partial document: keys set to null mean delete; other keys replace.

merge-patch-example.json
json
{
  "name": "Alice",
  "deprecated": null
}

Threat Model: What Attackers Do

1. Path Injection to Update Unauthorized Fields

If a client can send:

privilege-escalation.json
json
[
  {"op": "replace", "path": "/role", "value": "admin"}
]

Your API must ensure the caller is authorized to change /role. Type validation alone doesn't prevent privilege escalation.

2. "Patch-Only Validation" (The Common Bug)

Teams validate the patch document (it's an array of ops) but never validate the resulting resource. Attackers craft valid ops that produce an invalid state.

⚠️ Fix: Apply patch in memory, then validate the full result against your schema.

3. DoS via Expensive Selectors

User-defined JSONPath can cause:

  • Deep traversal (recursive descent)
  • Large fan-out selections
  • Heavy filter evaluation
Fix: Budgets + restrict features.

4. Data Exfil via Filter/Query Languages

If JSONPath controls which fields are returned, attackers can request sensitive fields by selecting them.

Fix: Field-level authorization and allowlists.

Safe Patterns for JSONPath

Pattern A: Don't Accept Arbitrary JSONPath from Untrusted Users

Instead, offer:

  • Fixed query parameters (?status=active&createdAfter=...)
  • Pre-defined query IDs (queryId=...)
  • A constrained DSL you control

If you must accept JSONPath, treat it like SQL: parse, validate against an allowlist grammar, and cap runtime.

Pattern B: Restrict JSONPath to "Read-Only Selectors"

Restrict to:

  • Root $
  • Simple dot/bracket name selection
  • Numeric array indices
  • No filters, no scripts, no recursive descent

And cap:

  • Maximum path length
  • Maximum selected nodes
  • Maximum document size
jsonpath-allowlist.ts
typescript
const JSONPATH_LIMITS = {
  maxPathLength: 100,
  maxSelectedNodes: 100,
  maxDocumentSizeBytes: 1_000_000,
  allowedFeatures: {
    recursiveDescent: false,
    filters: false,
    scripts: false,
  },
};

function validateJSONPath(path: string): boolean {
  if (path.length > JSONPATH_LIMITS.maxPathLength) return false;
  if (path.includes('..')) return false; // recursive descent
  if (path.includes('?')) return false;  // filters
  if (path.includes('(')) return false;  // scripts
  return true;
}

Safe Patterns for PATCH Endpoints

The Gold Standard: Apply → Validate → Authorize → Persist

  1. Load current resource (R0)
  2. Apply patch to produce candidate (R1)
  3. Validate R1 against the same schema used for full PUT/POST
  4. Authorize the transition (who can change what)
  5. Persist with optimistic concurrency
safe-patch-handler.ts
typescript
async function handlePatch(resourceId: string, patch: JsonPatch, user: User) {
  // 1. Load current resource
  const current = await db.getResource(resourceId);
  if (!current) throw new NotFoundError();
  
  // 2. Apply patch to produce candidate
  const candidate = applyPatch(current, patch);
  
  // 3. Validate full result (not just the patch!)
  const validation = schema.validate(candidate);
  if (!validation.valid) {
    throw new ValidationError(validation.errors);
  }
  
  // 4. Authorize the transition
  const changedPaths = getChangedPaths(current, candidate);
  for (const path of changedPaths) {
    if (!canUserModifyPath(user, path)) {
      throw new ForbiddenError(`Cannot modify ${path}`);
    }
  }
  
  // 5. Persist with optimistic concurrency
  await db.updateResource(resourceId, candidate, current.version);
}

This prevents:

  • Partial-update bypasses
  • Schema drift between PUT and PATCH
  • "Hidden field" injection

Field-Level Authorization: Allowlist Mutable Paths

Build a server-side allowlist:

path-allowlist.ts
typescript
const PATH_PERMISSIONS = {
  user: ['/displayName', '/profile/bio', '/settings/*'],
  admin: ['/displayName', '/profile/bio', '/settings/*', '/role', '/status'],
};

function canUserModifyPath(user: User, path: string): boolean {
  const allowedPaths = PATH_PERMISSIONS[user.role] || [];
  return allowedPaths.some(allowed => {
    if (allowed.endsWith('/*')) {
      return path.startsWith(allowed.slice(0, -1));
    }
    return path === allowed;
  });
}

Require test Operations for Risky Mutations

For high-concurrency or sensitive resources, require a test op before a replace:

test-before-replace.json
json
[
  {"op": "test", "path": "/version", "value": "v12"},
  {"op": "replace", "path": "/displayName", "value": "New Name"}
]

Or enforce HTTP If-Match with ETags instead (often simpler).

JSON Pointer Sharp Edges

Escaping Bugs Become Security Bugs

Because / and ~ must be escaped, naive string splitting can:

  • Target the wrong field
  • Bypass allowlists if you compare unescaped vs escaped forms inconsistently
Recommendation: Normalize pointers (parse + re-serialize) before comparing to allowlists.

JSON Patch Sharp Edges

Array Operations: - and Index Handling

path: "/items/-" means append (add). Ensure your allowlist recognizes this.

Also budget array sizes: an attacker can append 10k elements if you don't cap.

move / copy Can Bypass Naive Allowlists

If you allow writing to /public, but not reading from /secret, copy can exfiltrate within the document:

copy-exfil.json
json
{"op": "copy", "from": "/secret", "path": "/public"}
Recommendations:
  • Treat from as sensitive too
  • Allowlist operations per endpoint (add/replace only is common)
  • Or prohibit move/copy entirely for untrusted clients

Merge Patch Sharp Edges

Merge Patch is deceptively simple:

  • Setting a key to null deletes it
  • Objects merge recursively, other values replace

Pitfall: A patch can delete security-relevant fields (e.g., mfaEnabled: null) unless you authorize deletions.

Recommendations:
  • Validate full resource after merge
  • Enforce required fields
  • Enforce "cannot delete" policy for certain paths

Implementation Checklist

  • ☐ If JSONPath comes from users, treat it like SQL: allowlist grammar + runtime budgets
  • ☐ For PATCH: apply patch then validate full resulting resource
  • ☐ Enforce field-level authorization on paths (path and from)
  • ☐ Disable or strictly control move/copy operations
  • ☐ Normalize JSON Pointers before comparing to allowlists
  • ☐ Enforce input budgets: patch length, op count, max path length, max array growth
  • ☐ Use ETags / If-Match to avoid lost updates and reduce race bugs

References

Continue Learning

About the Author

AT

Adam Tse

Founder & Lead Developer · 10+ years experience

Full-stack engineer with 10+ years of experience building developer tools and APIs. Previously worked on data infrastructure at scale, processing billions of JSON documents daily. Passionate about creating privacy-first tools that don't compromise on functionality.

JavaScript/TypeScript Web Performance Developer Tools Data Processing