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

Open
wants to merge 4 commits into
base: dev
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

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
Expand Up @@ -4,6 +4,8 @@ public interface EncryptionService {

String encryptString(String plaintext);

String encryptStringForNodeServer(String plaintext);

String decryptString(String encryptedText);

String encryptPassword(String plaintext);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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
Expand All @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -30,6 +32,8 @@

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

import com.fasterxml.jackson.databind.ObjectMapper;

@Slf4j
@RequiredArgsConstructor
@Component
Expand All @@ -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 -> {
Expand Down Expand Up @@ -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")
Expand Down
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
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down Expand Up @@ -129,4 +132,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
Expand Up @@ -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"] || [];
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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(
Expand Down
10 changes: 10 additions & 0 deletions server/node-service/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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({
Expand Down
Loading
Loading