You are viewing a plain text version of this content. The canonical link for it is here.
Posted to oak-commits@jackrabbit.apache.org by re...@apache.org on 2026/08/25 08:15:08 UTC

(jackrabbit-oak) branch xbug_60 created (now be694a0a03)

This is an automated email from the ASF dual-hosted git repository.

reschke pushed a change to branch xbug_60
in repository https://gitbox.apache.org/repos/asf/jackrabbit-oak.git


      at be694a0a03 bug_60

This branch includes the following new commits:

     new be694a0a03 bug_60

The 1 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails.  The revisions
listed as "add" were already present in the repository and have only
been added to this reference.



(jackrabbit-oak) 01/01: bug_60

Posted by re...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

reschke pushed a commit to branch xbug_60
in repository https://gitbox.apache.org/repos/asf/jackrabbit-oak.git

commit be694a0a03e85c6dd643068ba5ef0e0bc8a1301d
Author: Julian Reschke <re...@apache.org>
AuthorDate: Tue Aug 25 09:14:56 2026 +0100

    bug_60
---
 .../jackrabbit/oak/spi/blob/AbstractBlobStore.java |  36 +++++--
 .../oak/spi/blob/MemoryBlobStoreTest.java          |  18 ++++
 .../org/apache/jackrabbit/oak/commons/IOUtils.java |   6 +-
 .../apache/jackrabbit/oak/commons/IOUtilsTest.java |  12 +++
 .../permission/CompiledPermissionImpl.java         |  28 +++--
 .../permission/MoveAwarePermissionValidator.java   |   2 +
 .../permission/PermissionValidator.java            |  22 +++-
 .../permission/CompiledPermissionImplTest.java     |  79 ++++++++++++++
 .../HiddenChildPermissionValidatorTest.java        | 119 +++++++++++++++++++++
 .../org/apache/jackrabbit/oak/http/OakServlet.java |   9 ++
 .../org/apache/jackrabbit/oak/run/HttpServer.java  |  18 +++-
 .../apache/jackrabbit/oak/run/ServerCommand.java   |   3 +-
 12 files changed, 326 insertions(+), 26 deletions(-)

diff --git a/oak-blob/src/main/java/org/apache/jackrabbit/oak/spi/blob/AbstractBlobStore.java b/oak-blob/src/main/java/org/apache/jackrabbit/oak/spi/blob/AbstractBlobStore.java
index b042175e0f..27f5595763 100644
--- a/oak-blob/src/main/java/org/apache/jackrabbit/oak/spi/blob/AbstractBlobStore.java
+++ b/oak-blob/src/main/java/org/apache/jackrabbit/oak/spi/blob/AbstractBlobStore.java
@@ -444,6 +444,26 @@ public abstract class AbstractBlobStore implements GarbageCollectableBlobStore,
         }
     }
 
