Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
version: 2
updates:
# Maven dependencies (pom.xml)
- package-ecosystem: "maven"
directory: "/"
schedule:
interval: "weekly"
open-pull-requests-limit: 10
groups:
# collapse routine minor/patch bumps into a single PR to cut noise; majors stay separate
minor-and-patch:
update-types:
- "minor"
- "patch"

# Base image in the Dockerfile
- package-ecosystem: "docker"
directory: "/"
schedule:
interval: "weekly"

# GitHub Actions used by the workflows
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
63 changes: 63 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
# LinkedDataHub — Agent Guide

This document describes how an autonomous agent (or any HTTP/LLM client) drives a **running LinkedDataHub (LDH) instance's HTTP API**. It is the API-usage counterpart to `CLAUDE.md` (which is for contributing to the codebase).

LinkedDataHub is a data-driven Knowledge Graph platform. Everything — documents, applications, access control, the UI — is RDF, managed over a small, uniform HTTP API and standard protocols. There is no bespoke REST surface to learn: you work with RDF documents and SPARQL.

## Data model

- The content is a **hierarchy of documents** (containers and items). A container holds child documents; items are leaves.
- **Every document URL is a named graph.** Reading a document returns the RDF in that graph; writing changes it. This is the [SPARQL 1.1 Graph Store Protocol](https://www.w3.org/TR/sparql11-http-rdf-update/).
- Identifiers are opaque URLs. Do not parse structure out of them; follow links (hypermedia) instead.

## Authentication

- **WebID-TLS** (client certificate) is the primary mechanism for programmatic agents. Every request carries the cert; the certificate's WebID is the agent identity. With `curl`: `-E cert.pem:password` (`-k` in dev with self-signed certs).
- **OAuth2 (Google)** and **OpenID Connect (ORCID)** are available for human logins.
- **Delegation**: an authorized secretary agent can act for a principal via the `On-Behalf-Of: <principal-WebID>` request header.
- Authorization is WebID-based ACLs (`acl:Read`/`Append`/`Write`/`Control`), enforced per document. A response's `Link` headers advertise the modes the current agent holds on that resource.

## Reading data

`GET` a document URL with content negotiation:

- `Accept: text/turtle` · `application/rdf+xml` · `application/ld+json` · `application/n-triples` (any RDF serialization Jena supports) → the document's RDF.
- `Accept: text/html` → the application shell (Saxon-JS then renders client-side). Request RDF, not HTML, when you want data.

## Writing data (the discipline)

Writes go through the **document URLs**, never through the SPARQL endpoint (which is read-only):

| Intent | Method | Body | Notes |
|--------|--------|------|-------|
| Create a child in a container | `POST` container URL | RDF (e.g. `Content-Type: text/turtle`) | Server mints the child URL and returns it in `Location` |
| Create or replace a document at a known URL | `PUT` document URL | RDF | Replaces the whole named graph |
| Update a document in place | `PATCH` document URL | `Content-Type: application/sparql-update` | A SPARQL Update (`INSERT`/`DELETE`) applied to that named graph |
| Delete a document | `DELETE` document URL | — | Removes the named graph |

Relative URIs in a request body resolve against the target URL. See `bin/post.sh`, `bin/put.sh`, `bin/patch.sh`, `bin/delete.sh` for exact, working invocations.

## Querying (read-only)

The dataspace exposes a **read-only SPARQL 1.1 Query** endpoint (advertised via the Service Description `sd:endpoint`; conventionally `/sparql`). `GET`/`POST` a `SELECT`/`CONSTRUCT`/`DESCRIBE`/`ASK`; results are content-negotiated. The endpoint does **not** accept SPARQL Update — mutate via `PATCH` on document URLs (above).

Write portable, standard SPARQL: use explicit `GRAPH` patterns, no engine-specific extensions.

## Content & document model

- Documents carry ordered **content blocks**. Only `ldh:Object` (an embedded RDF resource view) and `ldh:XHTML` (rich text) are permitted as block values; anything else must be wrapped in an `ldh:Object`.
- **Views** (`ldh:View`) are SPARQL-driven blocks (`SELECT`/`CONSTRUCT`/`DESCRIBE`) rendered as lists, tables, grids, charts, maps, or a graph.
- Forms and validation are ontology-driven (SPIN constructors + SHACL shapes), so instance data is shaped by the app's ontology rather than hardcoded schemas.

## Dataspaces

A single instance hosts multiple **dataspaces**, each a subdomain (origin). Each dataspace pairs an end-user app (`<subdomain>`) with an admin app at the **`admin.` prefix** (`admin.<subdomain>`) — never an `/admin` path. Admin apps manage ontologies, ACLs, and app settings.

## Tooling

- **CLI**: the `bin/` scripts wrap every operation above (`get.sh`, `post.sh`, `put.sh`, `patch.sh`, `delete.sh`, `create-container.sh`, `create-item.sh`, `add-view.sh`, `add-select.sh`, `add-construct.sh`, `add-result-set-chart.sh`, `add-file.sh`, `webid-keygen.sh`). They are the authoritative reference for request shapes.
- **Programmatic / MCP**: [Web-Algebra](https://github.com/AtomGraph/Web-Algebra) is the recommended path for agent-composed workflows — a JSON DSL and MCP server whose operations (create container/item, add view/chart, generate portal, …) compose multi-step LDH writes atomically under WebID auth.

## Standards

WebID-TLS · SPARQL 1.1 Query & Update · Graph Store Protocol · Linked Data Templates · SHACL · SPIN · RDF (Turtle/RDF-XML/JSON-LD/N-Triples). LDH composes existing W3C/IETF standards; it does not define new wire protocols.
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,19 @@
## [Unreleased]
### Security
- SSRF: `URLValidator` now blocks wildcard/any-local (0.0.0.0, ::) addresses in addition to link-local and private ranges, and checks every address the host resolves to (narrowing the DNS-rebinding window). Loopback stays reachable for same-origin document/WebID dereferencing; the backend triplestore is site-local (already blocked). `ALLOW_INTERNAL_URLS` remains the development escape hatch (LNK-003/LNK-009)
- XXE: added `SecureXML` hardened parser factories — `XSLTMasterUpdater` parses with DTDs and external entities disabled, and the external responses parsed by `ldh:send-request` use secure processing (entity-expansion capped) with external entities disabled (LNK-005 residual)
- Upgraded `java-jwt` 3.19.4 → 4.5.2 on the OAuth2/OIDC verification path
- Documented the pinned-truststore invariant behind the disabled hostname verification on internal HTTP clients

### Added
- Unit tests for `AuthorizationFilter`: the HTTP-method → ACL access-mode contract (`GET`/`HEAD`→Read, `POST`→Append, `PUT`/`DELETE`/`PATCH`→Write), mode lookup, and the owner Read/Write/Append grant
- Loopback/wildcard `URLValidator` tests; JWKS-based `JWTVerifier` tests (valid, wrong issuer, wrong audience, expired, missing `kid`, bad signature)
- `AGENTS.md`: an agent-facing guide to driving a running instance's HTTP API — data model, WebID auth, read/write discipline (writes via `POST`/`PUT`/`PATCH` on document URLs; read-only SPARQL), content model, dataspaces, tooling
- Dependabot config (`.github/dependabot.yml`) for Maven, the Docker base image, and GitHub Actions updates; routine Maven minor/patch bumps grouped into one PR

### Changed
- Cache TTLs configurable: the WebID model cache and the JWKS cache now read their expiration (seconds) from `WEBID_CACHE_EXPIRATION` / `JWKS_CACHE_EXPIRATION` (default 86400 = 1 day), via `CATALINA_OPTS` system properties like the `CLIENT_*` timeouts. Lowering `WEBID_CACHE_EXPIRATION` bounds how long a revoked WebID stays authenticated

## [5.6.0] - 2026-07-08
### Added
- `OntologyRepository` (renamed from `OntologyModelGetter`): a bounded, evicting ontology cache that serves bundled vocabularies without querying SPARQL; per-app creation is thread-safe and each ontology is materialized once under a lock (`owl:imports` closure flattened manually, then RDFS-inferred and materialized). Seeded ad-hoc in `Namespace`
Expand Down
4 changes: 4 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,10 @@ ENV CLIENT_CONNECTION_TIME_TO_LIVE=300000

ENV CLIENT_VALIDATE_AFTER_INACTIVITY=10000

ENV WEBID_CACHE_EXPIRATION=86400

ENV JWKS_CACHE_EXPIRATION=86400

ENV IMPORT_KEEPALIVE=

ENV MAX_IMPORT_THREADS=10
Expand Down
8 changes: 8 additions & 0 deletions platform/entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -1092,6 +1092,14 @@ if [ -n "$CLIENT_VALIDATE_AFTER_INACTIVITY" ]; then
export CATALINA_OPTS="$CATALINA_OPTS -Dcom.atomgraph.linkeddatahub.validateAfterInactivity=$CLIENT_VALIDATE_AFTER_INACTIVITY"
fi

if [ -n "$WEBID_CACHE_EXPIRATION" ]; then
export CATALINA_OPTS="$CATALINA_OPTS -Dcom.atomgraph.linkeddatahub.webIDCacheExpiration=$WEBID_CACHE_EXPIRATION"
fi

if [ -n "$JWKS_CACHE_EXPIRATION" ]; then
export CATALINA_OPTS="$CATALINA_OPTS -Dcom.atomgraph.linkeddatahub.jwksCacheExpiration=$JWKS_CACHE_EXPIRATION"
fi

if [ -n "$MAX_CONTENT_LENGTH" ]; then
MAX_CONTENT_LENGTH_PARAM="--stringparam ldhc:maxContentLength '$MAX_CONTENT_LENGTH' "
fi
Expand Down
6 changes: 3 additions & 3 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

<groupId>com.atomgraph</groupId>
<artifactId>linkeddatahub</artifactId>
<version>5.6.0</version>
<version>5.6.1-SNAPSHOT</version>
<packaging>${packaging.type}</packaging>

<name>AtomGraph LinkedDataHub</name>
Expand Down Expand Up @@ -46,7 +46,7 @@
<url>https://github.com/AtomGraph/LinkedDataHub</url>
<connection>scm:git:git://github.com/AtomGraph/LinkedDataHub.git</connection>
<developerConnection>scm:git:git@github.com:AtomGraph/LinkedDataHub.git</developerConnection>
<tag>linkeddatahub-5.6.0</tag>
<tag>linkeddatahub-5.5.4</tag>
</scm>

<repositories>
Expand Down Expand Up @@ -181,7 +181,7 @@
<dependency>
<groupId>com.auth0</groupId>
<artifactId>java-jwt</artifactId>
<version>3.19.4</version>
<version>4.5.2</version>
</dependency>
<dependency>
<groupId>net.jodah</groupId>
Expand Down
6 changes: 4 additions & 2 deletions src/main/java/com/atomgraph/linkeddatahub/Application.java
Original file line number Diff line number Diff line change
Expand Up @@ -288,9 +288,9 @@ public class Application extends ResourceConfig
private final KeyStore keyStore, trustStore;
private final URI secretaryWebIDURI;
private final List<Locale> supportedLanguages;
private final ExpiringMap<URI, Model> webIDmodelCache = ExpiringMap.builder().expiration(1, TimeUnit.DAYS).build(); // TO-DO: config for the expiration period?
private final ExpiringMap<URI, Model> webIDmodelCache = ExpiringMap.builder().expiration(Long.parseLong(System.getProperty("com.atomgraph.linkeddatahub.webIDCacheExpiration", "86400")), TimeUnit.SECONDS).build(); // TTL (seconds) configurable via WEBID_CACHE_EXPIRATION; a lower value bounds how long a revoked WebID stays cached
private final ExpiringMap<String, Model> oidcModelCache = ExpiringMap.builder().variableExpiration().build();
private final ExpiringMap<String, jakarta.json.JsonObject> jwksCache = ExpiringMap.builder().expiration(1, TimeUnit.DAYS).build(); // Cache JWKS responses
private final ExpiringMap<String, jakarta.json.JsonObject> jwksCache = ExpiringMap.builder().expiration(Long.parseLong(System.getProperty("com.atomgraph.linkeddatahub.jwksCacheExpiration", "86400")), TimeUnit.SECONDS).build(); // Cache JWKS responses; TTL (seconds) configurable via JWKS_CACHE_EXPIRATION
private final Map<URI, XsltExecutable> xsltExecutableCache = new ConcurrentHashMap<>();
private final MessageDigest messageDigest;
private final boolean enableWebIDSignUp;
Expand Down Expand Up @@ -1519,6 +1519,7 @@ public static Client getClient(KeyStore keyStore, String keyStorePassword, KeySt
ctx.init(kmf.getKeyManagers(), tmf.getTrustManagers(), null);

Registry<ConnectionSocketFactory> socketFactoryRegistry = RegistryBuilder.<ConnectionSocketFactory>create().
// hostname verification is safely disabled because ctx trusts only the pinned truststore; revisit if that truststore ever widens to public CAs
register("https", new SSLConnectionSocketFactory(ctx, NoopHostnameVerifier.INSTANCE)).
register("http", new PlainConnectionSocketFactory()).
build();
Expand Down Expand Up @@ -1626,6 +1627,7 @@ public static Client getNoCertClient(KeyStore trustStore, Integer maxConnPerRout
ctx.init(null, tmf.getTrustManagers(), null);

Registry<ConnectionSocketFactory> socketFactoryRegistry = RegistryBuilder.<ConnectionSocketFactory>create().
// hostname verification is safely disabled because ctx trusts only the pinned truststore; revisit if that truststore ever widens to public CAs
register("https", new SSLConnectionSocketFactory(ctx, NoopHostnameVerifier.INSTANCE)).
register("http", new PlainConnectionSocketFactory()).
build();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,7 @@ public SecurityContext authenticate(ContainerRequestContext request)
else
{
if (log.isDebugEnabled()) log.debug("ID token for subject '{}' has expired at {}, refresh token not found", jwt.getSubject(), jwt.getExpiresAt());
throw new TokenExpiredException("ID token for subject '%s' has expired at %s".formatted(jwt.getSubject(), jwt.getExpiresAt()));
throw new TokenExpiredException("ID token for subject '%s' has expired at %s".formatted(jwt.getSubject(), jwt.getExpiresAt()), jwt.getExpiresAt().toInstant());
}
}
if (!verify(jwt)) return null;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
/**
* Copyright 2026 Martynas Jusevičius <martynas@atomgraph.com>
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package com.atomgraph.linkeddatahub.server.util;

import javax.xml.XMLConstants;
import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.parsers.ParserConfigurationException;
import javax.xml.parsers.SAXParserFactory;
import org.xml.sax.SAXException;
import org.xml.sax.XMLReader;

/**
* Factory helpers for XML parsers hardened against XXE and entity-expansion (billion laughs) attacks.
*
* @author Martynas Jusevičius {@literal <martynas@atomgraph.com>}
* @see <a href="https://cheatsheetseries.owasp.org/cheatsheets/XML_External_Entity_Prevention_Cheat_Sheet.html">OWASP XXE Prevention</a>
*/
public final class SecureXML
{

private SecureXML()
{
}

/**
* Returns a namespace-aware {@link DocumentBuilderFactory} with DTDs and external entities disabled.
* Suitable for parsing trusted internal XML (e.g. stylesheets) that never carries a DOCTYPE.
*
* @return hardened document builder factory
* @throws ParserConfigurationException if a feature cannot be set
*/
public static DocumentBuilderFactory newDocumentBuilderFactory() throws ParserConfigurationException
{
DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
factory.setNamespaceAware(true);
factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
factory.setFeature("http://xml.org/sax/features/external-general-entities", false);
factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
factory.setXIncludeAware(false);
factory.setExpandEntityReferences(false);
return factory;
}

/**
* Returns an {@link XMLReader} hardened for parsing untrusted external content.
* Secure processing caps entity expansion (billion laughs) and external entities are disabled,
* while a benign internal DOCTYPE (e.g. XHTML) is still tolerated.
*
* @return hardened XML reader
* @throws ParserConfigurationException if a feature cannot be set
* @throws SAXException if the reader cannot be created
*/
public static XMLReader newXMLReader() throws ParserConfigurationException, SAXException
{
SAXParserFactory factory = SAXParserFactory.newInstance();
factory.setNamespaceAware(true);
factory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true);
factory.setFeature("http://xml.org/sax/features/external-general-entities", false);
factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
factory.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false);
return factory.newSAXParser().getXMLReader();
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -52,12 +52,17 @@ public URLValidator(boolean allowInternal)
/**
* Validates that the URI does not point to an internal/private network address.
* Prevents SSRF attacks by blocking access to:
* - RFC 1918 private addresses (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, fc00::/7)
* - Wildcard/any-local addresses (0.0.0.0, ::)
* - RFC 1918 private addresses (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, fec0::/10)
* - Link-local addresses (169.254.0.0/16, fe80::/10)
*
* Note: Loopback addresses (127.0.0.1, localhost, ::1) are NOT blocked as the application
* may legitimately need to access resources on the same server (e.g., transformation queries,
* WebID documents during development, admin operations).
* All addresses the host resolves to are checked (not just the first), narrowing the DNS-rebinding
* window where a host publishes both a public and an internal address.
*
* Loopback addresses (127.0.0.0/8, ::1) are intentionally NOT blocked: LinkedDataHub dereferences
* its own documents and WebIDs on the same origin (which is loopback in local/test deployments), while
* the backend triplestore is reached over a private/site-local address (blocked above), not loopback.
* The {@code ALLOW_INTERNAL_URLS} escape hatch disables all checks for fully-internal deployments.
*
* @param uri the URI to validate
* @return the validated URI
Expand All @@ -73,18 +78,18 @@ public URI validate(URI uri)
String host = uri.getHost();
if (host == null) throw new IllegalArgumentException("URI host cannot be null");

// Resolve hostname to IP and check if it's private/internal
// Resolve hostname to all IPs and reject if any is wildcard/private/internal (loopback intentionally allowed)
try
{
InetAddress address = InetAddress.getByName(host);

// Note: We don't block loopback addresses (127.0.0.1, localhost) because the application
// legitimately accesses its own endpoints for various operations

if (address.isLinkLocalAddress())
throw new InternalURLException(uri, address.getHostAddress());
if (address.isSiteLocalAddress())
throw new InternalURLException(uri, address.getHostAddress());
for (InetAddress address : InetAddress.getAllByName(host))
{
if (address.isAnyLocalAddress())
throw new InternalURLException(uri, address.getHostAddress());
if (address.isLinkLocalAddress())
throw new InternalURLException(uri, address.getHostAddress());
if (address.isSiteLocalAddress())
throw new InternalURLException(uri, address.getHostAddress());
}
}
catch (UnknownHostException e)
{
Expand Down
Loading
Loading