feat: Add patches functionality#3574
Conversation
|
Hi! I'm I would like to apply some automated changes to this pull request, but it looks like I don't have the necessary permissions to do so. To get this pull request into a mergeable state, please do one of the following two things:
|
71967f7 to
2e992b9
Compare
|
Hey, can you allow to maintainers can edit the PR? @ivanpadavan |
Sure! |
2e992b9 to
a4eb0bf
Compare
|
Oh wow, this is a really useful feature, actually! |
c8a2979 to
7747929
Compare
…eate snapshot for version 7
|
@greptile review |
| }), | ||
|
|
||
| readRepoFile: protectedProcedure | ||
| .input( | ||
| z.object({ | ||
| applicationId: z.string().optional(), | ||
| composeId: z.string().optional(), | ||
| repoPath: z.string(), |
There was a problem hiding this comment.
Path traversal vulnerability in user-supplied paths
The repoPath and filePath parameters accept arbitrary strings with no validation. Since repoPath is returned by ensureRepo to the client and then sent back in subsequent requests, a malicious client can substitute any path. Combined with filePath, this enables reading arbitrary files on the server:
filePath: "../../../../../../etc/passwd"
This flows into readPatchRepoFile → cat "${fullPath}" with no traversal check. The same issue exists in readRepoDirectories (line 290) and saveFileAsPatch (line 405), where filePath reaches generatePatch → echo ... | base64 -d > "${fullPath}", enabling arbitrary file writes.
Both repoPath and filePath need strict validation:
repoPathshould be validated server-side against the expected path for the given application/compose (not trusted from the client)filePathshould reject..segments and absolute paths (e.g.,.refine(p => !p.includes('..') && !path.isAbsolute(p)))
| }), | ||
|
|
||
| update: protectedProcedure | ||
| .input(apiUpdatePatch) | ||
| .mutation(async ({ input }) => { | ||
| const { patchId, ...data } = input; | ||
| return await updatePatch(patchId, data); | ||
| }), | ||
|
|
||
| delete: protectedProcedure | ||
| .input(apiDeletePatch) | ||
| .mutation(async ({ input }) => { |
There was a problem hiding this comment.
Missing authorization on update, delete, and toggleEnabled
The update, delete, and toggleEnabled (line 209) endpoints perform no ownership/authorization checks — any authenticated user can modify or delete any patch by ID. Every other router in the codebase (e.g., mount.ts, port.ts, domain.ts) verifies organizationId before allowing mutations.
These endpoints should fetch the patch, resolve the parent application/compose, and verify organizationId matches ctx.session.activeOrganizationId before proceeding. For example:
update: protectedProcedure
.input(apiUpdatePatch)
.mutation(async ({ input, ctx }) => {
const existing = await findPatchById(input.patchId);
if (existing.applicationId) {
const app = await findApplicationById(existing.applicationId);
if (app.environment.project.organizationId !== ctx.session.activeOrganizationId) {
throw new TRPCError({ code: "UNAUTHORIZED", message: "Not authorized" });
}
} else if (existing.composeId) {
const compose = await findComposeById(existing.composeId);
if (compose.environment.project.organizationId !== ctx.session.activeOrganizationId) {
throw new TRPCError({ code: "UNAUTHORIZED", message: "Not authorized" });
}
}
const { patchId, ...data } = input;
return await updatePatch(patchId, data);
}),|
|
||
| return await createPatch(input); | ||
| }), | ||
|
|
||
| one: protectedProcedure.input(apiFindPatch).query(async ({ input }) => { |
There was a problem hiding this comment.
Missing authorization on one endpoint
The one query returns any patch by ID without verifying the caller has access to the parent application/compose. An authenticated user from one organization can read patches belonging to another organization's services. This should include the same ownership verification pattern used in byApplicationId and byComposeId.
| const sshKey = await findSSHKeyById(sshKeyId); | ||
| const temporalKeyPath = "/tmp/patch_repo_id_rsa"; | ||
| sshSetup = ` | ||
| echo "${sshKey.privateKey}" > ${temporalKeyPath}; | ||
| chmod 600 ${temporalKeyPath}; | ||
| export GIT_SSH_COMMAND="ssh -i ${temporalKeyPath} -o UserKnownHostsFile=${knownHostsPath} -o StrictHostKeyChecking=accept-new"; | ||
| `; |
There was a problem hiding this comment.
Hardcoded SSH key path creates race condition and leaves key on disk
The SSH private key is written to a hardcoded path /tmp/patch_repo_id_rsa. This causes two problems:
- Race condition: If two patch repo operations run concurrently with different SSH keys, they overwrite each other's key file, causing authentication failures or using the wrong key.
- Key not cleaned up: The private key remains on disk in
/tmpafter the operation completes. Unlike ephemeral in-memory usage, this persists until the system clears/tmp.
Consider using a unique temp file (e.g., /tmp/patch_repo_id_rsa_${Date.now()}_${Math.random()}) and adding cleanup with a trap in the shell command:
trap "rm -f ${temporalKeyPath}" EXIT;| rm -f ${patchFileName}; | ||
| `; | ||
| } | ||
|
|
||
| return command; | ||
| }; | ||
|
|
||
| /** |
There was a problem hiding this comment.
Patch filePath interpolated unsanitized into shell echo strings
The p.filePath value (from user input stored in the database) is interpolated directly into the shell command string inside echo statements. If a filePath contains characters like backticks, $(...), or double-quote escapes, it could lead to command injection when the shell evaluates these echo strings.
For example, a filePath like `whoami` or $(cat /etc/passwd) would be executed by the shell.
The filePath should be sanitized when stored (enforce a strict allowlist pattern like [a-zA-Z0-9./_-]+) or these echo statements should use single quotes for the interpolated path.
| unique("patch_filepath_compose_unique").on(table.filePath, table.composeId), | ||
| ], | ||
| ); | ||
|
|
||
| export const patchRelations = relations(patch, ({ one }) => ({ | ||
| application: one(applications, { |
There was a problem hiding this comment.
Unique constraints allow duplicate patches when applicationId/composeId is NULL
The unique constraints (filePath, applicationId) and (filePath, composeId) do not prevent duplicates when the nullable column is NULL. In PostgreSQL, NULL != NULL, so multiple rows with the same filePath and applicationId = NULL are allowed. Since only one of applicationId/composeId should be set, the other is NULL — meaning the constraint on the NULL side provides no protection. This likely matches the intended behavior (one patch per file per service), but it means there's no DB-level enforcement preventing orphaned patches (both IDs null) from being created.
| ctx.session.activeOrganizationId, | ||
| "access", | ||
| ); | ||
| } | ||
| } else if (input.composeId) { | ||
| const compose = await findComposeById(input.composeId); | ||
| if ( | ||
| compose.environment.project.organizationId !== | ||
| ctx.session.activeOrganizationId | ||
| ) { | ||
| throw new TRPCError({ | ||
| code: "UNAUTHORIZED", |
There was a problem hiding this comment.
Compose create is missing checkServiceAccess for members
For applications, the create endpoint checks checkServiceAccess when ctx.user.role === "member" (line 132). For compose services, this check is absent — any member in the organization can create patches for any compose service regardless of their service-level access permissions.
- Updated the PatchEditor and ShowPatches components to accept a unified `id` and `type` prop, replacing the previous separate `applicationId` and `composeId` props. - Refactored API calls in the patch router to handle both application and compose types, improving code clarity and maintainability. - Enhanced the UI to provide consistent behavior for creating and managing patches across different types.
- Removed the local state for saving status and integrated loading state directly from the mutation hook. - Updated the saveAsPatch function to handle success and error notifications more cleanly. - Adjusted the file content loading to ensure it retrieves the latest data, improving user experience and consistency in the PatchEditor component. - Refactored API calls in the patch router to unify patch retrieval logic for applications and composes, enhancing maintainability.
|
Hey, I think this feature is really good. There's a lot to do. I've done quite a bit of refactoring, and I think the next idea is great, but it could be simplified a lot more. I think integrating patches using git commands seems like the right way to go, but it can turn into hell. I was thinking of a much simpler idea of saving the file content and path, as well as supporting file creation and deletion. That way, when the repo is cloned, the patches are applied. If the patch being applied does not exist, then the file or corresponding operation is created. I think this would be simpler and we would prevent a lot of weird synchronisation errors... |
… configuration handling - Removed redundant helper functions for retrieving git configurations for applications and composes, streamlining the codebase. - Updated the `ensurePatchRepo` function to directly handle repository cloning based on the application or compose type, improving clarity and maintainability. - Refactored patch creation logic to eliminate unnecessary checks and streamline the process of creating patches. - Enhanced the handling of output paths in repository cloning functions across different git providers, ensuring consistent behavior.
- Consolidated patch retrieval for applications and composes into a single query method, improving code clarity and reducing redundancy. - Updated the ShowPatches component to utilize the new unified query, simplifying data fetching logic. - Refactored patch application commands to streamline the process for both application and compose types, enhancing maintainability and consistency across the codebase.
|
Almost all the changes are done, just a few more details and it will work great! I hope to be able to merge this into v0.28.0. |
- Introduced the EditPatchDialog component to facilitate patch editing within the dashboard. - Integrated the dialog into the ShowPatches component, allowing users to edit patches directly from the list view. - Enhanced user experience with loading indicators and success/error notifications during patch updates. - Updated the UI to ensure consistent styling and behavior across patch management features.
…ch handling - Eliminated the repoPath parameter from the PatchEditor component and related API calls, simplifying the patch management logic. - Updated the patch retrieval and saving processes to focus on filePath and content, enhancing clarity and maintainability. - Adjusted the handling of file content in the CodeEditor to ensure it retrieves the correct data, improving user experience.
- Streamlined the command generation for applying patches by removing unnecessary checks for enabled status within the loop. - Consolidated patch retrieval and filtering logic to enhance clarity and maintainability. - Improved directory handling for file paths to ensure proper creation before applying patches, enhancing robustness.
- Introduced a new ENUM type "patchType" with values 'create', 'update', and 'delete' to categorize patch operations. - Updated the "patch" table schema to include a new "type" column, defaulting to 'update', ensuring better management of patch types. - Added a new snapshot file for version 7 to reflect the updated database schema.
…n PatchEditor - Added CreateFileDialog component for creating new files within the dashboard. - Integrated file creation functionality into the PatchEditor, allowing users to create files directly from the directory structure. - Enhanced user experience with form validation and success/error notifications during file creation. - Updated ShowPatches to display file types with badges for better clarity on patch operations.
…ction - Removed redundant command initialization and ensured proper command structure for deployment. - Enhanced clarity by consolidating command building logic, improving maintainability of the deployCompose function.
…epo directory access - Updated ID input validation to require a minimum length of 1 character. - Simplified the logic for accessing repository directories by consolidating return statements and removing unnecessary error handling for missing application or compose types.
- Introduced a new SQL file to create a "patch" table with a custom ENUM type "patchType" for tracking changes. - Added foreign key constraints linking "applicationId" and "composeId" to their respective tables. - Removed the previous SQL file that contained redundant definitions for the "patchType" and its column in the "patch" table.
- Added a mock for the patch table in application.command.test.ts to simulate findMany behavior. - Updated application.real.test.ts to include new paths for COMPOSE_PATH, SSH_PATH, and BASE_PATH for improved test coverage.
- Introduced a mock for the patch table to simulate findMany behavior in application.real.test.ts, enhancing test coverage.
- Replaced the mock for the patch service with a mock for db.query.patch.findMany to better simulate database behavior. - Added type and updatedAt fields to the mock patch object for improved test accuracy.
…l.test.ts - Eliminated the mock for the patch service and the associated deployment test case to streamline the test suite. - This change focuses on improving test clarity and reducing complexity by removing redundant code.
- Eliminated the mock constants for paths to simplify the test setup. - This change enhances test clarity by focusing on relevant mocks and reducing unnecessary complexity.
- Removed the mock for the patch service to streamline the test setup. - This change enhances test clarity by eliminating unnecessary mock definitions.
…t.ts - Removed the mock for the patch service to streamline the test setup. - This change enhances test clarity by eliminating unnecessary mock definitions.
…pplication.real.test.ts - Introduced a mock for the patch service's findMany method in both test files to simulate database behavior. - This change enhances test coverage and ensures consistency across the test suite.
|
Wow, that was cool. Thank you, @AlexDev404, for your support, and @Siumauricio for everything :D |
What is this PR about?
This PR introduces a new Patches system for Applications and Docker Compose services — a powerful way to customize deployments directly from the dashboard without maintaining complex forks.
While there are many "ready-to-work" repositories available, most require slight modifications to run correctly on a self-hosted instance. This feature dramatically improves the user experience by making it easy to apply small, persistent fixes to any service.
How It Works:
Users can apply persistent modifications (Git patches) to source code or configuration files (like
docker-compose.yml) directly from the Dokploy UI. These patches are automatically applied before every build or deployment.Key Features:
git applymechanics; if the source file changes drastically, the patch fails safely, preventing silent breakages.Implementation Details:
Patchentity and aPatchServiceto handle git operations (clone, diff, apply).I understand that the UI/UX and test coverage are open points for discussion. If you are open to including this feature in the product, I am ready to polish it further based on your feedback.
You can preview the implementation here: https://github.com/ivanpadavan/dokploy-patches
Checklist
Before submitting this PR, please make sure that:
canarybranch.Issues related (if applicable)
(If there is a feature request issue for this, link it here, e.g. closes #123)
Screenshots
Patches Tab (Empty State)

Creating/Editing a Patch

Patches List

Greptile Summary
This PR adds a Patches system that lets users apply persistent git patches to Applications and Docker Compose services before each build. It introduces a new
patchDB table, backend CRUD + git operations services, TRPC router, and a frontend file browser/editor UI.Critical issues that must be resolved before merging:
repoPathandfilePathparameters inreadRepoFile,readRepoDirectories, andsaveFileAsPatchendpoints accept arbitrary strings with no validation. SincerepoPathis returned to the client byensureRepoand sent back in subsequent requests, a malicious user can substitute any server path. Combined with unvalidatedfilePath, this enables reading and writing arbitrary files on the host system (e.g.,/etc/passwd, SSH keys, cron jobs).update,delete,toggleEnabled, andoneendpoints perform no ownership verification — any authenticated user can modify or read any patch by ID, regardless of organization. Every other router in the codebase verifiesorganizationIdbefore mutations.filePathfrom user input is interpolated unsanitized into shellechostrings ingenerateApplyPatchesCommand. ThegitBranchvariable is unquoted in thegit clonecommand.|| echo "Warning..."pattern ingenerateApplyPatchesCommandsilently swallowsgit applyfailures, allowing builds to proceed with unpatched code. This contradicts the PR description's claim that patches "fail safely, preventing silent breakages."/tmp/patch_repo_id_rsapath with no cleanup, creating race conditions for concurrent operations and leaving sensitive keys on disk.checkServiceAccessfor compose: Thecreateendpoint checks member-level service access for applications but not for compose services.Confidence Score: 1/5
apps/dokploy/server/api/routers/patch.ts(missing auth, path traversal),packages/server/src/services/patch-repo.ts(shell injection, SSH key handling), andpackages/server/src/services/patch.ts(command injection, silent failures).Last reviewed commit: 752f90c
(2/5) Greptile learns from your feedback when you react with thumbs up/down!