Skip to content

Commit 6dde76e

Browse files
committed
feat: Enhance webhook authentication by implementing URL-safe token generation and decryption
1 parent 04d7ce4 commit 6dde76e

3 files changed

Lines changed: 99 additions & 57 deletions

File tree

java-ecosystem/libs/rag-engine/src/main/java/org/rostilos/codecrow/ragengine/service/IncrementalRagUpdateService.java

Lines changed: 32 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -89,53 +89,43 @@ public Map<String, Object> performIncrementalUpdate(
8989
result.put("commitHash", commitHash);
9090

9191
if (!deletedFiles.isEmpty()) {
92-
try {
93-
Map<String, Object> deleteResult = ragPipelineClient.deleteFiles(
94-
new ArrayList<>(deletedFiles),
95-
projectWorkspace,
96-
projectNamespace,
97-
branch
98-
);
99-
result.put("deletedFiles", deletedFiles.size());
100-
log.info("Deleted {} files from RAG index", deletedFiles.size());
101-
} catch (Exception e) {
102-
log.error("Failed to delete files from RAG index", e);
103-
result.put("deleteError", e.getMessage());
104-
}
92+
Map<String, Object> deleteResult = ragPipelineClient.deleteFiles(
93+
new ArrayList<>(deletedFiles),
94+
projectWorkspace,
95+
projectNamespace,
96+
branch
97+
);
98+
result.put("deletedFiles", deletedFiles.size());
99+
log.info("Deleted {} files from RAG index", deletedFiles.size());
105100
}
106101

107102
if (!addedOrModifiedFiles.isEmpty()) {
103+
Path tempDir = Files.createTempDirectory("codecrow-rag-incremental-");
108104
try {
109-
Path tempDir = Files.createTempDirectory("codecrow-rag-incremental-");
110-
try {
111-
int fetchedFiles = fetchFilesToTempDir(
112-
vcsConnection,
113-
workspaceSlug,
114-
repoSlug,
115-
branch,
116-
addedOrModifiedFiles,
117-
tempDir
118-
);
105+
int fetchedFiles = fetchFilesToTempDir(
106+
vcsConnection,
107+
workspaceSlug,
108+
repoSlug,
109+
branch,
110+
addedOrModifiedFiles,
111+
tempDir
112+
);
119113

120-
Map<String, Object> updateResult = ragPipelineClient.updateFiles(
121-
new ArrayList<>(addedOrModifiedFiles),
122-
tempDir.toString(),
123-
projectWorkspace,
124-
projectNamespace,
125-
branch,
126-
commitHash
127-
);
128-
129-
result.put("updatedFiles", fetchedFiles);
130-
result.putAll(updateResult);
131-
log.info("Updated {} files in RAG index", fetchedFiles);
132-
133-
} finally {
134-
deleteDirectory(tempDir.toFile());
135-
}
136-
} catch (Exception e) {
137-
log.error("Failed to update files in RAG index", e);
138-
result.put("updateError", e.getMessage());
114+
Map<String, Object> updateResult = ragPipelineClient.updateFiles(
115+
new ArrayList<>(addedOrModifiedFiles),
116+
tempDir.toString(),
117+
projectWorkspace,
118+
projectNamespace,
119+
branch,
120+
commitHash
121+
);
122+
123+
result.put("updatedFiles", fetchedFiles);
124+
result.putAll(updateResult);
125+
log.info("Updated {} files in RAG index", fetchedFiles);
126+
127+
} finally {
128+
deleteDirectory(tempDir.toFile());
139129
}
140130
}
141131

java-ecosystem/services/pipeline-agent/src/main/java/org/rostilos/codecrow/pipelineagent/generic/webhookhandler/WebhookProjectResolver.java

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,12 @@
55
import org.rostilos.codecrow.core.model.vcs.VcsRepoBinding;
66
import org.rostilos.codecrow.core.persistence.repository.project.ProjectRepository;
77
import org.rostilos.codecrow.core.persistence.repository.vcs.VcsRepoBindingRepository;
8+
import org.rostilos.codecrow.security.oauth.TokenEncryptionService;
89
import org.slf4j.Logger;
910
import org.slf4j.LoggerFactory;
1011
import org.springframework.stereotype.Service;
1112

13+
import java.security.GeneralSecurityException;
1214
import java.util.Optional;
1315

1416
/**
@@ -22,13 +24,16 @@ public class WebhookProjectResolver {
2224

2325
private final VcsRepoBindingRepository bindingRepository;
2426
private final ProjectRepository projectRepository;
27+
private final TokenEncryptionService tokenEncryptionService;
2528

2629
public WebhookProjectResolver(
2730
VcsRepoBindingRepository bindingRepository,
28-
ProjectRepository projectRepository
31+
ProjectRepository projectRepository,
32+
TokenEncryptionService tokenEncryptionService
2933
) {
3034
this.bindingRepository = bindingRepository;
3135
this.projectRepository = projectRepository;
36+
this.tokenEncryptionService = tokenEncryptionService;
3237
}
3338

3439
/**
@@ -72,7 +77,13 @@ public boolean validateWebhookAuth(Project project, String authToken) {
7277
if (project.getAuthToken() == null || authToken == null) {
7378
return false;
7479
}
75-
return project.getAuthToken().equals(authToken);
80+
try {
81+
String decryptedToken = tokenEncryptionService.decrypt(project.getAuthToken());
82+
return decryptedToken.equals(authToken);
83+
} catch (GeneralSecurityException e) {
84+
log.error("Failed to decrypt auth token for project {}", project.getId(), e);
85+
return false;
86+
}
7687
}
7788

7889
/**

java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/project/service/ProjectService.java

Lines changed: 54 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -239,15 +239,16 @@ public Project createProject(Long workspaceId, CreateProjectRequest request) thr
239239
newProject.setAiConnectionBinding(aiBinding);
240240
}
241241

242-
// generate internal auth token for the project
242+
// Generate a URL-safe auth token for webhook authentication, encrypted at rest.
243+
// The plaintext token uses URL-safe Base64 (no '/', '+', '=') so it can be
244+
// safely embedded in webhook URL paths. It's stored AES-encrypted in the DB
245+
// and decrypted when building webhook URLs or validating incoming webhooks.
243246
try {
244247
byte[] random = new byte[32];
245248
new SecureRandom().nextBytes(random);
246249
String plainToken = Base64.getUrlEncoder().withoutPadding().encodeToString(random);
247250
String encrypted = tokenEncryptionService.encrypt(plainToken);
248251
newProject.setAuthToken(encrypted);
249-
// Note: plainToken is not returned in API responses; store encrypted token
250-
// only.
251252
} catch (GeneralSecurityException e) {
252253
throw new SecurityException("Failed to generate project auth token");
253254
}
@@ -905,18 +906,41 @@ public WebhookSetupResult setupWebhooks(Long workspaceId, Long projectId) {
905906
return new WebhookSetupResult(false, null, null, "No VCS connection found for this project");
906907
}
907908

908-
// Generate webhook URL
909-
String webhookUrl = generateWebhookUrl(binding.getProvider(), project);
909+
// Ensure auth token exists and its plaintext form is URL-safe.
910+
// The token is stored AES-encrypted in the DB. We decrypt to verify that
911+
// the underlying plaintext is URL-safe (no '/', '+', '='). Older tokens
912+
// may have been generated without URL-safe encoding.
913+
try {
914+
boolean needsRegeneration = false;
915+
if (project.getAuthToken() == null || project.getAuthToken().isBlank()) {
916+
needsRegeneration = true;
917+
} else {
918+
try {
919+
String decrypted = tokenEncryptionService.decrypt(project.getAuthToken());
920+
if (!isUrlSafeToken(decrypted)) {
921+
needsRegeneration = true;
922+
}
923+
} catch (Exception e) {
924+
// Token can't be decrypted (corrupt or old format) — regenerate
925+
needsRegeneration = true;
926+
}
927+
}
910928

911-
// Ensure auth token exists
912-
if (project.getAuthToken() == null || project.getAuthToken().isBlank()) {
913-
byte[] randomBytes = new byte[32];
914-
new SecureRandom().nextBytes(randomBytes);
915-
String authToken = Base64.getUrlEncoder().withoutPadding().encodeToString(randomBytes);
916-
project.setAuthToken(authToken);
917-
projectRepository.save(project);
929+
if (needsRegeneration) {
930+
byte[] randomBytes = new byte[32];
931+
new SecureRandom().nextBytes(randomBytes);
932+
String plainToken = Base64.getUrlEncoder().withoutPadding().encodeToString(randomBytes);
933+
project.setAuthToken(tokenEncryptionService.encrypt(plainToken));
934+
projectRepository.save(project);
935+
log.info("Generated new encrypted URL-safe auth token for project {}", projectId);
936+
}
937+
} catch (GeneralSecurityException e) {
938+
return new WebhookSetupResult(false, null, null, "Failed to process auth token: " + e.getMessage());
918939
}
919940

941+
// Generate webhook URL AFTER token check so it reflects the correct token
942+
String webhookUrl = generateWebhookUrl(binding.getProvider(), project);
943+
920944
try {
921945
// Get VCS client and setup webhook
922946
org.rostilos.codecrow.vcsclient.VcsClient client = vcsClientProvider.getClient(connection);
@@ -990,7 +1014,24 @@ private String generateWebhookUrl(EVcsProvider provider, Project project) {
9901014
String base = (urls.webhookBaseUrl() != null && !urls.webhookBaseUrl().isBlank())
9911015
? urls.webhookBaseUrl()
9921016
: urls.baseUrl();
993-
return base + "/api/webhooks/" + provider.getId() + "/" + project.getAuthToken();
1017+
// Decrypt the stored token to get the URL-safe plaintext for the webhook path
1018+
String plainToken;
1019+
try {
1020+
plainToken = tokenEncryptionService.decrypt(project.getAuthToken());
1021+
} catch (GeneralSecurityException e) {
1022+
log.error("Failed to decrypt auth token for project {}", project.getId(), e);
1023+
throw new IllegalStateException("Cannot generate webhook URL: auth token decryption failed");
1024+
}
1025+
return base + "/api/webhooks/" + provider.getId() + "/" + plainToken;
1026+
}
1027+
1028+
/**
1029+
* Check if a token is safe for use in URL path segments.
1030+
* Standard Base64 contains '/', '+', '=' which break URL paths.
1031+
* URL-safe Base64 without padding only uses [A-Za-z0-9_-].
1032+
*/
1033+
private boolean isUrlSafeToken(String token) {
1034+
return token.indexOf('/') < 0 && token.indexOf('+') < 0 && token.indexOf('=') < 0;
9941035
}
9951036

9961037
private List<String> getWebhookEvents(EVcsProvider provider) {

0 commit comments

Comments
 (0)