Skip to content

Commit a28b5da

Browse files
committed
fix: enforce per-database access in HTTP command handler
Security fix reported
1 parent 3028256 commit a28b5da

5 files changed

Lines changed: 276 additions & 3 deletions

File tree

server/src/main/java/com/arcadedb/server/http/handler/DatabaseAbstractHandler.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,10 @@ public ExecutionResponse execute(final HttpServerExchange exchange, final Server
6363
if (databaseName.isEmpty())
6464
return new ExecutionResponse(400, "{ \"error\" : \"Database parameter is null\"}");
6565

66+
if (user != null && !user.canAccessToDatabase(databaseName.getFirst()))
67+
throw new SecurityException(
68+
"User '" + user.getName() + "' is not allowed to access database '" + databaseName.getFirst() + "'");
69+
6670
database = httpServer.getServer().getDatabase(databaseName.getFirst(), false, false);
6771

6872
current = DatabaseContext.INSTANCE.getContextIfExists(database.getDatabasePath());

server/src/main/java/com/arcadedb/server/security/ServerSecurityDatabaseUser.java

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,11 +40,18 @@ public class ServerSecurityDatabaseUser implements SecurityDatabaseUser {
4040
private long resultSetLimit = -1;
4141
private long readTimeout = -1;
4242
private final boolean[] databaseAccessMap = new boolean[DATABASE_ACCESS.values().length];
43+
private final boolean denyAll;
4344

4445
public ServerSecurityDatabaseUser(final String databaseName, final String userName, final String[] groups) {
46+
this(databaseName, userName, groups, false);
47+
}
48+
49+
public ServerSecurityDatabaseUser(final String databaseName, final String userName, final String[] groups,
50+
final boolean denyAll) {
4551
this.databaseName = databaseName;
4652
this.userName = userName;
4753
this.groups = groups;
54+
this.denyAll = denyAll;
4855
}
4956

5057
public String[] getGroups() {
@@ -77,11 +84,16 @@ public String getDatabaseName() {
7784

7885
@Override
7986
public boolean requestAccessOnDatabase(final DATABASE_ACCESS access) {
87+
if (denyAll)
88+
return false;
8089
return databaseAccessMap[access.ordinal()];
8190
}
8291

8392
@Override
8493
public boolean requestAccessOnFile(final int fileId, final ACCESS access) {
94+
if (denyAll)
95+
return false;
96+
8597
final boolean[][] currentMap = fileAccessMap;
8698
if (currentMap == null)
8799
return true;

server/src/main/java/com/arcadedb/server/security/ServerSecurityUser.java

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -92,8 +92,9 @@ else if (userDatabases.has(SecurityManager.ANY))
9292
}
9393

9494
if (dbu == null)
95-
// USER HAS NO ACCESS TO THE DATABASE, RETURN A USER WITH NO AX
96-
dbu = new ServerSecurityDatabaseUser(databaseName, name, new String[0]);
95+
// USER HAS NO ACCESS TO THE DATABASE: deny-all sentinel so record/database operations are rejected even if the
96+
// caller bypasses the handler-level canAccessToDatabase gate.
97+
dbu = new ServerSecurityDatabaseUser(databaseName, name, new String[0], true);
9798

9899
final ServerSecurityDatabaseUser prev = databaseCache.putIfAbsent(databaseName, dbu);
99100
if (prev != null)

server/src/test/java/com/arcadedb/server/SelectOrderTest.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -156,7 +156,7 @@ void ridOrdering() {
156156
database.commit();
157157

158158
sqlString = "SELECT from Order WHERE @rid < ? ORDER BY @rid DESC LIMIT 10";
159-
parameter = new RID(database, "#3:1");
159+
parameter = new RID("#3:1");
160160

161161
database.begin();
162162
try (ResultSet resultSet = database.query("sql", sqlString, parameter)) {
Lines changed: 256 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,256 @@
1+
/*
2+
* Copyright © 2021-present Arcade Data Ltd (info@arcadedata.com)
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*
16+
* SPDX-FileCopyrightText: 2021-present Arcade Data Ltd (info@arcadedata.com)
17+
* SPDX-License-Identifier: Apache-2.0
18+
*/
19+
package com.arcadedb.server.security;
20+
21+
import com.arcadedb.serializer.json.JSONArray;
22+
import com.arcadedb.serializer.json.JSONObject;
23+
import com.arcadedb.server.BaseGraphServerTest;
24+
import org.junit.jupiter.api.Test;
25+
26+
import java.net.HttpURLConnection;
27+
import java.net.URL;
28+
import java.util.Base64;
29+
30+
import static org.assertj.core.api.Assertions.assertThat;
31+
32+
/**
33+
* Regression test for the authorization bypass where an authenticated user or API token scoped to a single database could
34+
* still read/write records on any other database on the same server.
35+
*
36+
* Root cause: {@code ServerSecurityUser.getDatabaseUser()} used to return a database user with an uninitialized
37+
* {@code fileAccessMap} when the database was not in the user's allowed list. The null map was then interpreted as
38+
* allow-all in {@code ServerSecurityDatabaseUser.requestAccessOnFile()}, silently bypassing per-database scoping.
39+
*/
40+
class CrossDatabaseAccessIT extends BaseGraphServerTest {
41+
42+
private static final String OTHER_DB = "otherdb";
43+
44+
@Test
45+
void scopedUserCannotReadOtherDatabase() throws Exception {
46+
testEachServer((serverIndex) -> {
47+
createDatabase(serverIndex, OTHER_DB);
48+
try {
49+
createType(serverIndex, OTHER_DB, "Memory");
50+
final String userAuth = createScopedUser(serverIndex, "scoped-reader", "secret-pwd-1");
51+
52+
final int status = commandStatus(serverIndex, OTHER_DB, userAuth, "SELECT FROM Memory");
53+
assertThat(status).as("scoped user must not SELECT on an unrelated database").isGreaterThanOrEqualTo(400);
54+
} finally {
55+
dropUser(serverIndex, "scoped-reader");
56+
dropDatabase(serverIndex, OTHER_DB);
57+
}
58+
});
59+
}
60+
61+
@Test
62+
void scopedUserCannotWriteOtherDatabase() throws Exception {
63+
testEachServer((serverIndex) -> {
64+
createDatabase(serverIndex, OTHER_DB);
65+
try {
66+
createType(serverIndex, OTHER_DB, "Memory");
67+
final String userAuth = createScopedUser(serverIndex, "scoped-writer", "secret-pwd-2");
68+
69+
final int status = commandStatus(serverIndex, OTHER_DB, userAuth,
70+
"INSERT INTO Memory SET leaked = true");
71+
assertThat(status).as("scoped user must not INSERT on an unrelated database").isGreaterThanOrEqualTo(400);
72+
} finally {
73+
dropUser(serverIndex, "scoped-writer");
74+
dropDatabase(serverIndex, OTHER_DB);
75+
}
76+
});
77+
}
78+
79+
@Test
80+
void scopedApiTokenCannotReadOtherDatabase() throws Exception {
81+
testEachServer((serverIndex) -> {
82+
createDatabase(serverIndex, OTHER_DB);
83+
try {
84+
createType(serverIndex, OTHER_DB, "Memory");
85+
final String tokenAuth = "Bearer " + createReadOnlyToken(serverIndex, "scoped-token-read");
86+
87+
final int status = commandStatus(serverIndex, OTHER_DB, tokenAuth, "SELECT FROM Memory");
88+
assertThat(status).as("scoped API token must not SELECT on an unrelated database").isGreaterThanOrEqualTo(400);
89+
} finally {
90+
deleteToken(serverIndex, "scoped-token-read");
91+
dropDatabase(serverIndex, OTHER_DB);
92+
}
93+
});
94+
}
95+
96+
@Test
97+
void scopedApiTokenCannotWriteOtherDatabase() throws Exception {
98+
testEachServer((serverIndex) -> {
99+
createDatabase(serverIndex, OTHER_DB);
100+
try {
101+
createType(serverIndex, OTHER_DB, "Memory");
102+
final String tokenAuth = "Bearer " + createCrudToken(serverIndex, "scoped-token-crud");
103+
104+
final int status = commandStatus(serverIndex, OTHER_DB, tokenAuth,
105+
"INSERT INTO Memory SET leaked = true");
106+
assertThat(status).as("scoped API token must not INSERT on an unrelated database").isGreaterThanOrEqualTo(400);
107+
} finally {
108+
deleteToken(serverIndex, "scoped-token-crud");
109+
dropDatabase(serverIndex, OTHER_DB);
110+
}
111+
});
112+
}
113+
114+
private String createScopedUser(final int serverIndex, final String name, final String password) throws Exception {
115+
if (getServer(serverIndex).getSecurity().existsUser(name))
116+
getServer(serverIndex).getSecurity().dropUser(name);
117+
118+
final JSONObject payload = new JSONObject();
119+
payload.put("name", name);
120+
payload.put("password", password);
121+
payload.put("databases", new JSONObject().put(getDatabaseName(), new JSONArray().put("admin")));
122+
123+
final HttpURLConnection connection = open(serverIndex, "/api/v1/server/users", "POST", basicAuth());
124+
connection.setDoOutput(true);
125+
connection.setRequestProperty("Content-Type", "application/json");
126+
connection.getOutputStream().write(payload.toString().getBytes());
127+
connection.connect();
128+
try {
129+
assertThat(connection.getResponseCode()).isEqualTo(201);
130+
} finally {
131+
connection.disconnect();
132+
}
133+
return "Basic " + Base64.getEncoder().encodeToString((name + ":" + password).getBytes());
134+
}
135+
136+
private void dropUser(final int serverIndex, final String name) {
137+
try {
138+
if (getServer(serverIndex) != null && getServer(serverIndex).getSecurity().existsUser(name))
139+
getServer(serverIndex).getSecurity().dropUser(name);
140+
} catch (final Exception ignore) {
141+
}
142+
}
143+
144+
private String createReadOnlyToken(final int serverIndex, final String name) throws Exception {
145+
final JSONObject permissions = new JSONObject()
146+
.put("types", new JSONObject()
147+
.put("*", new JSONObject().put("access", new JSONArray().put("readRecord"))))
148+
.put("database", new JSONArray());
149+
return createToken(serverIndex, name, permissions);
150+
}
151+
152+
private String createCrudToken(final int serverIndex, final String name) throws Exception {
153+
final JSONObject permissions = new JSONObject()
154+
.put("types", new JSONObject()
155+
.put("*", new JSONObject().put("access",
156+
new JSONArray().put("createRecord").put("readRecord").put("updateRecord").put("deleteRecord"))))
157+
.put("database", new JSONArray());
158+
return createToken(serverIndex, name, permissions);
159+
}
160+
161+
private String createToken(final int serverIndex, final String name, final JSONObject permissions) throws Exception {
162+
final ApiTokenConfiguration tokenConfig = getServer(serverIndex).getSecurity().getApiTokenConfiguration();
163+
tokenConfig.listTokens().stream()
164+
.filter(t -> name.equals(t.getString("name", "")))
165+
.forEach(t -> tokenConfig.deleteToken(t.getString("tokenHash")));
166+
167+
final HttpURLConnection connection = open(serverIndex, "/api/v1/server/api-tokens", "POST", basicAuth());
168+
connection.setDoOutput(true);
169+
connection.setRequestProperty("Content-Type", "application/json");
170+
171+
final JSONObject payload = new JSONObject();
172+
payload.put("name", name);
173+
payload.put("database", getDatabaseName());
174+
payload.put("expiresAt", 0);
175+
payload.put("permissions", permissions);
176+
connection.getOutputStream().write(payload.toString().getBytes());
177+
connection.connect();
178+
179+
try {
180+
assertThat(connection.getResponseCode()).isEqualTo(201);
181+
return new JSONObject(readResponse(connection)).getJSONObject("result").getString("token");
182+
} finally {
183+
connection.disconnect();
184+
}
185+
}
186+
187+
private void deleteToken(final int serverIndex, final String name) {
188+
try {
189+
final ApiTokenConfiguration tokenConfig = getServer(serverIndex).getSecurity().getApiTokenConfiguration();
190+
tokenConfig.listTokens().stream()
191+
.filter(t -> name.equals(t.getString("name", "")))
192+
.forEach(t -> tokenConfig.deleteToken(t.getString("tokenHash")));
193+
} catch (final Exception ignore) {
194+
}
195+
}
196+
197+
private void createDatabase(final int serverIndex, final String database) throws Exception {
198+
final HttpURLConnection connection = open(serverIndex, "/api/v1/server", "POST", basicAuth());
199+
connection.setDoOutput(true);
200+
connection.setRequestProperty("Content-Type", "application/json");
201+
connection.getOutputStream().write(new JSONObject().put("command", "create database " + database).toString().getBytes());
202+
connection.connect();
203+
try {
204+
assertThat(connection.getResponseCode()).isEqualTo(200);
205+
} finally {
206+
connection.disconnect();
207+
}
208+
}
209+
210+
private void dropDatabase(final int serverIndex, final String database) {
211+
try {
212+
final HttpURLConnection connection = open(serverIndex, "/api/v1/server", "POST", basicAuth());
213+
connection.setDoOutput(true);
214+
connection.setRequestProperty("Content-Type", "application/json");
215+
connection.getOutputStream().write(new JSONObject().put("command", "drop database " + database).toString().getBytes());
216+
connection.connect();
217+
try {
218+
connection.getResponseCode();
219+
} finally {
220+
connection.disconnect();
221+
}
222+
} catch (final Exception ignore) {
223+
}
224+
}
225+
226+
private void createType(final int serverIndex, final String database, final String typeName) throws Exception {
227+
final int status = commandStatus(serverIndex, database, basicAuth(), "CREATE DOCUMENT TYPE " + typeName);
228+
assertThat(status).isEqualTo(200);
229+
}
230+
231+
private int commandStatus(final int serverIndex, final String database, final String auth, final String sql) throws Exception {
232+
final HttpURLConnection connection = open(serverIndex, "/api/v1/command/" + database, "POST", auth);
233+
connection.setDoOutput(true);
234+
connection.setRequestProperty("Content-Type", "application/json");
235+
final JSONObject payload = new JSONObject().put("language", "sql").put("command", sql);
236+
connection.getOutputStream().write(payload.toString().getBytes());
237+
connection.connect();
238+
try {
239+
return connection.getResponseCode();
240+
} finally {
241+
connection.disconnect();
242+
}
243+
}
244+
245+
private HttpURLConnection open(final int serverIndex, final String path, final String method, final String auth)
246+
throws Exception {
247+
final HttpURLConnection connection = (HttpURLConnection) new URL("http://127.0.0.1:248" + serverIndex + path).openConnection();
248+
connection.setRequestMethod(method);
249+
connection.setRequestProperty("Authorization", auth);
250+
return connection;
251+
}
252+
253+
private String basicAuth() {
254+
return "Basic " + Base64.getEncoder().encodeToString(("root:" + DEFAULT_PASSWORD_FOR_TESTS).getBytes());
255+
}
256+
}

0 commit comments

Comments
 (0)