diff --git a/server/api-service/lowcoder-domain/src/main/java/org/lowcoder/domain/encryption/EncryptionService.java b/server/api-service/lowcoder-domain/src/main/java/org/lowcoder/domain/encryption/EncryptionService.java index 276f4059d..fdd173ed7 100644 --- a/server/api-service/lowcoder-domain/src/main/java/org/lowcoder/domain/encryption/EncryptionService.java +++ b/server/api-service/lowcoder-domain/src/main/java/org/lowcoder/domain/encryption/EncryptionService.java @@ -4,6 +4,8 @@ public interface EncryptionService { String encryptString(String plaintext); + String encryptStringForNodeServer(String plaintext); + String decryptString(String encryptedText); String encryptPassword(String plaintext); diff --git a/server/api-service/lowcoder-domain/src/main/java/org/lowcoder/domain/encryption/EncryptionServiceImpl.java b/server/api-service/lowcoder-domain/src/main/java/org/lowcoder/domain/encryption/EncryptionServiceImpl.java index 6524682b6..72eeba412 100644 --- a/server/api-service/lowcoder-domain/src/main/java/org/lowcoder/domain/encryption/EncryptionServiceImpl.java +++ b/server/api-service/lowcoder-domain/src/main/java/org/lowcoder/domain/encryption/EncryptionServiceImpl.java @@ -5,6 +5,7 @@ import org.lowcoder.sdk.config.CommonConfig; import org.lowcoder.sdk.config.CommonConfig.Encrypt; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.encrypt.Encryptors; import org.springframework.security.crypto.encrypt.TextEncryptor; @@ -14,13 +15,18 @@ public class EncryptionServiceImpl implements EncryptionService { private final TextEncryptor textEncryptor; + private final TextEncryptor textEncryptorForNodeServer; private final BCryptPasswordEncoder bCryptPasswordEncoder = new BCryptPasswordEncoder(); @Autowired - public EncryptionServiceImpl(CommonConfig commonConfig) { + public EncryptionServiceImpl( + CommonConfig commonConfig + ) { Encrypt encrypt = commonConfig.getEncrypt(); String saltInHex = Hex.encodeHexString(encrypt.getSalt().getBytes()); this.textEncryptor = Encryptors.text(encrypt.getPassword(), saltInHex); + String saltInHexForNodeServer = Hex.encodeHexString(commonConfig.getJsExecutor().getSalt().getBytes()); + this.textEncryptorForNodeServer = Encryptors.text(commonConfig.getJsExecutor().getPassword(), saltInHexForNodeServer); } @Override @@ -30,6 +36,13 @@ public String encryptString(String plaintext) { } return textEncryptor.encrypt(plaintext); } + @Override + public String encryptStringForNodeServer(String plaintext) { + if (StringUtils.isEmpty(plaintext)) { + return plaintext; + } + return textEncryptorForNodeServer.encrypt(plaintext); + } @Override public String decryptString(String encryptedText) { diff --git a/server/api-service/lowcoder-domain/src/main/java/org/lowcoder/domain/plugin/client/DatasourcePluginClient.java b/server/api-service/lowcoder-domain/src/main/java/org/lowcoder/domain/plugin/client/DatasourcePluginClient.java index f2aa878bb..5f0da78c4 100644 --- a/server/api-service/lowcoder-domain/src/main/java/org/lowcoder/domain/plugin/client/DatasourcePluginClient.java +++ b/server/api-service/lowcoder-domain/src/main/java/org/lowcoder/domain/plugin/client/DatasourcePluginClient.java @@ -5,10 +5,12 @@ import org.apache.commons.collections4.CollectionUtils; import org.apache.commons.collections4.MapUtils; import org.apache.commons.lang3.StringUtils; +import org.lowcoder.domain.encryption.EncryptionService; import org.lowcoder.domain.plugin.client.dto.DatasourcePluginDefinition; import org.lowcoder.domain.plugin.client.dto.GetPluginDynamicConfigRequestDTO; import org.lowcoder.infra.js.NodeServerClient; import org.lowcoder.infra.js.NodeServerHelper; +import org.lowcoder.sdk.config.CommonConfig; import org.lowcoder.sdk.config.CommonConfigHelper; import org.lowcoder.sdk.exception.ServerException; import org.lowcoder.sdk.models.DatasourceTestResult; @@ -30,6 +32,8 @@ import static org.lowcoder.sdk.constants.GlobalContext.REQUEST; +import com.fasterxml.jackson.databind.ObjectMapper; + @Slf4j @RequiredArgsConstructor @Component @@ -45,13 +49,17 @@ public class DatasourcePluginClient implements NodeServerClient { .build(); private final CommonConfigHelper commonConfigHelper; + private final CommonConfig commonConfig; private final NodeServerHelper nodeServerHelper; + private final EncryptionService encryptionService; private static final String PLUGINS_PATH = "plugins"; private static final String RUN_PLUGIN_QUERY = "runPluginQuery"; private static final String VALIDATE_PLUGIN_DATA_SOURCE_CONFIG = "validatePluginDataSourceConfig"; private static final String GET_PLUGIN_DYNAMIC_CONFIG = "getPluginDynamicConfig"; + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + public Mono<List<Object>> getPluginDynamicConfigSafely(List<GetPluginDynamicConfigRequestDTO> getPluginDynamicConfigRequestDTOS) { return getPluginDynamicConfig(getPluginDynamicConfigRequestDTOS) .onErrorResume(throwable -> { @@ -119,21 +127,47 @@ public Flux<DatasourcePluginDefinition> getDatasourcePluginDefinitions() { @SuppressWarnings("unchecked") public Mono<QueryExecutionResult> executeQuery(String pluginName, Object queryDsl, List<Map<String, Object>> context, Object datasourceConfig) { return getAcceptLanguage() - .flatMap(language -> WEB_CLIENT - .post() - .uri(nodeServerHelper.createUri(RUN_PLUGIN_QUERY)) - .header(HttpHeaders.ACCEPT_LANGUAGE, language) - .bodyValue(Map.of("pluginName", pluginName, "dsl", queryDsl, "context", context, "dataSourceConfig", datasourceConfig)) - .exchangeToMono(response -> { - if (response.statusCode().is2xxSuccessful()) { - return response.bodyToMono(Map.class) - .map(map -> map.get("result")) - .map(QueryExecutionResult::success); - } - return response.bodyToMono(Map.class) - .map(map -> MapUtils.getString(map, "message")) - .map(QueryExecutionResult::errorWithMessage); - })); + .flatMap(language -> { + try { + Map<String, Object> body = Map.of( + "pluginName", pluginName, + "dsl", queryDsl, + "context", context, + "dataSourceConfig", datasourceConfig + ); + String json = OBJECT_MAPPER.writeValueAsString(body); + + boolean encryptionEnabled = commonConfig.getJsExecutor().isEncrypted(); + String payload; + WebClient.RequestBodySpec requestSpec = WEB_CLIENT + .post() + .uri(nodeServerHelper.createUri(RUN_PLUGIN_QUERY)) + .header(HttpHeaders.ACCEPT_LANGUAGE, language); + + if (encryptionEnabled) { + payload = encryptionService.encryptStringForNodeServer(json); + requestSpec = requestSpec.header("X-Encrypted", "true"); + } else { + payload = json; + } + + return requestSpec + .bodyValue(payload) + .exchangeToMono(response -> { + if (response.statusCode().is2xxSuccessful()) { + return response.bodyToMono(Map.class) + .map(map -> map.get("result")) + .map(QueryExecutionResult::success); + } + return response.bodyToMono(Map.class) + .map(map -> MapUtils.getString(map, "message")) + .map(QueryExecutionResult::errorWithMessage); + }); + } catch (Exception e) { + log.error("Encryption error", e); + return Mono.error(new ServerException("Encryption error")); + } + }); } @SuppressWarnings("unchecked") diff --git a/server/api-service/lowcoder-domain/src/test/java/org/lowcoder/domain/encryption/EncryptionServiceImplTest.java b/server/api-service/lowcoder-domain/src/test/java/org/lowcoder/domain/encryption/EncryptionServiceImplTest.java new file mode 100644 index 000000000..41bd465c8 --- /dev/null +++ b/server/api-service/lowcoder-domain/src/test/java/org/lowcoder/domain/encryption/EncryptionServiceImplTest.java @@ -0,0 +1,80 @@ +package org.lowcoder.domain.encryption; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.lowcoder.sdk.config.CommonConfig; +import org.lowcoder.sdk.config.CommonConfig.Encrypt; +import org.lowcoder.sdk.config.CommonConfig.JsExecutor; +import org.springframework.security.crypto.encrypt.Encryptors; +import org.springframework.security.crypto.encrypt.TextEncryptor; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class EncryptionServiceImplTest { + + private EncryptionServiceImpl encryptionService; + private TextEncryptor nodeServerEncryptor; + private String nodePassword = "nodePassword"; + private String nodeSalt = "nodeSalt"; + + @BeforeEach + void setUp() { + // Mock CommonConfig and its nested classes + Encrypt encrypt = mock(Encrypt.class); + when(encrypt.getPassword()).thenReturn("testPassword"); + when(encrypt.getSalt()).thenReturn("testSalt"); + + JsExecutor jsExecutor = mock(JsExecutor.class); + when(jsExecutor.getPassword()).thenReturn(nodePassword); + when(jsExecutor.getSalt()).thenReturn(nodeSalt); + + CommonConfig commonConfig = mock(CommonConfig.class); + when(commonConfig.getEncrypt()).thenReturn(encrypt); + when(commonConfig.getJsExecutor()).thenReturn(jsExecutor); + + encryptionService = new EncryptionServiceImpl(commonConfig); + + // For direct comparison in test + String saltInHexForNodeServer = org.apache.commons.codec.binary.Hex.encodeHexString(nodeSalt.getBytes()); + nodeServerEncryptor = Encryptors.text(nodePassword, saltInHexForNodeServer); + } + + @Test + void testEncryptStringForNodeServer_NullInput() { + assertNull(encryptionService.encryptStringForNodeServer(null)); + } + + @Test + void testEncryptStringForNodeServer_EmptyInput() { + assertEquals("", encryptionService.encryptStringForNodeServer("")); + } + + @Test + void testEncryptStringForNodeServer_EncryptsAndDecryptsCorrectly() { + String plain = "node secret"; + String encrypted = encryptionService.encryptStringForNodeServer(plain); + assertNotNull(encrypted); + assertNotEquals(plain, encrypted); + + // Decrypt using the same encryptor to verify correctness + String decrypted = nodeServerEncryptor.decrypt(encrypted); + assertEquals(plain, decrypted); + } + + @Test + void testEncryptStringForNodeServer_DifferentInputsProduceDifferentOutputs() { + String encrypted1 = encryptionService.encryptStringForNodeServer("abc"); + String encrypted2 = encryptionService.encryptStringForNodeServer("def"); + assertNotEquals(encrypted1, encrypted2); + } + + @Test + void testEncryptStringForNodeServer_SameInputProducesDifferentOutputs() { + String input = "repeat"; + String encrypted1 = encryptionService.encryptStringForNodeServer(input); + String encrypted2 = encryptionService.encryptStringForNodeServer(input); + // Spring's Encryptors.text uses random IV, so outputs should differ + assertNotEquals(encrypted1, encrypted2); + } +} \ No newline at end of file diff --git a/server/api-service/lowcoder-sdk/src/main/java/org/lowcoder/sdk/config/CommonConfig.java b/server/api-service/lowcoder-sdk/src/main/java/org/lowcoder/sdk/config/CommonConfig.java index b50d06935..697f42fcd 100644 --- a/server/api-service/lowcoder-sdk/src/main/java/org/lowcoder/sdk/config/CommonConfig.java +++ b/server/api-service/lowcoder-sdk/src/main/java/org/lowcoder/sdk/config/CommonConfig.java @@ -147,6 +147,9 @@ public long getMaxAgeInSeconds() { @Data public static class JsExecutor { private String host; + private String password; + private String salt; + private boolean isEncrypted; } @Data diff --git a/server/api-service/lowcoder-server/src/main/resources/application-debug.yaml b/server/api-service/lowcoder-server/src/main/resources/application-debug.yaml index d52888ca7..050e8077b 100644 --- a/server/api-service/lowcoder-server/src/main/resources/application-debug.yaml +++ b/server/api-service/lowcoder-server/src/main/resources/application-debug.yaml @@ -37,6 +37,9 @@ common: cookie-name: LOWCODER_DEBUG_TOKEN js-executor: host: "http://127.0.0.1:6060" + password: ${LOWCODER_NODE_SERVICE_SECRET:lowcoderpwd} + salt: ${LOWCODER_NODE_SERVICE_SECRET_SALT:lowcodersalt} + is-encrypted: ${LOWCODER_NODE_SERVICE_ENCRYPTED:false} workspace: mode: ${LOWCODER_WORKSPACE_MODE:SAAS} plugin-dirs: diff --git a/server/api-service/lowcoder-server/src/main/resources/application.yaml b/server/api-service/lowcoder-server/src/main/resources/application.yaml index e5058563c..00bce2fc1 100644 --- a/server/api-service/lowcoder-server/src/main/resources/application.yaml +++ b/server/api-service/lowcoder-server/src/main/resources/application.yaml @@ -74,6 +74,9 @@ common: corsAllowedDomainString: ${LOWCODER_CORS_DOMAINS:*} js-executor: host: ${LOWCODER_NODE_SERVICE_URL:http://127.0.0.1:6060} + password: ${LOWCODER_NODE_SERVICE_SECRET:lowcoderpwd} + salt: ${LOWCODER_NODE_SERVICE_SECRET_SALT:lowcodersalt} + is-encrypted: ${LOWCODER_NODE_SERVICE_ENCRYPTED:false} max-query-request-size: ${LOWCODER_MAX_REQUEST_SIZE:20m} max-query-response-size: ${LOWCODER_MAX_REQUEST_SIZE:20m} max-upload-size: ${LOWCODER_MAX_REQUEST_SIZE:20m} @@ -129,4 +132,4 @@ management: redis: enabled: true diskspace: - enabled: false + enabled: false \ No newline at end of file diff --git a/server/node-service/src/controllers/plugins.ts b/server/node-service/src/controllers/plugins.ts index e20a109b3..d763586ce 100644 --- a/server/node-service/src/controllers/plugins.ts +++ b/server/node-service/src/controllers/plugins.ts @@ -3,6 +3,23 @@ import { Request, Response } from "express"; import _ from "lodash"; import { Config } from "lowcoder-sdk/dataSource"; import * as pluginServices from "../services/plugin"; +// Add import for decryption utility +import { decryptString } from "../utils/encryption"; // <-- implement this utility as needed + +async function getDecryptedBody(req: Request): Promise<any> { + if (req.headers["x-encrypted"]) { + // Assume body is a raw encrypted string, decrypt and parse as JSON + const encrypted = typeof req.body === "string" ? req.body : req.body?.toString?.(); + if (!encrypted) throw badRequest("Missing encrypted body"); + const decrypted = await decryptString(encrypted); + try { + return JSON.parse(decrypted); + } catch (e) { + throw badRequest("Failed to parse decrypted body as JSON"); + } + } + return req.body; +} export async function listPlugins(req: Request, res: Response) { let ids = req.query["id"] || []; @@ -15,12 +32,10 @@ export async function listPlugins(req: Request, res: Response) { } export async function runPluginQuery(req: Request, res: Response) { - const { pluginName, dsl, context, dataSourceConfig } = req.body; + const body = await getDecryptedBody(req); + const { pluginName, dsl, context, dataSourceConfig } = body; const ctx = pluginServices.getPluginContext(req); - - // console.log("pluginName: ", pluginName, "dsl: ", dsl, "context: ", context, "dataSourceConfig: ", dataSourceConfig, "ctx: ", ctx); - const result = await pluginServices.runPluginQuery( pluginName, dsl, @@ -32,7 +47,8 @@ export async function runPluginQuery(req: Request, res: Response) { } export async function validatePluginDataSourceConfig(req: Request, res: Response) { - const { pluginName, dataSourceConfig } = req.body; + const body = await getDecryptedBody(req); + const { pluginName, dataSourceConfig } = body; const ctx = pluginServices.getPluginContext(req); const result = await pluginServices.validatePluginDataSourceConfig( pluginName, @@ -50,10 +66,11 @@ type GetDynamicDefReqBody = { export async function getDynamicDef(req: Request, res: Response) { const ctx = pluginServices.getPluginContext(req); - if (!Array.isArray(req.body)) { + const body = await getDecryptedBody(req); + if (!Array.isArray(body)) { throw badRequest("request body is not a valid array"); } - const fields = req.body as GetDynamicDefReqBody; + const fields = body as GetDynamicDefReqBody; const result: Config[] = []; for (const item of fields) { const def = await pluginServices.getDynamicConfigDef( diff --git a/server/node-service/src/server.ts b/server/node-service/src/server.ts index 124c8d1e5..793161ef5 100644 --- a/server/node-service/src/server.ts +++ b/server/node-service/src/server.ts @@ -9,6 +9,7 @@ import { collectDefaultMetrics } from "prom-client"; import apiRouter from "./routes/apiRouter"; import systemRouter from "./routes/systemRouter"; import cors, { CorsOptions } from "cors"; +import bodyParser from "body-parser"; collectDefaultMetrics(); const prefix = "/node-service"; @@ -32,6 +33,15 @@ router.use(morgan("dev")); /** Parse the request */ router.use(express.urlencoded({ extended: false })); +/** Custom middleware: use raw body for encrypted requests */ +router.use((req, res, next) => { + if (req.headers["x-encrypted"]) { + bodyParser.text({ type: "*/*" })(req, res, next); + } else { + bodyParser.json()(req, res, next); + } +}); + /** Takes care of JSON data */ router.use( express.json({ diff --git a/server/node-service/src/utils/encryption.ts b/server/node-service/src/utils/encryption.ts new file mode 100644 index 000000000..2240a6571 --- /dev/null +++ b/server/node-service/src/utils/encryption.ts @@ -0,0 +1,42 @@ +import { createDecipheriv, pbkdf2Sync } from "crypto"; +import { badRequest } from "../common/error"; + +// Spring's Encryptors.text uses AES-256-CBC with PBKDF2 (HmacSHA1, 1024 iterations). +const ALGORITHM = "aes-256-cbc"; +const KEY_LENGTH = 32; // 256 bits +const IV_LENGTH = 16; // 128 bits +const ITERATIONS = 1024; +const DIGEST = "sha1"; + +// You must set these to match your Java config: +const PASSWORD = process.env.LOWCODER_NODE_SERVICE_SECRET || "lowcoderpwd"; +const SALT_HEX = process.env.LOWCODER_NODE_SERVICE_SECRET_SALT || "lowcodersalt"; + +/** + * Derive key from password and salt using PBKDF2WithHmacSHA1 (Spring's default). + */ +function deriveKey(password: string, saltHex: string): Buffer { + const salt = Buffer.from(saltHex, "utf8"); + return pbkdf2Sync(password, salt, ITERATIONS, KEY_LENGTH, DIGEST); +} + +/** + * Decrypt a string encrypted by Spring's Encryptors.text. + */ +export async function decryptString(encrypted: string): Promise<string> { + try { + // Spring's format: hex(salt) + encryptedHex(IV + ciphertext) + const key = deriveKey(PASSWORD, SALT_HEX); + + const encryptedBuf = Buffer.from(encrypted, "hex"); + const iv = encryptedBuf.slice(0, IV_LENGTH); + const ciphertext = encryptedBuf.slice(IV_LENGTH); + + const decipher = createDecipheriv(ALGORITHM, key, iv); + let decrypted = decipher.update(ciphertext, undefined, "utf8"); + decrypted += decipher.final("utf8"); + return decrypted; + } catch (e) { + throw badRequest("Failed to decrypt string"); + } +} \ No newline at end of file