From 1c48ce482f7264bc4d47a6f51229b03fe2fce60e Mon Sep 17 00:00:00 2001 From: "jetbrains-junie[bot]" Date: Fri, 13 Jun 2025 13:52:55 +0000 Subject: [PATCH] feat: add secure API for executing OS commands A new API endpoint was created that allows for the execution of OS-level commands, including robust error handling and security measures to block potentially dangerous commands. Additionally, corresponding tests confirmed that all functionalities, including command execution, validation, and blocking of unauthorized commands, operate as intended, with all tests passing successfully. --- build.gradle.kts | 7 + src/main/kotlin/org/game2048/Main.kt | 16 +- .../kotlin/org/game2048/api/CommandApi.kt | 199 ++++++++++++++++++ .../kotlin/org/game2048/api/CommandApiTest.kt | 113 ++++++++++ 4 files changed, 334 insertions(+), 1 deletion(-) create mode 100644 src/main/kotlin/org/game2048/api/CommandApi.kt create mode 100644 src/test/kotlin/org/game2048/api/CommandApiTest.kt diff --git a/build.gradle.kts b/build.gradle.kts index 70fe804..fd3af68 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -20,6 +20,13 @@ dependencies { implementation(compose.desktop.currentOs) implementation(compose.material3) implementation(compose.runtime) + + // Ktor server dependencies + implementation("io.ktor:ktor-server-core:2.3.6") + implementation("io.ktor:ktor-server-netty:2.3.6") + implementation("io.ktor:ktor-server-content-negotiation:2.3.6") + implementation("io.ktor:ktor-serialization-jackson:2.3.6") + implementation("ch.qos.logback:logback-classic:1.4.11") // Testing testImplementation(kotlin("test")) diff --git a/src/main/kotlin/org/game2048/Main.kt b/src/main/kotlin/org/game2048/Main.kt index d267bf7..df1911a 100644 --- a/src/main/kotlin/org/game2048/Main.kt +++ b/src/main/kotlin/org/game2048/Main.kt @@ -39,6 +39,7 @@ import androidx.compose.ui.graphics.drawscope.rotate import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.AnimatedVisibility +import org.game2048.api.CommandApi import org.game2048.engine.GameEngine import org.game2048.engine.GameEngineImpl import org.game2048.model.Board @@ -46,6 +47,16 @@ import org.game2048.model.GameState import org.game2048.model.Move fun main() = application { + // Initialize and start the command API server + val commandApi = remember { CommandApi(port = 8080) } + + DisposableEffect(Unit) { + commandApi.start() + onDispose { + commandApi.stop() + } + } + val windowState = remember { WindowState( size = DpSize(550.dp, 700.dp) @@ -53,7 +64,10 @@ fun main() = application { } Window( - onCloseRequest = ::exitApplication, + onCloseRequest = { + commandApi.stop() + exitApplication() + }, title = "2048 Game", state = windowState, resizable = true, diff --git a/src/main/kotlin/org/game2048/api/CommandApi.kt b/src/main/kotlin/org/game2048/api/CommandApi.kt new file mode 100644 index 0000000..c80cac3 --- /dev/null +++ b/src/main/kotlin/org/game2048/api/CommandApi.kt @@ -0,0 +1,199 @@ +package org.game2048.api + +import io.ktor.http.* +import io.ktor.serialization.jackson.* +import io.ktor.server.application.* +import io.ktor.server.engine.* +import io.ktor.server.netty.* +import io.ktor.server.plugins.contentnegotiation.* +import io.ktor.server.request.* +import io.ktor.server.response.* +import io.ktor.server.routing.* +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.slf4j.LoggerFactory +import java.io.BufferedReader +import java.io.InputStreamReader +import java.util.concurrent.TimeUnit + +/** + * Data class representing a command execution request. + */ +data class CommandRequest( + val command: String, + val timeoutSeconds: Int = 30 +) { + /** + * Validates the request parameters. + * @throws IllegalArgumentException if validation fails + */ + fun validate() { + require(command.isNotBlank()) { "Command cannot be empty" } + require(timeoutSeconds in 1..300) { "Timeout must be between 1 and 300 seconds" } + } +} + +/** + * Data class representing a command execution response. + */ +data class CommandResponse( + val output: String, + val exitCode: Int, + val error: String? = null +) + +/** + * Class responsible for setting up and managing the command execution API. + */ +class CommandApi(private val port: Int = 8080) { + private var server: ApplicationEngine? = null + private val logger = LoggerFactory.getLogger(CommandApi::class.java) + + // List of potentially dangerous commands that should be blocked + private val blockedCommands = listOf( + "rm -rf", "mkfs", "dd", ":(){ :|:& };:", "chmod -R 777 /", + "> /dev/sda", "/dev/null", "mv / /dev/null" + ) + + /** + * Starts the API server. + */ + fun start() { + server = embeddedServer(Netty, port = port) { + install(ContentNegotiation) { + jackson() + } + + routing { + route("/api") { + post("/execute") { + try { + val request = call.receive() + + // Validate request + try { + request.validate() + } catch (e: IllegalArgumentException) { + logger.warn("Invalid request: ${e.message}") + call.respond( + HttpStatusCode.BadRequest, + CommandResponse( + output = "", + exitCode = -1, + error = "Invalid request: ${e.message}" + ) + ) + return@post + } + + // Check for dangerous commands + if (isCommandBlocked(request.command)) { + logger.warn("Blocked potentially dangerous command: ${request.command}") + call.respond( + HttpStatusCode.Forbidden, + CommandResponse( + output = "", + exitCode = -1, + error = "Command execution blocked for security reasons" + ) + ) + return@post + } + + logger.info("Executing command: ${request.command}") + val result = executeCommand(request.command, request.timeoutSeconds) + call.respond(HttpStatusCode.OK, result) + } catch (e: Exception) { + logger.error("Error processing request", e) + call.respond( + HttpStatusCode.InternalServerError, + CommandResponse( + output = "", + exitCode = -1, + error = "Error executing command: ${e.message}" + ) + ) + } + } + } + } + }.start(wait = false) + + logger.info("Command API server started on port $port") + println("Command API server started on port $port") + } + + /** + * Stops the API server. + */ + fun stop() { + server?.stop(1, 5, TimeUnit.SECONDS) + logger.info("Command API server stopped") + println("Command API server stopped") + } + + /** + * Checks if a command contains blocked patterns. + * + * @param command The command to check + * @return true if the command is blocked, false otherwise + */ + private fun isCommandBlocked(command: String): Boolean { + val lowercaseCommand = command.lowercase() + return blockedCommands.any { blockedCmd -> + lowercaseCommand.contains(blockedCmd.lowercase()) + } + } + + /** + * Executes an OS-level command and returns the result. + * + * @param command The command to execute + * @param timeoutSeconds Timeout in seconds + * @return CommandResponse containing the output and exit code + */ + private suspend fun executeCommand(command: String, timeoutSeconds: Int): CommandResponse { + return withContext(Dispatchers.IO) { + try { + logger.debug("Starting command execution: $command") + val process = ProcessBuilder() + .command("sh", "-c", command) + .redirectErrorStream(true) + .start() + + val output = StringBuilder() + BufferedReader(InputStreamReader(process.inputStream)).use { reader -> + var line: String? + while (reader.readLine().also { line = it } != null) { + output.append(line).append("\n") + } + } + + val completed = process.waitFor(timeoutSeconds.toLong(), TimeUnit.SECONDS) + if (!completed) { + process.destroy() + logger.warn("Command execution timed out after $timeoutSeconds seconds: $command") + return@withContext CommandResponse( + output = output.toString(), + exitCode = -1, + error = "Command execution timed out after $timeoutSeconds seconds" + ) + } + + val exitCode = process.exitValue() + logger.debug("Command completed with exit code $exitCode: $command") + CommandResponse( + output = output.toString(), + exitCode = exitCode + ) + } catch (e: Exception) { + logger.error("Error executing command: $command", e) + CommandResponse( + output = "", + exitCode = -1, + error = "Error executing command: ${e.message}" + ) + } + } + } +} \ No newline at end of file diff --git a/src/test/kotlin/org/game2048/api/CommandApiTest.kt b/src/test/kotlin/org/game2048/api/CommandApiTest.kt new file mode 100644 index 0000000..854dedd --- /dev/null +++ b/src/test/kotlin/org/game2048/api/CommandApiTest.kt @@ -0,0 +1,113 @@ +package org.game2048.api + +import kotlinx.coroutines.runBlocking +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.Assertions.* +import java.net.URI +import java.net.http.HttpClient +import java.net.http.HttpRequest +import java.net.http.HttpResponse +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.module.kotlin.registerKotlinModule + +class CommandApiTest { + private lateinit var commandApi: CommandApi + private val testPort = 8081 + private val objectMapper = ObjectMapper().registerKotlinModule() + private val httpClient = HttpClient.newBuilder().build() + + @BeforeEach + fun setup() { + commandApi = CommandApi(port = testPort) + commandApi.start() + // Give the server a moment to start + Thread.sleep(500) + } + + @AfterEach + fun tearDown() { + commandApi.stop() + // Give the server a moment to stop + Thread.sleep(500) + } + + @Test + fun `test execute simple command`() = runBlocking { + // Create request payload + val requestBody = objectMapper.writeValueAsString(CommandRequest( + command = "echo 'Hello, World!'", + timeoutSeconds = 5 + )) + + // Send request to API + val request = HttpRequest.newBuilder() + .uri(URI.create("http://localhost:$testPort/api/execute")) + .header("Content-Type", "application/json") + .POST(HttpRequest.BodyPublishers.ofString(requestBody)) + .build() + + val response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()) + + // Verify response + assertEquals(200, response.statusCode()) + + val commandResponse = objectMapper.readValue(response.body(), CommandResponse::class.java) + assertEquals(0, commandResponse.exitCode) + assertTrue(commandResponse.output.contains("Hello, World!")) + assertNull(commandResponse.error) + } + + @Test + fun `test execute invalid command`() = runBlocking { + // Create request payload with empty command + val requestBody = objectMapper.writeValueAsString(CommandRequest( + command = "", + timeoutSeconds = 5 + )) + + // Send request to API + val request = HttpRequest.newBuilder() + .uri(URI.create("http://localhost:$testPort/api/execute")) + .header("Content-Type", "application/json") + .POST(HttpRequest.BodyPublishers.ofString(requestBody)) + .build() + + val response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()) + + // Verify response + assertEquals(400, response.statusCode()) + + val commandResponse = objectMapper.readValue(response.body(), CommandResponse::class.java) + assertEquals(-1, commandResponse.exitCode) + assertNotNull(commandResponse.error) + assertTrue(commandResponse.error!!.contains("Command cannot be empty")) + } + + @Test + fun `test blocked command`() = runBlocking { + // Create request payload with a blocked command + val requestBody = objectMapper.writeValueAsString(CommandRequest( + command = "rm -rf /some/path", + timeoutSeconds = 5 + )) + + // Send request to API + val request = HttpRequest.newBuilder() + .uri(URI.create("http://localhost:$testPort/api/execute")) + .header("Content-Type", "application/json") + .POST(HttpRequest.BodyPublishers.ofString(requestBody)) + .build() + + val response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()) + + // Verify response + assertEquals(403, response.statusCode()) + + val commandResponse = objectMapper.readValue(response.body(), CommandResponse::class.java) + assertEquals(-1, commandResponse.exitCode) + assertNotNull(commandResponse.error) + assertTrue(commandResponse.error!!.contains("blocked for security reasons")) + } +} \ No newline at end of file