+    /**
+     * Read a length field from a blob id stream. The length is validated
+     * against the number of remaining bytes in the stream, so that a corrupt
+     * or crafted blob id can not declare a negative length or a length larger
+     * than the stream itself (which could otherwise result in an excessively
+     * large allocation).
+     *
+     * @param idStream the blob id stream
+     * @return the length
+     * @throws IOException if the length is negative or larger than the number
+     *             of remaining bytes in the stream
+     */
+    private static int readLength(ByteArrayInputStream idStream) throws IOException {
+        int len = IOUtils.readVarInt(idStream);
+        if (len < 0 || len > idStream.available()) {
+            throw new IOException("Invalid length in blob id: " + len);
+        }
+        return len;
+    }
+
     @Override
     public int readBlob(String blobId, long pos, byte[] buff, int off,
             int length) throws IOException {
@@ -458,7 +478,7 @@ public abstract class AbstractBlobStore implements GarbageCollectableBlobStore,
                 statsCollector.downloadCompleted(blobId);
                 return -1;
             } else if (type == TYPE_DATA) {
-                int len = IOUtils.readVarInt(idStream);
+                int len = readLength(idStream);
                 if (pos < len) {
                     IOUtils.skipFully(idStream, (int) pos);
                     len -= pos;
@@ -480,7 +500,7 @@ public abstract class AbstractBlobStore implements GarbageCollectableBlobStore,
                     // block length (ignored)
                     IOUtils.readVarLong(idStream);
                 }
-                byte[] digest = new byte[IOUtils.readVarInt(idStream)];
+                byte[] digest = new byte[readLength(idStream)];
                 IOUtils.readFully(idStream, digest, 0, digest.length);
                 if (pos >= totalLength) {
                     pos -= totalLength;
@@ -544,7 +564,7 @@ public abstract class AbstractBlobStore implements GarbageCollectableBlobStore,
                 break;
             }
             if (type == TYPE_DATA) {
-                int len = IOUtils.readVarInt(idStream);
+                int len = readLength(idStream);
                 IOUtils.skipFully(idStream, len);
                 totalLength += len;
             } else if (type == TYPE_HASH) {
@@ -557,7 +577,7 @@ public abstract class AbstractBlobStore implements GarbageCollectableBlobStore,
                     // block length (ignored)
                     IOUtils.readVarLong(idStream);
                 }
-                int digestLength = IOUtils.readVarInt(idStream);
+                int digestLength = readLength(idStream);
                 IOUtils.skipFully(idStream, digestLength);
             } else {
                 throw new IOException("Datastore id type " + type + " for blob " + blobId);
@@ -582,7 +602,7 @@ public abstract class AbstractBlobStore implements GarbageCollectableBlobStore,
             if (type == -1) {
                 return;
             } else if (type == TYPE_DATA) {
-                int len = IOUtils.readVarInt(idStream);
+                int len = readLength(idStream);
                 IOUtils.skipFully(idStream, len);
             } else if (type == TYPE_HASH) {
                 int level = IOUtils.readVarInt(idStream);
@@ -594,7 +614,7 @@ public abstract class AbstractBlobStore implements GarbageCollectableBlobStore,
                     // block length (ignored)
                     IOUtils.readVarLong(idStream);
                 }
-                byte[] digest = new byte[IOUtils.readVarInt(idStream)];
+                byte[] digest = new byte[readLength(idStream)];
                 IOUtils.readFully(idStream, digest, 0, digest.length);
                 BlockId id = new BlockId(digest, 0);
                 mark(id);
@@ -727,7 +747,7 @@ public abstract class AbstractBlobStore implements GarbageCollectableBlobStore,
                     if (type == -1) {
                         streamsStack.pop();
                     } else if (type == TYPE_DATA) {
-                        int len = IOUtils.readVarInt(idStream);
+                        int len = readLength(idStream);
                         IOUtils.skipFully(idStream, len);
                     } else if (type == TYPE_HASH) {
                         int level = IOUtils.readVarInt(idStream);
@@ -739,7 +759,7 @@ public abstract class AbstractBlobStore implements GarbageCollectableBlobStore,
                             // block length (ignored)
                             IOUtils.readVarLong(idStream);
                         }
-                        byte[] digest = new byte[IOUtils.readVarInt(idStream)];
+                        byte[] digest = new byte[readLength(idStream)];
                         IOUtils.readFully(idStream, digest, 0, digest.length);
                         if (level > 0) {
                             queue.add(StringUtils.convertBytesToHex(digest));
diff --git a/oak-blob/src/test/java/org/apache/jackrabbit/oak/spi/blob/MemoryBlobStoreTest.java b/oak-blob/src/test/java/org/apache/jackrabbit/oak/spi/blob/MemoryBlobStoreTest.java
index 797082ed8a..8ad25c6a50 100644
--- a/oak-blob/src/test/java/org/apache/jackrabbit/oak/spi/blob/MemoryBlobStoreTest.java
+++ b/oak-blob/src/test/java/org/apache/jackrabbit/oak/spi/blob/MemoryBlobStoreTest.java
@@ -16,6 +16,12 @@
  */
 package org.apache.jackrabbit.oak.spi.blob;
 
+import static org.junit.Assert.fail;
+
+import java.io.IOException;
+
+import org.junit.Test;
+
 /**
  * Tests the MemoryBlobStore implementation.
  */
@@ -28,4 +34,16 @@ public class MemoryBlobStoreTest extends AbstractBlobStoreTest {
         ((MemoryBlobStore) store).setBlockSizeMin(48);
     }    
 
+    @Test
+    public void malformedBlobIdLength() {
+        // a hash entry whose digest length field decodes to -1:
+        // it must be rejected, and must not be used for an allocation
+        try {
+            store.getBlobLength("010000ffffffff0f");
+            fail();
+        } catch (IOException e) {
+            // expected
+        }
+    }
+
 }
diff --git a/oak-commons/src/main/java/org/apache/jackrabbit/oak/commons/IOUtils.java b/oak-commons/src/main/java/org/apache/jackrabbit/oak/commons/IOUtils.java
index 9956ca9ae6..33c29980e3 100644
--- a/oak-commons/src/main/java/org/apache/jackrabbit/oak/commons/IOUtils.java
+++ b/oak-commons/src/main/java/org/apache/jackrabbit/oak/commons/IOUtils.java
@@ -153,10 +153,14 @@ public final class IOUtils {
      *
      * @param in the data input stream
      * @return the bytes
-     * @throws IOException if an IO exception occurred while reading from the stream.
+     * @throws IOException if an IO exception occurred while reading from the
+     *             stream, or if the stream declares a negative length.
      */
     public static byte[] readBytes(InputStream in) throws IOException {
         int len = readVarInt(in);
+        if (len < 0) {
+            throw new IOException("Negative length: " + len);
+        }
         byte[] data = new byte[len];
         for (int pos = 0; pos < len;) {
             int l = in.read(data, pos, data.length - pos);
diff --git a/oak-commons/src/test/java/org/apache/jackrabbit/oak/commons/IOUtilsTest.java b/oak-commons/src/test/java/org/apache/jackrabbit/oak/commons/IOUtilsTest.java
index 0a38f06485..9b50bfed97 100644
--- a/oak-commons/src/test/java/org/apache/jackrabbit/oak/commons/IOUtilsTest.java
+++ b/oak-commons/src/test/java/org/apache/jackrabbit/oak/commons/IOUtilsTest.java
@@ -222,6 +222,18 @@ public class IOUtilsTest extends TestCase {
         }
     }
 
+    public void testBytesNegativeLength() {
+        // a stream that declares a negative length must be rejected,
+        // and must not cause an allocation attempt
+        byte[] negativeLength = {(byte) 0xff, (byte) 0xff, (byte) 0xff, (byte) 0xff, 0x0f};
+        try {
+            IOUtils.readBytes(new ByteArrayInputStream(negativeLength));
+            fail();
+        } catch (IOException e) {
+            // expected
+        }
+    }
+
     public void testVarInt() throws IOException {
         testVarInt(0, 1);
         testVarInt(0x7f, 1);
diff --git a/oak-core/src/main/java/org/apache/jackrabbit/oak/security/authorization/permission/CompiledPermissionImpl.java b/oak-core/src/main/java/org/apache/jackrabbit/oak/security/authorization/permission/CompiledPermissionImpl.java
index eab7f64338..ced82910a0 100644
--- a/oak-core/src/main/java/org/apache/jackrabbit/oak/security/authorization/permission/CompiledPermissionImpl.java
+++ b/oak-core/src/main/java/org/apache/jackrabbit/oak/security/authorization/permission/CompiledPermissionImpl.java
@@ -31,7 +31,6 @@ import org.apache.jackrabbit.commons.iterator.AbstractLazyIterator;
 import org.apache.jackrabbit.oak.api.PropertyState;
 import org.apache.jackrabbit.oak.api.Root;
 import org.apache.jackrabbit.oak.api.Tree;
-import org.apache.jackrabbit.oak.commons.PathUtils;
 import org.apache.jackrabbit.oak.commons.collections.IteratorUtils;
 import org.apache.jackrabbit.oak.namepath.NamePathMapper;
 import org.apache.jackrabbit.oak.plugins.tree.TreeType;
@@ -217,8 +216,17 @@ final class CompiledPermissionImpl implements CompiledPermissions, PermissionCon
 
     @NotNull
     private TreePermission buildVersionDelegatee(@NotNull Tree versionableTree) {
-        while (!versionableTree.exists()) {
-            versionableTree = versionableTree.getParent();
+        if (!versionableTree.exists()) {
+            // The versionable node no longer exists in this workspace. Its
+            // permission entries were removed from the permission store
+            // together with the node (see PermissionHook), so no ACL setup is
+            // left that could be evaluated for its version history. Walking up
+            // to the nearest existing ancestor would resurrect the ancestor's
+            // (potentially more permissive) allow entries while the removed
+            // node's own deny entries are gone; denying access is the only
+            // sound option (consistent with getPrivileges, which already
+            // returns no privileges for non-existing versionable nodes).
+            return EMPTY;
         }
         if (versionableTree.isRoot()) {
             return createRootPermission(versionableTree);
@@ -265,13 +273,13 @@ final class CompiledPermissionImpl implements CompiledPermissions, PermissionCon
                 if (evalTree.exists()) {
                     return internalIsGranted(evalTree, property, permissions);
                 } else {
-                    // the versionable node does not exist (anymore) in this workspace
-                    // use best effort calculation based on the item path.
-                    String path = evalTree.getPath();
-                    if (property != null) {
-                        path = PathUtils.concat(path, property.getName());
-                    }
-                    return isGranted(path, property != null, permissions);
+                    // The versionable node does not exist (anymore) in this
+                    // workspace and its permission entries were removed with it
+                    // (see PermissionHook). A best-effort evaluation by path
+                    // would only see the surviving ancestors' entries -> deny
+                    // access, consistent with getTreePermission (see
+                    // buildVersionDelegatee) and getPrivileges.
+                    return false;
                 }
             case INTERNAL:
                 return false;
diff --git a/oak-core/src/main/java/org/apache/jackrabbit/oak/security/authorization/permission/MoveAwarePermissionValidator.java b/oak-core/src/main/java/org/apache/jackrabbit/oak/security/authorization/permission/MoveAwarePermissionValidator.java
index 3c94b6fb3d..364681b980 100644
--- a/oak-core/src/main/java/org/apache/jackrabbit/oak/security/authorization/permission/MoveAwarePermissionValidator.java
+++ b/oak-core/src/main/java/org/apache/jackrabbit/oak/security/authorization/permission/MoveAwarePermissionValidator.java
@@ -86,6 +86,7 @@ public class MoveAwarePermissionValidator extends PermissionValidator {
     //----------------------------------------------------------< Validator >---
     @Override
     public Validator childNodeAdded(String name, NodeState after) throws CommitFailedException {
+        checkNotHidden(name);
         if (moveCtx.processAdd(getParentAfter(), name, this)) {
             return null;
         } else {
@@ -95,6 +96,7 @@ public class MoveAwarePermissionValidator extends PermissionValidator {
 
     @Override
     public Validator childNodeDeleted(String name, NodeState before) throws CommitFailedException {
+        checkNotHidden(name);
         if (moveCtx.processDelete(getParentBefore(), name, this)) {
             return null;
         } else {
diff --git a/oak-core/src/main/java/org/apache/jackrabbit/oak/security/authorization/permission/PermissionValidator.java b/oak-core/src/main/java/org/apache/jackrabbit/oak/security/authorization/permission/PermissionValidator.java
index 817e00d844..68e692faac 100644
--- a/oak-core/src/main/java/org/apache/jackrabbit/oak/security/authorization/permission/PermissionValidator.java
+++ b/oak-core/src/main/java/org/apache/jackrabbit/oak/security/authorization/permission/PermissionValidator.java
@@ -134,6 +134,7 @@ class PermissionValidator extends DefaultValidator {
 
     @Override
     public Validator childNodeAdded(String name, NodeState after) throws CommitFailedException {
+        checkNotHidden(name);
         Tree child = requireNonNull(parentAfter.getChild(name));
         if (isVersionstorageTree(child)) {
             child = getVersionHistoryTree(child);
@@ -148,7 +149,8 @@ class PermissionValidator extends DefaultValidator {
 
 
     @Override
-    public Validator childNodeChanged(String name, NodeState before, NodeState after) {
+    public Validator childNodeChanged(String name, NodeState before, NodeState after) throws CommitFailedException {
+        checkNotHidden(name);
         Tree childBefore = parentBefore.getChild(name);
         Tree childAfter = parentAfter.getChild(name);
         return nextValidator(childBefore, childAfter, parentPermission.getChildPermission(name, before));
@@ -156,6 +158,7 @@ class PermissionValidator extends DefaultValidator {
 
     @Override
     public Validator childNodeDeleted(String name, NodeState before) throws CommitFailedException {
+        checkNotHidden(name);
         Tree child = parentBefore.getChild(name);
         if (isVersionstorageTree(child)) {
             provider.getAccessMonitor().accessViolation();
@@ -349,6 +352,23 @@ class PermissionValidator extends DefaultValidator {
         return tree.getPath().contains(IndexConstants.INDEX_DEFINITIONS_NAME);
     }
 
+    /**
+     * A diff submitted by a content session must never cross a hidden
+     * (":"-prefixed) boundary: hidden trees are internal bookkeeping (index
+     * storage, checkpoints, conflict markers) and are maintained exclusively
+     * by commit editors inside the hook chain, which are not subject to this
+     * validator. Note that below the root level hidden child nodes never
+     * reach this validator as the descent is wrapped in a node-hiding
+     * {@code VisibleValidator}; this check closes the top-level gap and any
+     * future unwrapped resolution.
+     */
+    void checkNotHidden(@NotNull String name) throws CommitFailedException {
+        if (NodeStateUtils.isHidden(name)) {
+            provider.getAccessMonitor().accessViolation();
+            throw new CommitFailedException(ACCESS, 23, "Attempt to create, modify or remove the hidden child '" + name + '\'');
+        }
+    }
+
     void checkIsGranted(boolean isGranted) throws CommitFailedException {
         if (!isGranted) {
             provider.getAccessMonitor().accessViolation();
diff --git a/oak-core/src/test/java/org/apache/jackrabbit/oak/security/authorization/permission/CompiledPermissionImplTest.java b/oak-core/src/test/java/org/apache/jackrabbit/oak/security/authorization/permission/CompiledPermissionImplTest.java
index ccf259cd7d..d3c61bd3db 100644
--- a/oak-core/src/test/java/org/apache/jackrabbit/oak/security/authorization/permission/CompiledPermissionImplTest.java
+++ b/oak-core/src/test/java/org/apache/jackrabbit/oak/security/authorization/permission/CompiledPermissionImplTest.java
@@ -443,6 +443,68 @@ public class CompiledPermissionImplTest extends AbstractSecurityTest {
         assertTrue(tp instanceof VersionTreePermission);
     }
 
+    @Test
+    public void testVersionableTreeRemovedAncestorReadable() throws Exception {
+        // the surviving PARENT carries an ordinary inheritable allow-read,
+        // while the versionable node is removed (its permission entries are
+        // purged from the permission store together with the node).
+        grant(TEST_PATH, EveryonePrincipal.getInstance(), JCR_READ);
+        root.commit();
+
+        Tree version = createVersions(SUBTREE_PATH).getChild("1.2");
+        assertEquals(NT_VERSION, TreeUtil.getPrimaryTypeName(version));
+        Tree frozenNode = version.getChild(JCR_FROZENNODE);
+        PropertyState property = frozenNode.getProperty(JCR_PRIMARYTYPE);
+
+        root.getTree(SUBTREE_PATH).remove();
+        root.commit();
+
+        CompiledPermissionImpl cp = createForTestSession(ConfigurationParameters.EMPTY);
+
+        // the version history of the removed node must not become readable
+        // through the surviving ancestor's allow entries
+        TreePermission tp = createTreePermission(cp, version.getPath());
+        assertTrue(tp instanceof VersionTreePermission);
+        assertFalse(tp.canRead());
+        assertFalse(tp.canReadAll());
+        assertFalse(tp.canReadProperties());
+        assertFalse(tp.isGranted(Permissions.READ_NODE));
+
+        TreePermission frozenTp = createTreePermission(cp, frozenNode.getPath());
+        assertTrue(frozenTp instanceof VersionTreePermission);
+        assertFalse(frozenTp.canRead());
+        assertFalse(frozenTp.canRead(property));
+
+        // isGranted(Tree, PropertyState, long) must agree with the tree
+        // permission evaluation
+        assertFalse(cp.isGranted(version, null, Permissions.READ));
+        assertFalse(cp.isGranted(frozenNode, null, Permissions.READ));
+        assertFalse(cp.isGranted(frozenNode, property, Permissions.READ));
+
+        // privileges were already denied for non-existing versionable nodes
+        assertTrue(cp.getPrivileges(version).isEmpty());
+        assertFalse(cp.hasPrivileges(version, JCR_READ));
+    }
+
+    @Test
+    public void testVersionableTreeExistsAncestorReadable() throws Exception {
+        // control: as long as the versionable node exists, the inherited
+        // allow-read from the ancestor keeps the version history readable
+        grant(TEST_PATH, EveryonePrincipal.getInstance(), JCR_READ);
+        root.commit();
+
+        Tree version = createVersions(SUBTREE_PATH).getChild("1.2");
+        assertEquals(NT_VERSION, TreeUtil.getPrimaryTypeName(version));
+
+        CompiledPermissionImpl cp = createForTestSession(ConfigurationParameters.EMPTY);
+
+        TreePermission tp = createTreePermission(cp, version.getPath());
+        assertTrue(tp instanceof VersionTreePermission);
+        assertTrue(tp.canRead());
+        assertTrue(cp.isGranted(version, null, Permissions.READ));
+        assertTrue(cp.hasPrivileges(version, JCR_READ));
+    }
+
     @Test
     public void testCacheBuildOnDemand() throws Exception {
         grant(ACCESS_CONTROLLED_PATH, getTestUser().getPrincipal(), JCR_VERSION_MANAGEMENT);
@@ -601,6 +663,23 @@ public class CompiledPermissionImplTest extends AbstractSecurityTest {
         assertFalse(tp.isGranted(Permissions.NO_PERMISSION));
     }
 
+    @Test
+    public void testGetTreePermissionForRemovedVersionable() throws Exception {
+        String wspName = adminSession.getWorkspaceName();
+        CompiledPermissionImpl cp = create(root, wspName, Collections.singleton(EveryonePrincipal.getInstance()), mockPermissionStore(root, wspName), ConfigurationParameters.EMPTY);
+
+        Tree removed = when(mock(Tree.class).exists()).thenReturn(false).getMock();
+        setVersionManager(cp, removed);
+
+        Tree t = when(mock(Tree.class).exists()).thenReturn(true).getMock();
+        TreePermission tp = cp.getTreePermission(t, TreeType.VERSION, TreePermission.EMPTY);
+        assertTrue(tp instanceof VersionTreePermission);
+        assertFalse(tp.canRead());
+        assertFalse(tp.canReadProperties());
+        assertFalse(tp.canReadAll());
+        assertFalse(tp.isGranted(Permissions.READ_NODE));
+    }
+
     private static void setVersionManager(@NotNull CompiledPermissionImpl cp, @NotNull Tree t) throws Exception {
         ReadOnlyVersionManager versionManager = createVersionManager(t);
         Field f = CompiledPermissionImpl.class.getDeclaredField("versionManager");
diff --git a/oak-core/src/test/java/org/apache/jackrabbit/oak/security/authorization/permission/HiddenChildPermissionValidatorTest.java b/oak-core/src/test/java/org/apache/jackrabbit/oak/security/authorization/permission/HiddenChildPermissionValidatorTest.java
new file mode 100644
index 0000000000..42e5404297
--- /dev/null
+++ b/oak-core/src/test/java/org/apache/jackrabbit/oak/security/authorization/permission/HiddenChildPermissionValidatorTest.java
@@ -0,0 +1,119 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.jackrabbit.oak.security.authorization.permission;
+
+import org.apache.jackrabbit.oak.AbstractSecurityTest;
+import org.apache.jackrabbit.oak.api.CommitFailedException;
+import org.apache.jackrabbit.oak.api.Tree;
+import org.apache.jackrabbit.oak.commons.PathUtils;
+import org.apache.jackrabbit.oak.security.authorization.ProviderCtx;
+import org.apache.jackrabbit.oak.security.authorization.monitor.AuthorizationMonitor;
+import org.apache.jackrabbit.oak.spi.commit.MoveTracker;
+import org.apache.jackrabbit.oak.spi.security.authorization.AuthorizationConfiguration;
+import org.apache.jackrabbit.oak.spi.security.authorization.permission.PermissionProvider;
+import org.apache.jackrabbit.oak.spi.state.NodeState;
+import org.junit.Test;
+
+import java.security.Principal;
+import java.util.Set;
+
+import static org.apache.jackrabbit.oak.api.CommitFailedException.ACCESS;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * A diff submitted by a content session must never create, modify or remove a
+ * hidden ({@code :}-prefixed) child node: those changes previously resolved to
+ * {@code TreeType.HIDDEN -> TreePermission.ALL} and committed without any
+ * permission evaluation (repository-wide index poisoning).
+ */
+public class HiddenChildPermissionValidatorTest extends AbstractSecurityTest {
+
+    private final AuthorizationMonitor monitor = mock(AuthorizationMonitor.class);
+
+    private PermissionValidator createRootValidator(Set<Principal> principals) {
+        Tree t = root.getTree(PathUtils.ROOT_PATH);
+        NodeState ns = getTreeProvider().asNodeState(t);
+
+        String wspName = root.getContentSession().getWorkspaceName();
+        PermissionProvider pp = getConfig(AuthorizationConfiguration.class).getPermissionProvider(root, wspName, principals);
+
+        ProviderCtx ctx = mock(ProviderCtx.class);
+        when(ctx.getSecurityProvider()).thenReturn(getSecurityProvider());
+        when(ctx.getTreeProvider()).thenReturn(getTreeProvider());
+        when(ctx.getMonitor()).thenReturn(monitor);
+
+        PermissionValidatorProvider pvp = new PermissionValidatorProvider(wspName, principals, new MoveTracker(), ctx);
+        return new PermissionValidator(ns, ns, pp, pvp);
+    }
+
+    private static void assertAccessViolation(CommitFailedException e) {
+        assertTrue(e.isOfType(ACCESS));
+        assertEquals(23, e.getCode());
+    }
+
+    @Test
+    public void testHiddenChildNodeAdded() throws Exception {
+        PermissionValidator validator = createRootValidator(Set.of());
+        try {
+            validator.childNodeAdded(":hidden", mock(NodeState.class));
+            fail("adding a hidden child must be denied");
+        } catch (CommitFailedException e) {
+            assertAccessViolation(e);
+            verify(monitor).accessViolation();
+        }
+    }
+
+    @Test
+    public void testHiddenChildNodeChanged() throws Exception {
+        PermissionValidator validator = createRootValidator(Set.of());
+        try {
+            validator.childNodeChanged(":async", mock(NodeState.class), mock(NodeState.class));
+            fail("changing a hidden child must be denied");
+        } catch (CommitFailedException e) {
+            assertAccessViolation(e);
+            verify(monitor).accessViolation();
+        }
+    }
+
+    @Test
+    public void testHiddenChildNodeDeleted() throws Exception {
+        PermissionValidator validator = createRootValidator(Set.of());
+        try {
+            validator.childNodeDeleted(":async", mock(NodeState.class));
+            fail("removing a hidden child must be denied");
+        } catch (CommitFailedException e) {
+            assertAccessViolation(e);
+            verify(monitor).accessViolation();
+        }
+    }
+
+    @Test
+    public void testHiddenChildDeniedForAdminToo() throws Exception {
+        PermissionValidator validator = createRootValidator(adminSession.getAuthInfo().getPrincipals());
+        try {
+            validator.childNodeAdded(":hidden", mock(NodeState.class));
+            fail("hidden-boundary changes are structural violations, independent of principal");
+        } catch (CommitFailedException e) {
+            assertAccessViolation(e);
+        }
+    }
+}
diff --git a/oak-http/src/main/java/org/apache/jackrabbit/oak/http/OakServlet.java b/oak-http/src/main/java/org/apache/jackrabbit/oak/http/OakServlet.java
index 1e000c21b5..89910539dd 100644
--- a/oak-http/src/main/java/org/apache/jackrabbit/oak/http/OakServlet.java
+++ b/oak-http/src/main/java/org/apache/jackrabbit/oak/http/OakServlet.java
@@ -38,6 +38,7 @@ import org.apache.jackrabbit.oak.api.ContentSession;
 import org.apache.jackrabbit.oak.api.Root;
 import org.apache.jackrabbit.oak.api.Tree;
 import org.apache.jackrabbit.oak.commons.PathUtils;
+import org.apache.jackrabbit.oak.spi.state.NodeStateUtils;
 import org.apache.tika.mime.MediaType;
 
 public class OakServlet extends HttpServlet {
@@ -79,6 +80,14 @@ public class OakServlet extends HttpServlet {
                 // Note that we need to traverse the path in reverse
                 // direction as some parent nodes may be read-protected.
                 String head = request.getPathInfo();
+                // Hidden (":"-prefixed) items are internal to Oak and must not
+                // be addressable over HTTP, neither for reading nor for writing
+                for (String name : PathUtils.elements(head)) {
+                    if (NodeStateUtils.isHidden(name)) {
+                        response.sendError(HttpServletResponse.SC_NOT_FOUND);
+                        return;
+                    }
+                }
                 String tail = "";
                 Tree tree = root.getTree(head);
                 while (!tree.exists()) {
diff --git a/oak-run/src/main/java/org/apache/jackrabbit/oak/run/HttpServer.java b/oak-run/src/main/java/org/apache/jackrabbit/oak/run/HttpServer.java
index 8a08c92e5f..dc55284b4c 100644
--- a/oak-run/src/main/java/org/apache/jackrabbit/oak/run/HttpServer.java
+++ b/oak-run/src/main/java/org/apache/jackrabbit/oak/run/HttpServer.java
@@ -17,6 +17,8 @@
 
 package org.apache.jackrabbit.oak.run;
 
+import java.net.InetSocketAddress;
+import java.net.URI;
 import java.util.Collections;
 import java.util.Map;
 
@@ -40,11 +42,11 @@ class HttpServer {
 
     private final Server server;
 
-    public HttpServer(int port) throws Exception {
-        this(port, Collections.singletonMap(new Oak(), ""));
+    public HttpServer(String uri) throws Exception {
+        this(uri, Collections.singletonMap(new Oak(), ""));
     }
 
-    public HttpServer(int port, Map<Oak, String> oakMap) throws Exception {
+    public HttpServer(String uri, Map<Oak, String> oakMap) throws Exception {
         context = new ServletContextHandler();
         context.setContextPath("/");
 
@@ -52,9 +54,17 @@ class HttpServer {
             addServlets(entry.getKey(), entry.getValue());
         }
 
-        server = new Server(port);
+        // bind exactly the address the operator specified, defaulting to
+        // loopback: this development server exposes Basic-auth login and
+        // full repository write access without TLS and must not silently
+        // listen on all interfaces
+        URI parsed = URI.create(uri);
+        String host = (parsed.getHost() == null) ? "127.0.0.1" : parsed.getHost();
+        int port = (parsed.getPort() == -1) ? 8080 : parsed.getPort();
+        server = new Server(new InetSocketAddress(host, port));
         server.setHandler(context);
         server.start();
+        System.out.println("HTTP server listening on http://" + host + ":" + port + "/");
     }
 
     public void join() throws Exception {
diff --git a/oak-run/src/main/java/org/apache/jackrabbit/oak/run/ServerCommand.java b/oak-run/src/main/java/org/apache/jackrabbit/oak/run/ServerCommand.java
index 80214fa8d6..b34b1324a3 100644
--- a/oak-run/src/main/java/org/apache/jackrabbit/oak/run/ServerCommand.java
+++ b/oak-run/src/main/java/org/apache/jackrabbit/oak/run/ServerCommand.java
@@ -20,7 +20,6 @@ package org.apache.jackrabbit.oak.run;
 import static java.util.Arrays.asList;
 
 import java.io.File;
-import java.net.URI;
 import java.util.Collections;
 import java.util.HashMap;
 import java.util.List;
@@ -52,7 +51,7 @@ class ServerCommand implements Command {
                 m.put(oakFixture.getOak(i), "/node" + i);
             }
         }
-        new HttpServer(URI.create(uri).getPort(), m);
+        new HttpServer(uri, m);
     }
 
     @Override