diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 000000000..c1c05e267 --- /dev/null +++ b/.github/dependabot.yml @@ -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" diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..5305a8558 --- /dev/null +++ b/AGENTS.md @@ -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: ` 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 (``) with an admin app at the **`admin.` prefix** (`admin.`) — 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. diff --git a/CHANGELOG.md b/CHANGELOG.md index d82acc00c..eb1c7ded1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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` diff --git a/Dockerfile b/Dockerfile index afcdb0e8d..98243907d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/platform/entrypoint.sh b/platform/entrypoint.sh index d5c81dd76..1a8915ad6 100755 --- a/platform/entrypoint.sh +++ b/platform/entrypoint.sh @@ -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 diff --git a/pom.xml b/pom.xml index e28cae297..c4410dd1d 100644 --- a/pom.xml +++ b/pom.xml @@ -3,7 +3,7 @@ com.atomgraph linkeddatahub - 5.6.0 + 5.6.1-SNAPSHOT ${packaging.type} AtomGraph LinkedDataHub @@ -46,7 +46,7 @@ https://github.com/AtomGraph/LinkedDataHub scm:git:git://github.com/AtomGraph/LinkedDataHub.git scm:git:git@github.com:AtomGraph/LinkedDataHub.git - linkeddatahub-5.6.0 + linkeddatahub-5.5.4 @@ -181,7 +181,7 @@ com.auth0 java-jwt - 3.19.4 + 4.5.2 net.jodah diff --git a/src/main/java/com/atomgraph/linkeddatahub/Application.java b/src/main/java/com/atomgraph/linkeddatahub/Application.java index 2d429ce4f..9147b37fc 100644 --- a/src/main/java/com/atomgraph/linkeddatahub/Application.java +++ b/src/main/java/com/atomgraph/linkeddatahub/Application.java @@ -288,9 +288,9 @@ public class Application extends ResourceConfig private final KeyStore keyStore, trustStore; private final URI secretaryWebIDURI; private final List supportedLanguages; - private final ExpiringMap webIDmodelCache = ExpiringMap.builder().expiration(1, TimeUnit.DAYS).build(); // TO-DO: config for the expiration period? + private final ExpiringMap 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 oidcModelCache = ExpiringMap.builder().variableExpiration().build(); - private final ExpiringMap jwksCache = ExpiringMap.builder().expiration(1, TimeUnit.DAYS).build(); // Cache JWKS responses + private final ExpiringMap 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 xsltExecutableCache = new ConcurrentHashMap<>(); private final MessageDigest messageDigest; private final boolean enableWebIDSignUp; @@ -1519,6 +1519,7 @@ public static Client getClient(KeyStore keyStore, String keyStorePassword, KeySt ctx.init(kmf.getKeyManagers(), tmf.getTrustManagers(), null); Registry socketFactoryRegistry = RegistryBuilder.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(); @@ -1626,6 +1627,7 @@ public static Client getNoCertClient(KeyStore trustStore, Integer maxConnPerRout ctx.init(null, tmf.getTrustManagers(), null); Registry socketFactoryRegistry = RegistryBuilder.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(); diff --git a/src/main/java/com/atomgraph/linkeddatahub/server/filter/request/auth/IDTokenFilterBase.java b/src/main/java/com/atomgraph/linkeddatahub/server/filter/request/auth/IDTokenFilterBase.java index d3ed486c8..7d42cb81c 100644 --- a/src/main/java/com/atomgraph/linkeddatahub/server/filter/request/auth/IDTokenFilterBase.java +++ b/src/main/java/com/atomgraph/linkeddatahub/server/filter/request/auth/IDTokenFilterBase.java @@ -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; diff --git a/src/main/java/com/atomgraph/linkeddatahub/server/util/SecureXML.java b/src/main/java/com/atomgraph/linkeddatahub/server/util/SecureXML.java new file mode 100644 index 000000000..32c8f7240 --- /dev/null +++ b/src/main/java/com/atomgraph/linkeddatahub/server/util/SecureXML.java @@ -0,0 +1,78 @@ +/** + * Copyright 2026 Martynas Jusevičius + * + * 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 } + * @see OWASP XXE Prevention + */ +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(); + } + +} diff --git a/src/main/java/com/atomgraph/linkeddatahub/server/util/URLValidator.java b/src/main/java/com/atomgraph/linkeddatahub/server/util/URLValidator.java index 23e3c92ec..9a1c6fbd1 100644 --- a/src/main/java/com/atomgraph/linkeddatahub/server/util/URLValidator.java +++ b/src/main/java/com/atomgraph/linkeddatahub/server/util/URLValidator.java @@ -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 @@ -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) { diff --git a/src/main/java/com/atomgraph/linkeddatahub/server/util/XSLTMasterUpdater.java b/src/main/java/com/atomgraph/linkeddatahub/server/util/XSLTMasterUpdater.java index cf37e56c3..d193455dc 100644 --- a/src/main/java/com/atomgraph/linkeddatahub/server/util/XSLTMasterUpdater.java +++ b/src/main/java/com/atomgraph/linkeddatahub/server/util/XSLTMasterUpdater.java @@ -23,8 +23,8 @@ import org.w3c.dom.Node; import org.w3c.dom.NodeList; import jakarta.servlet.ServletContext; +import javax.xml.XMLConstants; import javax.xml.parsers.DocumentBuilder; -import javax.xml.parsers.DocumentBuilderFactory; import javax.xml.transform.OutputKeys; import javax.xml.transform.Transformer; import javax.xml.transform.TransformerFactory; @@ -203,15 +203,14 @@ public void removePackageImport(Path masterFile, String packagePath) throws IOEx private Document parseDocument(Path file) throws ParserConfigurationException, SAXException, IOException { - DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); - factory.setNamespaceAware(true); - DocumentBuilder builder = factory.newDocumentBuilder(); + DocumentBuilder builder = SecureXML.newDocumentBuilderFactory().newDocumentBuilder(); return builder.parse(file.toFile()); } private void serializeDocument(Document doc, Path file) throws TransformerException { TransformerFactory transformerFactory = TransformerFactory.newInstance(); + transformerFactory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true); Transformer transformer = transformerFactory.newTransformer(); transformer.setOutputProperty(OutputKeys.INDENT, "no"); transformer.setOutputProperty(OutputKeys.ENCODING, "UTF-8"); diff --git a/src/main/java/com/atomgraph/linkeddatahub/writer/function/SendHTTPRequest.java b/src/main/java/com/atomgraph/linkeddatahub/writer/function/SendHTTPRequest.java index 3847b8a38..647356ef8 100644 --- a/src/main/java/com/atomgraph/linkeddatahub/writer/function/SendHTTPRequest.java +++ b/src/main/java/com/atomgraph/linkeddatahub/writer/function/SendHTTPRequest.java @@ -16,6 +16,7 @@ */ package com.atomgraph.linkeddatahub.writer.function; +import com.atomgraph.linkeddatahub.server.util.SecureXML; import com.atomgraph.linkeddatahub.vocabulary.LDH; import java.io.IOException; import java.io.InputStream; @@ -25,7 +26,8 @@ import jakarta.ws.rs.core.MultivaluedHashMap; import jakarta.ws.rs.core.MultivaluedMap; import jakarta.ws.rs.core.Response; -import javax.xml.transform.stream.StreamSource; +import javax.xml.parsers.ParserConfigurationException; +import javax.xml.transform.sax.SAXSource; import net.sf.saxon.s9api.ExtensionFunction; import net.sf.saxon.s9api.ItemType; import net.sf.saxon.s9api.ItemTypeFactory; @@ -38,6 +40,8 @@ import net.sf.saxon.s9api.XdmValue; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.xml.sax.InputSource; +import org.xml.sax.SAXException; /** * Executes an HTTP request. @@ -117,7 +121,7 @@ public XdmValue call(XdmValue[] arguments) throws SaxonApiException if (cr.hasEntity()) try (InputStream is = cr.readEntity(InputStream.class)) { - return getProcessor().newDocumentBuilder().build(new StreamSource(is)); + return getProcessor().newDocumentBuilder().build(new SAXSource(SecureXML.newXMLReader(), new InputSource(is))); } } else @@ -135,14 +139,14 @@ public XdmValue call(XdmValue[] arguments) throws SaxonApiException if (cr.hasEntity()) try (InputStream is = cr.readEntity(InputStream.class)) { - return getProcessor().newDocumentBuilder().build(new StreamSource(is)); + return getProcessor().newDocumentBuilder().build(new SAXSource(SecureXML.newXMLReader(), new InputSource(is))); } } } - + return XdmEmptySequence.getInstance(); } - catch (IOException ex) + catch (IOException | ParserConfigurationException | SAXException ex) { throw new SaxonApiException(ex); } diff --git a/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/bootstrap/2.3.2/client/navigation.xsl b/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/bootstrap/2.3.2/client/navigation.xsl index ba5c52a93..a1c148684 100644 --- a/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/bootstrap/2.3.2/client/navigation.xsl +++ b/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/bootstrap/2.3.2/client/navigation.xsl @@ -1355,7 +1355,7 @@ ORDER BY DESC(?created) - + diff --git a/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/bootstrap/2.3.2/document.xsl b/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/bootstrap/2.3.2/document.xsl index f051f895d..a55089436 100644 --- a/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/bootstrap/2.3.2/document.xsl +++ b/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/bootstrap/2.3.2/document.xsl @@ -458,6 +458,7 @@ extension-element-prefixes="ixsl" + @@ -477,6 +478,9 @@ extension-element-prefixes="ixsl" + + + diff --git a/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/bootstrap/2.3.2/layout.xsl b/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/bootstrap/2.3.2/layout.xsl index 287c62335..4ef00ce53 100644 --- a/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/bootstrap/2.3.2/layout.xsl +++ b/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/bootstrap/2.3.2/layout.xsl @@ -849,6 +849,7 @@ WHERE + diff --git a/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/client.xsl b/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/client.xsl index ddf605df9..7fc44c4a4 100644 --- a/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/client.xsl +++ b/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/client.xsl @@ -447,24 +447,14 @@ WHERE - - - + + - - - - - - - - - - - - - + + + + @@ -542,6 +532,11 @@ WHERE + + + + + @@ -567,6 +562,7 @@ WHERE + @@ -793,9 +789,33 @@ WHERE + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/test/java/com/atomgraph/linkeddatahub/server/filter/request/AuthorizationFilterTest.java b/src/test/java/com/atomgraph/linkeddatahub/server/filter/request/AuthorizationFilterTest.java new file mode 100644 index 000000000..b4cb06ed1 --- /dev/null +++ b/src/test/java/com/atomgraph/linkeddatahub/server/filter/request/AuthorizationFilterTest.java @@ -0,0 +1,135 @@ +/* + * Copyright 2026 Martynas Jusevičius . + * + * 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.filter.request; + +import com.atomgraph.linkeddatahub.vocabulary.ACL; +import com.atomgraph.linkeddatahub.vocabulary.LACL; +import jakarta.ws.rs.HttpMethod; +import org.apache.jena.rdf.model.Model; +import org.apache.jena.rdf.model.ModelFactory; +import org.apache.jena.rdf.model.Resource; +import org.apache.jena.rdf.model.ResourceFactory; +import org.apache.jena.vocabulary.RDF; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Unit tests for the pure authorization logic in {@link AuthorizationFilter}: + * the HTTP-method-to-access-mode contract, mode lookup, and the owner grant. + * These methods do not touch the injected collaborators, so the filter is exercised directly. + * + * @author Martynas Jusevičius {@literal } + */ +public class AuthorizationFilterTest +{ + + private static final Resource ACCESS_TO = ResourceFactory.createResource("https://localhost/doc/"); + private static final Resource AGENT = ResourceFactory.createResource("https://localhost/acl/agents/me/#this"); + + // HTTP method -> ACL access mode: a security-critical contract (a regression here silently changes what each verb requires) + + @Test + public void testReadMethodsRequireRead() + { + assertEquals(ACL.Read, AuthorizationFilter.ACCESS_MODES.get(HttpMethod.GET)); + assertEquals(ACL.Read, AuthorizationFilter.ACCESS_MODES.get(HttpMethod.HEAD)); + } + + @Test + public void testPostRequiresAppend() + { + assertEquals(ACL.Append, AuthorizationFilter.ACCESS_MODES.get(HttpMethod.POST)); + } + + @Test + public void testWriteMethodsRequireWrite() + { + assertEquals(ACL.Write, AuthorizationFilter.ACCESS_MODES.get(HttpMethod.PUT)); + assertEquals(ACL.Write, AuthorizationFilter.ACCESS_MODES.get(HttpMethod.DELETE)); + assertEquals(ACL.Write, AuthorizationFilter.ACCESS_MODES.get(HttpMethod.PATCH)); + } + + @Test + public void testUnknownMethodHasNoAccessMode() + { + assertNull(AuthorizationFilter.ACCESS_MODES.get(HttpMethod.OPTIONS)); + } + + // getAuthorizationByMode: find an authorization in the model that grants the requested mode + + @Test + public void testFindsAuthorizationGrantingMode() + { + Model model = ModelFactory.createDefaultModel(); + Resource auth = model.createResource("https://localhost/acl/authorizations/1/#this"). + addProperty(RDF.type, ACL.Authorization). + addProperty(ACL.mode, ACL.Read). + addProperty(ACL.mode, ACL.Append); + + assertEquals(auth, new AuthorizationFilter().getAuthorizationByMode(model, ACL.Read)); + assertEquals(auth, new AuthorizationFilter().getAuthorizationByMode(model, ACL.Append)); + } + + @Test + public void testReturnsNullWhenNoAuthorizationGrantsMode() + { + Model model = ModelFactory.createDefaultModel(); + model.createResource("https://localhost/acl/authorizations/1/#this"). + addProperty(RDF.type, ACL.Authorization). + addProperty(ACL.mode, ACL.Read); + + assertNull(new AuthorizationFilter().getAuthorizationByMode(model, ACL.Write)); + } + + // createOwnerAuthorization: owner is granted Read/Write/Append on the document + + @Test + public void testOwnerAuthorizationGrantsReadWriteAppend() + { + Model model = ModelFactory.createDefaultModel(); + Resource auth = new AuthorizationFilter().createOwnerAuthorization(model, ACCESS_TO, AGENT); + + assertTrue(auth.hasProperty(RDF.type, ACL.Authorization)); + assertTrue(auth.hasProperty(RDF.type, LACL.OwnerAuthorization)); + assertTrue(auth.hasProperty(ACL.accessTo, ACCESS_TO)); + assertTrue(auth.hasProperty(ACL.agent, AGENT)); + assertTrue(auth.hasProperty(ACL.mode, ACL.Read)); + assertTrue(auth.hasProperty(ACL.mode, ACL.Write)); + assertTrue(auth.hasProperty(ACL.mode, ACL.Append)); + } + + @Test + public void testOwnerAuthorizationIsDiscoverableByMode() + { + Model model = ModelFactory.createDefaultModel(); + Resource auth = new AuthorizationFilter().createOwnerAuthorization(model, ACCESS_TO, AGENT); + + assertEquals(auth, new AuthorizationFilter().getAuthorizationByMode(model, ACL.Write)); + } + + @Test + public void testCreateOwnerAuthorizationRejectsNullArguments() + { + Model model = ModelFactory.createDefaultModel(); + assertThrows(IllegalArgumentException.class, () -> new AuthorizationFilter().createOwnerAuthorization(null, ACCESS_TO, AGENT)); + assertThrows(IllegalArgumentException.class, () -> new AuthorizationFilter().createOwnerAuthorization(model, null, AGENT)); + assertThrows(IllegalArgumentException.class, () -> new AuthorizationFilter().createOwnerAuthorization(model, ACCESS_TO, null)); + } + +} diff --git a/src/test/java/com/atomgraph/linkeddatahub/server/util/JWTVerifierTest.java b/src/test/java/com/atomgraph/linkeddatahub/server/util/JWTVerifierTest.java new file mode 100644 index 000000000..c9b7d8093 --- /dev/null +++ b/src/test/java/com/atomgraph/linkeddatahub/server/util/JWTVerifierTest.java @@ -0,0 +1,154 @@ +/* + * Copyright 2026 Martynas Jusevičius . + * + * 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 com.auth0.jwt.JWT; +import com.auth0.jwt.JWTCreator; +import com.auth0.jwt.algorithms.Algorithm; +import com.auth0.jwt.interfaces.DecodedJWT; +import jakarta.json.Json; +import jakarta.json.JsonObject; +import java.math.BigInteger; +import java.net.URI; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.NoSuchAlgorithmException; +import java.security.interfaces.RSAPrivateKey; +import java.security.interfaces.RSAPublicKey; +import java.time.Instant; +import java.util.Base64; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Unit tests for JWKS-based JWT verification. + * The JWKS is supplied through the cache argument so verification runs offline (no HTTP client needed). + * + * @author Martynas Jusevičius {@literal } + */ +public class JWTVerifierTest +{ + + private static final String ISSUER = "https://accounts.example.com"; + private static final String CLIENT_ID = "client-abc"; + private static final String KID = "test-key-1"; + private static final String SUBJECT = "user-123"; + private static final URI JWKS_ENDPOINT = URI.create("https://accounts.example.com/jwks"); + + private static KeyPair keyPair; // published in the JWKS + private static KeyPair otherKeyPair; // used to forge a bad signature + private static List allowedIssuers; + private static Map jwksCache; + + @BeforeAll + public static void init() throws NoSuchAlgorithmException + { + KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA"); + generator.initialize(2048); + keyPair = generator.generateKeyPair(); + otherKeyPair = generator.generateKeyPair(); + + allowedIssuers = List.of(ISSUER); + + JsonObject jwk = Json.createObjectBuilder(). + add("kty", "RSA"). + add("use", "sig"). + add("alg", "RS256"). + add("kid", KID). + add("n", base64Url(((RSAPublicKey) keyPair.getPublic()).getModulus())). + add("e", base64Url(((RSAPublicKey) keyPair.getPublic()).getPublicExponent())). + build(); + JsonObject jwks = Json.createObjectBuilder().add("keys", Json.createArrayBuilder().add(jwk)).build(); + + jwksCache = new HashMap<>(); + jwksCache.put(JWKS_ENDPOINT.toString(), jwks); + } + + @Test + public void testValidTokenVerifies() + { + DecodedJWT jwt = JWT.decode(createToken(ISSUER, CLIENT_ID, KID, Instant.now().plusSeconds(300), keyPair)); + assertTrue(JWTVerifier.verify(jwt, JWKS_ENDPOINT, allowedIssuers, CLIENT_ID, null, jwksCache)); + } + + @Test + public void testWrongIssuerRejected() + { + DecodedJWT jwt = JWT.decode(createToken("https://evil.example", CLIENT_ID, KID, Instant.now().plusSeconds(300), keyPair)); + assertFalse(JWTVerifier.verify(jwt, JWKS_ENDPOINT, allowedIssuers, CLIENT_ID, null, jwksCache)); + } + + @Test + public void testWrongAudienceRejected() + { + DecodedJWT jwt = JWT.decode(createToken(ISSUER, "some-other-client", KID, Instant.now().plusSeconds(300), keyPair)); + assertFalse(JWTVerifier.verify(jwt, JWKS_ENDPOINT, allowedIssuers, CLIENT_ID, null, jwksCache)); + } + + @Test + public void testExpiredTokenRejected() + { + DecodedJWT jwt = JWT.decode(createToken(ISSUER, CLIENT_ID, KID, Instant.now().minusSeconds(300), keyPair)); + assertFalse(JWTVerifier.verify(jwt, JWKS_ENDPOINT, allowedIssuers, CLIENT_ID, null, jwksCache)); + } + + @Test + public void testMissingKeyIdRejected() + { + DecodedJWT jwt = JWT.decode(createToken(ISSUER, CLIENT_ID, null, Instant.now().plusSeconds(300), keyPair)); + assertFalse(JWTVerifier.verify(jwt, JWKS_ENDPOINT, allowedIssuers, CLIENT_ID, null, jwksCache)); + } + + @Test + public void testBadSignatureRejected() + { + // token references the published kid but is signed with a different key + DecodedJWT jwt = JWT.decode(createToken(ISSUER, CLIENT_ID, KID, Instant.now().plusSeconds(300), otherKeyPair)); + assertFalse(JWTVerifier.verify(jwt, JWKS_ENDPOINT, allowedIssuers, CLIENT_ID, null, jwksCache)); + } + + private static String createToken(String issuer, String audience, String kid, Instant expiresAt, KeyPair signingKeyPair) + { + Algorithm algorithm = Algorithm.RSA256((RSAPublicKey) signingKeyPair.getPublic(), (RSAPrivateKey) signingKeyPair.getPrivate()); + + JWTCreator.Builder builder = JWT.create(). + withIssuer(issuer). + withAudience(audience). + withSubject(SUBJECT). + withExpiresAt(expiresAt); + if (kid != null) builder = builder.withKeyId(kid); + + return builder.sign(algorithm); + } + + private static String base64Url(BigInteger value) + { + byte[] bytes = value.toByteArray(); + if (bytes.length > 1 && bytes[0] == 0) // drop the sign byte so the magnitude round-trips + { + byte[] trimmed = new byte[bytes.length - 1]; + System.arraycopy(bytes, 1, trimmed, 0, trimmed.length); + bytes = trimmed; + } + return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); + } + +} diff --git a/src/test/java/com/atomgraph/linkeddatahub/server/util/URLValidatorTest.java b/src/test/java/com/atomgraph/linkeddatahub/server/util/URLValidatorTest.java index b72bc4b65..735e23c09 100644 --- a/src/test/java/com/atomgraph/linkeddatahub/server/util/URLValidatorTest.java +++ b/src/test/java/com/atomgraph/linkeddatahub/server/util/URLValidatorTest.java @@ -39,6 +39,21 @@ public void testNullURI() assertThrows(IllegalArgumentException.class, () -> new URLValidator(false).validate(null)); } + @Test + public void testLoopbackAllowedForSelfDereferencing() + { + // loopback is intentionally allowed even with SSRF protection on: LDH dereferences its own + // documents/WebIDs on the same origin (loopback in local/test deployments); the backend is site-local (blocked) + new URLValidator(false).validate(URI.create("http://127.0.0.1:3030/ds")); + new URLValidator(false).validate(URI.create("http://localhost:3030/ds")); + } + + @Test + public void testAnyLocalBlocked() + { + assertThrows(InternalURLException.class, () -> new URLValidator(false).validate(URI.create("http://0.0.0.0:8080/test"))); + } + @Test public void testLinkLocalIPv4Blocked() { @@ -97,4 +112,11 @@ public void testAllowInternalLinkLocalAllowed() // When allowInternal=true, link-local addresses should pass through without exception new URLValidator(true).validate(URI.create("http://169.254.1.1:8080/test")); } + + @Test + public void testAllowInternalLoopbackAllowed() + { + // When allowInternal=true, loopback addresses should pass through without exception (dev escape hatch) + new URLValidator(true).validate(URI.create("http://127.0.0.1:3030/ds")); + } }