Skip to main content

JSON-LD Security

JSON-LD processing introduces unique security considerations.

Context Injection

The Risk

Malicious contexts can redefine terms:

{
"@context": [
"https://www.w3.org/ns/activitystreams",
{
"inbox": "https://evil.example/steal-data"
}
],
"type": "Person",
"inbox": "ignored-value"
}

Prevention

Always use a fixed, trusted context:

const TRUSTED_CONTEXT = {
"@context": "https://www.w3.org/ns/activitystreams"
};

function processActivity(activity) {
// Strip remote context, use trusted one
const safe = {
...TRUSTED_CONTEXT,
...activity,
"@context": TRUSTED_CONTEXT["@context"]
};
return safe;
}

Remote Context Fetching

The Risk

┌──────────────┐                    ┌──────────────┐
│ Your Server │───fetch context──▶│ Evil Server │
│ │◀──malicious data──│ │
└──────────────┘ └──────────────┘

Risks include:

  • SSRF (Server-Side Request Forgery)
  • Denial of service (slow responses)
  • Context redefinition
  • Information disclosure

Prevention

// Cache known contexts locally
const CONTEXT_CACHE = {
'https://www.w3.org/ns/activitystreams': require('./contexts/activitystreams.json'),
'https://w3id.org/security/v1': require('./contexts/security.json')
};

function getContext(url) {
if (CONTEXT_CACHE[url]) {
return CONTEXT_CACHE[url];
}
throw new Error(`Unknown context: ${url}`);
}

SSRF Prevention

Block requests to internal networks:

const BLOCKED_RANGES = [
'127.0.0.0/8', // Loopback
'10.0.0.0/8', // Private
'172.16.0.0/12', // Private
'192.168.0.0/16', // Private
'169.254.0.0/16', // Link-local
'::1/128', // IPv6 loopback
'fc00::/7' // IPv6 private
];

async function safeFetch(url) {
const parsed = new URL(url);
const ip = await dns.resolve(parsed.hostname);

if (isBlockedIP(ip)) {
throw new Error('Blocked IP range');
}

return fetch(url, { timeout: 5000 });
}

Recursive Expansion

The Risk

Deeply nested JSON-LD can cause stack overflow:

{
"@context": { "a": { "@id": "a", "@context": { "a": { "@id": "a" } } } }
}

Prevention

function processJsonLd(doc, depth = 0) {
if (depth > 10) {
throw new Error('Max depth exceeded');
}

// Process with depth limit
return expand(doc, { maxDepth: 10 });
}

Type Confusion

The Risk

Type can be an array:

{
"type": ["Person", "Service"],
"inbox": "..."
}

Prevention

function getType(object) {
const type = object.type;

if (Array.isArray(type)) {
// Use first recognized type
return type.find(t => KNOWN_TYPES.includes(t));
}

return type;
}

Property Overloading

The Risk

Properties can be objects instead of strings:

{
"id": {
"@id": "https://example.com/user",
"malicious": "data"
}
}

Prevention

function getId(object) {
const id = object.id || object['@id'];

if (typeof id === 'object') {
return id['@id'] || id.id;
}

return id;
}

Safe Processing Pattern

class SafeJsonLdProcessor {
constructor() {
this.contextCache = new Map();
this.maxDepth = 10;
this.timeout = 5000;
}

async process(document) {
// 1. Validate structure
this.validateStructure(document);

// 2. Normalize context
const normalized = this.normalizeContext(document);

// 3. Extract values safely
return {
id: this.extractId(normalized),
type: this.extractType(normalized),
actor: this.extractId(normalized.actor),
object: this.extractObject(normalized.object)
};
}

validateStructure(doc) {
if (typeof doc !== 'object' || doc === null) {
throw new Error('Invalid document');
}

if (JSON.stringify(doc).length > 1000000) {
throw new Error('Document too large');
}
}

normalizeContext(doc) {
return {
...doc,
"@context": "https://www.w3.org/ns/activitystreams"
};
}

extractId(value) {
if (!value) return null;
if (typeof value === 'string') return value;
return value.id || value['@id'];
}

extractType(doc) {
const type = doc.type || doc['@type'];
if (Array.isArray(type)) return type[0];
return type;
}
}

Security Checklist

CheckPriority
Use fixed/cached contextsCritical
Block private IP rangesCritical
Limit recursion depthHigh
Set fetch timeoutsHigh
Validate document sizeHigh
Handle type arraysMedium
Normalize property accessMedium

See Also