Skip to content

Nodeserver encrypted payload #1661

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 6 commits into from
May 22, 2025
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -4,6 +4,8 @@ public interface EncryptionService {

String encryptString(String plaintext);

String encryptStringForNodeServer(String plaintext);

String decryptString(String encryptedText);

String encryptPassword(String plaintext);
Original file line number Diff line number Diff line change
@@ -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) {
Original file line number Diff line number Diff line change
@@ -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().getPassword().isEmpty() || commonConfig.getJsExecutor().getSalt().isEmpty());
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")
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -37,6 +37,8 @@ common:
cookie-name: LOWCODER_DEBUG_TOKEN
js-executor:
host: "http://127.0.0.1:6060"
password: ${LOWCODER_NODE_SERVICE_SECRET:}
salt: ${LOWCODER_NODE_SERVICE_SECRET_SALT:}
workspace:
mode: ${LOWCODER_WORKSPACE_MODE:SAAS}
plugin-dirs:
Original file line number Diff line number Diff line change
@@ -74,6 +74,8 @@ common:
corsAllowedDomainString: ${LOWCODER_CORS_DOMAINS:*}
js-executor:
host: ${LOWCODER_NODE_SERVICE_URL:http://127.0.0.1:6060}
password: ${LOWCODER_NODE_SERVICE_SECRET:}
salt: ${LOWCODER_NODE_SERVICE_SECRET_SALT:}
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 +131,4 @@ management:
redis:
enabled: true
diskspace:
enabled: false
enabled: false
31 changes: 24 additions & 7 deletions server/node-service/src/controllers/plugins.ts
Original file line number Diff line number Diff line change
@@ -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(
10 changes: 10 additions & 0 deletions server/node-service/src/server.ts
Original file line number Diff line number Diff line change
@@ -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({
42 changes: 42 additions & 0 deletions server/node-service/src/utils/encryption.ts
Original file line number Diff line number Diff line change
@@ -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");
}
}