Skip to content

Commit 29cac29

Browse files
authored
Merge pull request #1661 from lowcoder-org/nodeserver_encrypted_payload
Nodeserver encrypted payload
2 parents 10066c1 + bda2f16 commit 29cac29

File tree

10 files changed

+229
-24
lines changed

10 files changed

+229
-24
lines changed

server/api-service/lowcoder-domain/src/main/java/org/lowcoder/domain/encryption/EncryptionService.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ public interface EncryptionService {
44

55
String encryptString(String plaintext);
66

7+
String encryptStringForNodeServer(String plaintext);
8+
79
String decryptString(String encryptedText);
810

911
String encryptPassword(String plaintext);

server/api-service/lowcoder-domain/src/main/java/org/lowcoder/domain/encryption/EncryptionServiceImpl.java

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import org.lowcoder.sdk.config.CommonConfig;
66
import org.lowcoder.sdk.config.CommonConfig.Encrypt;
77
import org.springframework.beans.factory.annotation.Autowired;
8+
import org.springframework.beans.factory.annotation.Value;
89
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
910
import org.springframework.security.crypto.encrypt.Encryptors;
1011
import org.springframework.security.crypto.encrypt.TextEncryptor;
@@ -14,13 +15,18 @@
1415
public class EncryptionServiceImpl implements EncryptionService {
1516

1617
private final TextEncryptor textEncryptor;
18+
private final TextEncryptor textEncryptorForNodeServer;
1719
private final BCryptPasswordEncoder bCryptPasswordEncoder = new BCryptPasswordEncoder();
1820

1921
@Autowired
20-
public EncryptionServiceImpl(CommonConfig commonConfig) {
22+
public EncryptionServiceImpl(
23+
CommonConfig commonConfig
24+
) {
2125
Encrypt encrypt = commonConfig.getEncrypt();
2226
String saltInHex = Hex.encodeHexString(encrypt.getSalt().getBytes());
2327
this.textEncryptor = Encryptors.text(encrypt.getPassword(), saltInHex);
28+
String saltInHexForNodeServer = Hex.encodeHexString(commonConfig.getJsExecutor().getSalt().getBytes());
29+
this.textEncryptorForNodeServer = Encryptors.text(commonConfig.getJsExecutor().getPassword(), saltInHexForNodeServer);
2430
}
2531

2632
@Override
@@ -30,6 +36,13 @@ public String encryptString(String plaintext) {
3036
}
3137
return textEncryptor.encrypt(plaintext);
3238
}
39+
@Override
40+
public String encryptStringForNodeServer(String plaintext) {
41+
if (StringUtils.isEmpty(plaintext)) {
42+
return plaintext;
43+
}
44+
return textEncryptorForNodeServer.encrypt(plaintext);
45+
}
3346

3447
@Override
3548
public String decryptString(String encryptedText) {

server/api-service/lowcoder-domain/src/main/java/org/lowcoder/domain/plugin/client/DatasourcePluginClient.java

Lines changed: 49 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,12 @@
55
import org.apache.commons.collections4.CollectionUtils;
66
import org.apache.commons.collections4.MapUtils;
77
import org.apache.commons.lang3.StringUtils;
8+
import org.lowcoder.domain.encryption.EncryptionService;
89
import org.lowcoder.domain.plugin.client.dto.DatasourcePluginDefinition;
910
import org.lowcoder.domain.plugin.client.dto.GetPluginDynamicConfigRequestDTO;
1011
import org.lowcoder.infra.js.NodeServerClient;
1112
import org.lowcoder.infra.js.NodeServerHelper;
13+
import org.lowcoder.sdk.config.CommonConfig;
1214
import org.lowcoder.sdk.config.CommonConfigHelper;
1315
import org.lowcoder.sdk.exception.ServerException;
1416
import org.lowcoder.sdk.models.DatasourceTestResult;
@@ -30,6 +32,8 @@
3032

3133
import static org.lowcoder.sdk.constants.GlobalContext.REQUEST;
3234

35+
import com.fasterxml.jackson.databind.ObjectMapper;
36+
3337
@Slf4j
3438
@RequiredArgsConstructor
3539
@Component
@@ -45,13 +49,17 @@ public class DatasourcePluginClient implements NodeServerClient {
4549
.build();
4650

4751
private final CommonConfigHelper commonConfigHelper;
52+
private final CommonConfig commonConfig;
4853
private final NodeServerHelper nodeServerHelper;
54+
private final EncryptionService encryptionService;
4955

5056
private static final String PLUGINS_PATH = "plugins";
5157
private static final String RUN_PLUGIN_QUERY = "runPluginQuery";
5258
private static final String VALIDATE_PLUGIN_DATA_SOURCE_CONFIG = "validatePluginDataSourceConfig";
5359
private static final String GET_PLUGIN_DYNAMIC_CONFIG = "getPluginDynamicConfig";
5460

61+
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
62+
5563
public Mono<List<Object>> getPluginDynamicConfigSafely(List<GetPluginDynamicConfigRequestDTO> getPluginDynamicConfigRequestDTOS) {
5664
return getPluginDynamicConfig(getPluginDynamicConfigRequestDTOS)
5765
.onErrorResume(throwable -> {
@@ -119,21 +127,47 @@ public Flux<DatasourcePluginDefinition> getDatasourcePluginDefinitions() {
119127
@SuppressWarnings("unchecked")
120128
public Mono<QueryExecutionResult> executeQuery(String pluginName, Object queryDsl, List<Map<String, Object>> context, Object datasourceConfig) {
121129
return getAcceptLanguage()
122-
.flatMap(language -> WEB_CLIENT
123-
.post()
124-
.uri(nodeServerHelper.createUri(RUN_PLUGIN_QUERY))
125-
.header(HttpHeaders.ACCEPT_LANGUAGE, language)
126-
.bodyValue(Map.of("pluginName", pluginName, "dsl", queryDsl, "context", context, "dataSourceConfig", datasourceConfig))
127-
.exchangeToMono(response -> {
128-
if (response.statusCode().is2xxSuccessful()) {
129-
return response.bodyToMono(Map.class)
130-
.map(map -> map.get("result"))
131-
.map(QueryExecutionResult::success);
132-
}
133-
return response.bodyToMono(Map.class)
134-
.map(map -> MapUtils.getString(map, "message"))
135-
.map(QueryExecutionResult::errorWithMessage);
136-
}));
130+
.flatMap(language -> {
131+
try {
132+
Map<String, Object> body = Map.of(
133+
"pluginName", pluginName,
134+
"dsl", queryDsl,
135+
"context", context,
136+
"dataSourceConfig", datasourceConfig
137+
);
138+
String json = OBJECT_MAPPER.writeValueAsString(body);
139+
140+
boolean encryptionEnabled = !(commonConfig.getJsExecutor().getPassword().isEmpty() || commonConfig.getJsExecutor().getSalt().isEmpty());
141+
String payload;
142+
WebClient.RequestBodySpec requestSpec = WEB_CLIENT
143+
.post()
144+
.uri(nodeServerHelper.createUri(RUN_PLUGIN_QUERY))
145+
.header(HttpHeaders.ACCEPT_LANGUAGE, language);
146+
147+
if (encryptionEnabled) {
148+
payload = encryptionService.encryptStringForNodeServer(json);
149+
requestSpec = requestSpec.header("X-Encrypted", "true");
150+
} else {
151+
payload = json;
152+
}
153+
154+
return requestSpec
155+
.bodyValue(payload)
156+
.exchangeToMono(response -> {
157+
if (response.statusCode().is2xxSuccessful()) {
158+
return response.bodyToMono(Map.class)
159+
.map(map -> map.get("result"))
160+
.map(QueryExecutionResult::success);
161+
}
162+
return response.bodyToMono(Map.class)
163+
.map(map -> MapUtils.getString(map, "message"))
164+
.map(QueryExecutionResult::errorWithMessage);
165+
});
166+
} catch (Exception e) {
167+
log.error("Encryption error", e);
168+
return Mono.error(new ServerException("Encryption error"));
169+
}
170+
});
137171
}
138172

139173
@SuppressWarnings("unchecked")
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
package org.lowcoder.domain.encryption;
2+
3+
import org.junit.jupiter.api.BeforeEach;
4+
import org.junit.jupiter.api.Test;
5+
import org.lowcoder.sdk.config.CommonConfig;
6+
import org.lowcoder.sdk.config.CommonConfig.Encrypt;
7+
import org.lowcoder.sdk.config.CommonConfig.JsExecutor;
8+
import org.springframework.security.crypto.encrypt.Encryptors;
9+
import org.springframework.security.crypto.encrypt.TextEncryptor;
10+
11+
import static org.junit.jupiter.api.Assertions.*;
12+
import static org.mockito.Mockito.*;
13+
14+
class EncryptionServiceImplTest {
15+
16+
private EncryptionServiceImpl encryptionService;
17+
private TextEncryptor nodeServerEncryptor;
18+
private String nodePassword = "nodePassword";
19+
private String nodeSalt = "nodeSalt";
20+
21+
@BeforeEach
22+
void setUp() {
23+
// Mock CommonConfig and its nested classes
24+
Encrypt encrypt = mock(Encrypt.class);
25+
when(encrypt.getPassword()).thenReturn("testPassword");
26+
when(encrypt.getSalt()).thenReturn("testSalt");
27+
28+
JsExecutor jsExecutor = mock(JsExecutor.class);
29+
when(jsExecutor.getPassword()).thenReturn(nodePassword);
30+
when(jsExecutor.getSalt()).thenReturn(nodeSalt);
31+
32+
CommonConfig commonConfig = mock(CommonConfig.class);
33+
when(commonConfig.getEncrypt()).thenReturn(encrypt);
34+
when(commonConfig.getJsExecutor()).thenReturn(jsExecutor);
35+
36+
encryptionService = new EncryptionServiceImpl(commonConfig);
37+
38+
// For direct comparison in test
39+
String saltInHexForNodeServer = org.apache.commons.codec.binary.Hex.encodeHexString(nodeSalt.getBytes());
40+
nodeServerEncryptor = Encryptors.text(nodePassword, saltInHexForNodeServer);
41+
}
42+
43+
@Test
44+
void testEncryptStringForNodeServer_NullInput() {
45+
assertNull(encryptionService.encryptStringForNodeServer(null));
46+
}
47+
48+
@Test
49+
void testEncryptStringForNodeServer_EmptyInput() {
50+
assertEquals("", encryptionService.encryptStringForNodeServer(""));
51+
}
52+
53+
@Test
54+
void testEncryptStringForNodeServer_EncryptsAndDecryptsCorrectly() {
55+
String plain = "node secret";
56+
String encrypted = encryptionService.encryptStringForNodeServer(plain);
57+
assertNotNull(encrypted);
58+
assertNotEquals(plain, encrypted);
59+
60+
// Decrypt using the same encryptor to verify correctness
61+
String decrypted = nodeServerEncryptor.decrypt(encrypted);
62+
assertEquals(plain, decrypted);
63+
}
64+
65+
@Test
66+
void testEncryptStringForNodeServer_DifferentInputsProduceDifferentOutputs() {
67+
String encrypted1 = encryptionService.encryptStringForNodeServer("abc");
68+
String encrypted2 = encryptionService.encryptStringForNodeServer("def");
69+
assertNotEquals(encrypted1, encrypted2);
70+
}
71+
72+
@Test
73+
void testEncryptStringForNodeServer_SameInputProducesDifferentOutputs() {
74+
String input = "repeat";
75+
String encrypted1 = encryptionService.encryptStringForNodeServer(input);
76+
String encrypted2 = encryptionService.encryptStringForNodeServer(input);
77+
// Spring's Encryptors.text uses random IV, so outputs should differ
78+
assertNotEquals(encrypted1, encrypted2);
79+
}
80+
}

server/api-service/lowcoder-sdk/src/main/java/org/lowcoder/sdk/config/CommonConfig.java

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,9 @@ public long getMaxAgeInSeconds() {
147147
@Data
148148
public static class JsExecutor {
149149
private String host;
150+
private String password;
151+
private String salt;
152+
private boolean isEncrypted;
150153
}
151154

152155
@Data

server/api-service/lowcoder-server/src/main/resources/application-debug.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@ common:
3737
cookie-name: LOWCODER_DEBUG_TOKEN
3838
js-executor:
3939
host: "http://127.0.0.1:6060"
40+
password: ${LOWCODER_NODE_SERVICE_SECRET:}
41+
salt: ${LOWCODER_NODE_SERVICE_SECRET_SALT:}
4042
workspace:
4143
mode: ${LOWCODER_WORKSPACE_MODE:SAAS}
4244
plugin-dirs:

server/api-service/lowcoder-server/src/main/resources/application.yaml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,8 @@ common:
7474
corsAllowedDomainString: ${LOWCODER_CORS_DOMAINS:*}
7575
js-executor:
7676
host: ${LOWCODER_NODE_SERVICE_URL:http://127.0.0.1:6060}
77+
password: ${LOWCODER_NODE_SERVICE_SECRET:}
78+
salt: ${LOWCODER_NODE_SERVICE_SECRET_SALT:}
7779
max-query-request-size: ${LOWCODER_MAX_REQUEST_SIZE:20m}
7880
max-query-response-size: ${LOWCODER_MAX_REQUEST_SIZE:20m}
7981
max-upload-size: ${LOWCODER_MAX_REQUEST_SIZE:20m}
@@ -129,4 +131,4 @@ management:
129131
redis:
130132
enabled: true
131133
diskspace:
132-
enabled: false
134+
enabled: false

server/node-service/src/controllers/plugins.ts

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,23 @@ import { Request, Response } from "express";
33
import _ from "lodash";
44
import { Config } from "lowcoder-sdk/dataSource";
55
import * as pluginServices from "../services/plugin";
6+
// Add import for decryption utility
7+
import { decryptString } from "../utils/encryption"; // <-- implement this utility as needed
8+
9+
async function getDecryptedBody(req: Request): Promise<any> {
10+
if (req.headers["x-encrypted"]) {
11+
// Assume body is a raw encrypted string, decrypt and parse as JSON
12+
const encrypted = typeof req.body === "string" ? req.body : req.body?.toString?.();
13+
if (!encrypted) throw badRequest("Missing encrypted body");
14+
const decrypted = await decryptString(encrypted);
15+
try {
16+
return JSON.parse(decrypted);
17+
} catch (e) {
18+
throw badRequest("Failed to parse decrypted body as JSON");
19+
}
20+
}
21+
return req.body;
22+
}
623

724
export async function listPlugins(req: Request, res: Response) {
825
let ids = req.query["id"] || [];
@@ -15,12 +32,10 @@ export async function listPlugins(req: Request, res: Response) {
1532
}
1633

1734
export async function runPluginQuery(req: Request, res: Response) {
18-
const { pluginName, dsl, context, dataSourceConfig } = req.body;
35+
const body = await getDecryptedBody(req);
36+
const { pluginName, dsl, context, dataSourceConfig } = body;
1937
const ctx = pluginServices.getPluginContext(req);
2038

21-
22-
// console.log("pluginName: ", pluginName, "dsl: ", dsl, "context: ", context, "dataSourceConfig: ", dataSourceConfig, "ctx: ", ctx);
23-
2439
const result = await pluginServices.runPluginQuery(
2540
pluginName,
2641
dsl,
@@ -32,7 +47,8 @@ export async function runPluginQuery(req: Request, res: Response) {
3247
}
3348

3449
export async function validatePluginDataSourceConfig(req: Request, res: Response) {
35-
const { pluginName, dataSourceConfig } = req.body;
50+
const body = await getDecryptedBody(req);
51+
const { pluginName, dataSourceConfig } = body;
3652
const ctx = pluginServices.getPluginContext(req);
3753
const result = await pluginServices.validatePluginDataSourceConfig(
3854
pluginName,
@@ -50,10 +66,11 @@ type GetDynamicDefReqBody = {
5066

5167
export async function getDynamicDef(req: Request, res: Response) {
5268
const ctx = pluginServices.getPluginContext(req);
53-
if (!Array.isArray(req.body)) {
69+
const body = await getDecryptedBody(req);
70+
if (!Array.isArray(body)) {
5471
throw badRequest("request body is not a valid array");
5572
}
56-
const fields = req.body as GetDynamicDefReqBody;
73+
const fields = body as GetDynamicDefReqBody;
5774
const result: Config[] = [];
5875
for (const item of fields) {
5976
const def = await pluginServices.getDynamicConfigDef(

server/node-service/src/server.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { collectDefaultMetrics } from "prom-client";
99
import apiRouter from "./routes/apiRouter";
1010
import systemRouter from "./routes/systemRouter";
1111
import cors, { CorsOptions } from "cors";
12+
import bodyParser from "body-parser";
1213
collectDefaultMetrics();
1314

1415
const prefix = "/node-service";
@@ -32,6 +33,15 @@ router.use(morgan("dev"));
3233
/** Parse the request */
3334
router.use(express.urlencoded({ extended: false }));
3435

36+
/** Custom middleware: use raw body for encrypted requests */
37+
router.use((req, res, next) => {
38+
if (req.headers["x-encrypted"]) {
39+
bodyParser.text({ type: "*/*" })(req, res, next);
40+
} else {
41+
bodyParser.json()(req, res, next);
42+
}
43+
});
44+
3545
/** Takes care of JSON data */
3646
router.use(
3747
express.json({

0 commit comments

Comments
 (0)