diff --git a/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java b/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java index 9714f7a1dd..b55fc381bb 100644 --- a/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java +++ b/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java @@ -249,6 +249,8 @@ public class ApplicationProperties { * in-network object store. */ private boolean allowPrivateS3Endpoints = false; + + private long webhookMaxBytes = 104857600L; } @Data diff --git a/app/common/src/main/java/stirling/software/common/util/RequestUriUtils.java b/app/common/src/main/java/stirling/software/common/util/RequestUriUtils.java index ea14dcc5b3..a653ae6c0b 100644 --- a/app/common/src/main/java/stirling/software/common/util/RequestUriUtils.java +++ b/app/common/src/main/java/stirling/software/common/util/RequestUriUtils.java @@ -202,6 +202,7 @@ public class RequestUriUtils { || trimmedUri.startsWith("/readiness") || trimmedUri.startsWith( "/api/v1/mobile-scanner/") // Mobile scanner endpoints (no auth) + || trimmedUri.startsWith("/api/v1/webhooks/") || trimmedUri.startsWith("/v1/api-docs") // Workflow participant endpoints - access controlled by share tokens, not login || trimmedUri.startsWith("/api/v1/workflow/participant/") diff --git a/app/common/src/test/java/stirling/software/common/util/RequestUriUtilsTest.java b/app/common/src/test/java/stirling/software/common/util/RequestUriUtilsTest.java index a6fefd1091..1912f3808b 100644 --- a/app/common/src/test/java/stirling/software/common/util/RequestUriUtilsTest.java +++ b/app/common/src/test/java/stirling/software/common/util/RequestUriUtilsTest.java @@ -176,6 +176,13 @@ class RequestUriUtilsTest { assertFalse(RequestUriUtils.isPublicAuthEndpoint("/api/v1/convert", "")); } + @Test + void testIsPublicAuthEndpoint_webhookReceiver() { + // The webhook source receiver authenticates each delivery by HMAC signature, not a session. + assertTrue(RequestUriUtils.isPublicAuthEndpoint("/api/v1/webhooks/whk_abc123", "")); + assertTrue(RequestUriUtils.isPublicAuthEndpoint("/app/api/v1/webhooks/whk_abc123", "/app")); + } + @Test void testIsPublicAuthEndpoint_withContextPath() { assertTrue(RequestUriUtils.isPublicAuthEndpoint("/app/login", "/app")); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/input/InputSource.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/input/InputSource.java index d32c2fc546..15fb0d84f2 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/input/InputSource.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/input/InputSource.java @@ -3,6 +3,7 @@ package stirling.software.proprietary.policy.input; import java.io.IOException; import java.nio.file.Path; import java.util.List; +import java.util.Map; import stirling.software.proprietary.policy.model.InputSpec; @@ -22,6 +23,11 @@ public interface InputSource { /** Throws {@link IllegalArgumentException} on bad config. Called on save to fail fast. */ default void validate(InputSpec spec) {} + default Map prepareOptionsForSave( + Map options, boolean isCreate) { + return options; + } + /** * Resolve the spec into zero or more units of work, each carrying one run's files and a * completion hook. Empty list means nothing to run right now. Discovery is read-only - files diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/input/WebhookInputSource.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/input/WebhookInputSource.java new file mode 100644 index 0000000000..49cb3c70c4 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/input/WebhookInputSource.java @@ -0,0 +1,145 @@ +package stirling.software.proprietary.policy.input; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Stream; + +import org.springframework.core.io.FileSystemResource; +import org.springframework.core.io.Resource; +import org.springframework.stereotype.Service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import stirling.software.common.util.FileReadinessChecker; +import stirling.software.proprietary.policy.ledger.FolderIdentities; +import stirling.software.proprietary.policy.model.InputSpec; +import stirling.software.proprietary.policy.model.PolicyInputs; +import stirling.software.proprietary.policy.webhook.WebhookConfig; +import stirling.software.proprietary.policy.webhook.WebhookIds; +import stirling.software.proprietary.policy.webhook.WebhookSpool; + +@Slf4j +@Service +@RequiredArgsConstructor +public class WebhookInputSource implements InputSource { + + static final String TYPE = "webhook"; + + private final WebhookSpool spool; + private final FileReadinessChecker readinessChecker; + + @Override + public String type() { + return TYPE; + } + + @Override + public boolean supports(InputSpec spec) { + return spec != null && TYPE.equals(spec.type()); + } + + @Override + public void validate(InputSpec spec) { + WebhookConfig.from(spec.options()); + } + + @Override + public Map prepareOptionsForSave( + Map options, boolean isCreate) { + boolean hasId = + options.get(WebhookConfig.WEBHOOK_ID_OPTION) != null + && !options.get(WebhookConfig.WEBHOOK_ID_OPTION).toString().isBlank(); + if (!isCreate && hasId) { + return options; + } + Map prepared = new LinkedHashMap<>(options); + prepared.put(WebhookConfig.WEBHOOK_ID_OPTION, WebhookIds.newWebhookId()); + prepared.put(WebhookConfig.SIGNING_SECRET_OPTION, WebhookIds.newSigningSecret()); + return prepared; + } + + @Override + public List resolve(InputSpec spec, ResolveContext ctx) throws IOException { + WebhookConfig config = WebhookConfig.from(spec.options()); + Path dir = spool.dirFor(config.webhookId()); + if (!Files.isDirectory(dir)) { + ctx.reportPresent(List.of()); + return List.of(); + } + Path canonicalDir = FolderIdentities.canonicalDir(dir); + List present = listFiles(dir); + + ctx.reportPresent( + present.stream() + .map(file -> FolderIdentities.identity(canonicalDir, dir, file)) + .toList()); + + List work = new ArrayList<>(); + for (Path file : present) { + if (!readinessChecker.isReady(file)) { + continue; + } + String identity = FolderIdentities.identity(canonicalDir, dir, file); + String gate; + boolean claimed; + try { + gate = FolderIdentities.statGate(file); + claimed = ctx.claim(identity, gate, null); + } catch (IOException | UncheckedIOException e) { + log.debug("Could not read {} for its version: {}", file, e.getMessage()); + continue; + } + if (!claimed) { + continue; + } + work.add( + new ResolvedInput( + PolicyInputs.of(List.of(fileResource(file))), + success -> completeConsumed(ctx, identity, file, gate, success))); + } + return work; + } + + private static void completeConsumed( + ResolveContext ctx, String identity, Path file, String claimGate, boolean success) { + ctx.settle(identity, claimGate, null, success); + if (!success) { + return; + } + try { + if (FolderIdentities.statGate(file).equals(claimGate) && ctx.allSettledDone(identity)) { + Files.deleteIfExists(file); + } + } catch (java.nio.file.NoSuchFileException alreadyGone) { + } catch (IOException e) { + log.warn("Could not remove consumed webhook delivery {}: {}", file, e.getMessage()); + } + } + + private static List listFiles(Path dir) throws IOException { + List files = new ArrayList<>(); + try (Stream entries = Files.list(dir)) { + entries.filter(Files::isRegularFile) + .filter(file -> !file.getFileName().toString().startsWith(".")) + .forEach(files::add); + } + return files; + } + + private static Resource fileResource(Path path) { + String name = WebhookSpool.displayName(path.getFileName().toString()); + return new FileSystemResource(path.toFile()) { + @Override + public String getFilename() { + return name; + } + }; + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceController.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceController.java index b9a19e9d2f..4ad611f42e 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceController.java @@ -2,6 +2,7 @@ package stirling.software.proprietary.policy.source; import java.util.List; import java.util.Map; +import java.util.Optional; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; @@ -44,6 +45,8 @@ import stirling.software.proprietary.util.SecretMasker; @Tag(name = "Sources", description = "Reusable policy input connections") public class SourceController { + private static final String WEBHOOK_TYPE = "webhook"; + private final SourceStore sourceStore; private final SourceAccessGuard sourceAccessGuard; private final SourceOverviewService overviewService; @@ -107,7 +110,8 @@ public class SourceController { public ResponseEntity save(@RequestBody Source source) { requireSourceEditingAllowed(); requireNotEditor(source.id(), source.type()); - Source owned = withStoredSecrets(resolveOwnership(source)); + boolean isCreate = source.id() == null || source.id().isBlank(); + Source owned = withPreparedOptions(withStoredSecrets(resolveOwnership(source)), isCreate); try { validateConfig(owned); } catch (IllegalArgumentException e) { @@ -117,7 +121,7 @@ public class SourceController { // An edited folder source can change which directory needs watching, so re-sync trigger // registrations now instead of waiting for the next reconcile. policyTriggerManager.notifyPoliciesChanged(); - return ResponseEntity.ok(withMaskedSecrets(saved)); + return ResponseEntity.ok(revealOnCreate(saved, isCreate)); } @DeleteMapping("/{sourceId}") @@ -219,14 +223,33 @@ public class SourceController { /** Validate the config against the bean that handles the source's type, as the engine will. */ private void validateConfig(Source source) { InputSpec spec = source.toInputSpec(); - inputSources.stream() - .filter(inputSource -> inputSource.supports(spec)) - .findFirst() + inputSourceFor(spec) .orElseThrow( () -> new IllegalArgumentException("unknown source type: " + source.type())) .validate(spec); } + private Source withPreparedOptions(Source source, boolean isCreate) { + InputSpec spec = source.toInputSpec(); + InputSource input = inputSourceFor(spec).orElse(null); + if (input == null) { + return source; + } + Map prepared = input.prepareOptionsForSave(source.options(), isCreate); + return prepared == null ? source : withOptions(source, prepared); + } + + private static Source revealOnCreate(Source saved, boolean isCreate) { + if (isCreate && WEBHOOK_TYPE.equals(saved.type())) { + return saved; + } + return withMaskedSecrets(saved); + } + + private Optional inputSourceFor(InputSpec spec) { + return inputSources.stream().filter(input -> input.supports(spec)).findFirst(); + } + /** * Editing sources requires the editor role for the caller's team (a team leader on SaaS), the * same rule as policies. Single-user deployments (login disabled) trust the local operator. diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceOverviewService.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceOverviewService.java index 1f5eb18174..2c02b9f152 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceOverviewService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceOverviewService.java @@ -100,7 +100,8 @@ public class SourceOverviewService { List.of(), docs.total(), docs.last24h(), - docs.last30d()); + docs.last30d(), + null); } /** @@ -141,7 +142,16 @@ public class SourceOverviewService { configRows(source), docs.total(), docs.last24h(), - docs.last30d()); + docs.last30d(), + webhookPath(source)); + } + + private static String webhookPath(Source source) { + if (!"webhook".equals(source.type())) { + return null; + } + Object webhookId = source.options().get("webhookId"); + return webhookId == null ? null : "/api/v1/webhooks/" + webhookId; } /** A disabled (paused) source reads as "disabled"; an unreferenced one reads as "unused". */ diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceView.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceView.java index 6c6a9c4c6d..077edf62d8 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceView.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceView.java @@ -17,7 +17,8 @@ public record SourceView( List config, long docsTotal, long docs24h, - long docs30d) { + long docs30d, + String webhookPath) { /** A policy that references this source. */ public record PolicyRef(String id, String name) {} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/trigger/WebhookTrigger.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/trigger/WebhookTrigger.java new file mode 100644 index 0000000000..816f510bba --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/trigger/WebhookTrigger.java @@ -0,0 +1,130 @@ +package stirling.software.proprietary.policy.trigger; + +import java.util.Set; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; + +import org.springframework.stereotype.Service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import stirling.software.common.model.ApplicationProperties; +import stirling.software.proprietary.policy.engine.PolicyRunner; +import stirling.software.proprietary.policy.engine.SweepKind; +import stirling.software.proprietary.policy.model.Policy; +import stirling.software.proprietary.policy.source.Source; +import stirling.software.proprietary.policy.source.SourceStore; +import stirling.software.proprietary.policy.store.PolicyStore; +import stirling.software.proprietary.policy.webhook.WebhookConfig; + +@Slf4j +@Service +@RequiredArgsConstructor +public class WebhookTrigger implements PolicyTrigger { + + static final String TYPE = "webhook"; + private static final String WEBHOOK_SOURCE_TYPE = "webhook"; + + private final PolicyStore policyStore; + private final PolicyRunner policyRunner; + private final SourceStore sourceStore; + private final ApplicationProperties applicationProperties; + + private volatile ScheduledExecutorService reconciler; + + @Override + public String type() { + return TYPE; + } + + @Override + public boolean requiresSource() { + return true; + } + + @Override + public Set supportedSourceTypes() { + return Set.of(WEBHOOK_SOURCE_TYPE); + } + + @Override + public void validate(Policy policy) { + boolean hasWebhookSource = + policy.sourceIds().stream() + .map(sourceStore::get) + .flatMap(java.util.Optional::stream) + .anyMatch(source -> WEBHOOK_SOURCE_TYPE.equals(source.type())); + if (!hasWebhookSource) { + throw new IllegalArgumentException( + "webhook trigger requires at least one webhook input source"); + } + } + + @Override + public synchronized void start() { + if (reconciler != null) { + return; + } + long reconcileSeconds = applicationProperties.getPolicies().getWatchReconcileSeconds(); + reconciler = + Executors.newSingleThreadScheduledExecutor( + Thread.ofVirtual().name("policy-webhook-reconcile-", 0).factory()); + reconciler.scheduleAtFixedRate(this::safeReconcile, 0, reconcileSeconds, TimeUnit.SECONDS); + log.info("Webhook trigger started (reconcile every {}s)", reconcileSeconds); + } + + @Override + public synchronized void stop() { + if (reconciler != null) { + reconciler.shutdownNow(); + reconciler = null; + } + } + + public void fireForWebhook(String webhookId) { + for (Policy policy : policyStore.findByTriggerType(TYPE)) { + if (!referencesWebhook(policy, webhookId)) { + continue; + } + try { + log.debug("Webhook policy {} ({}) saw a delivery", policy.id(), policy.name()); + policyRunner.run(policy, SweepKind.LIGHT); + } catch (RuntimeException e) { + log.warn("Webhook run failed for policy {}: {}", policy.id(), e.getMessage()); + } + } + } + + private void safeReconcile() { + try { + for (Policy policy : policyStore.findByTriggerType(TYPE)) { + try { + policyRunner.run(policy); + } catch (RuntimeException e) { + log.warn( + "Webhook reconcile run failed for policy {}: {}", + policy.id(), + e.getMessage()); + } + } + } catch (RuntimeException e) { + log.error("Webhook reconcile failed: {}", e.getMessage(), e); + } + } + + private boolean referencesWebhook(Policy policy, String webhookId) { + for (String sourceId : policy.sourceIds()) { + Source source = sourceStore.get(sourceId).orElse(null); + if (source == null || !WEBHOOK_SOURCE_TYPE.equals(source.type())) { + continue; + } + Object configured = source.options().get(WebhookConfig.WEBHOOK_ID_OPTION); + if (configured != null && configured.toString().equals(webhookId)) { + return true; + } + } + return false; + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/webhook/WebhookConfig.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/webhook/WebhookConfig.java new file mode 100644 index 0000000000..4757513314 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/webhook/WebhookConfig.java @@ -0,0 +1,37 @@ +package stirling.software.proprietary.policy.webhook; + +import java.util.Map; + +public record WebhookConfig(String webhookId, String signingSecret) { + + public static final String WEBHOOK_ID_OPTION = "webhookId"; + public static final String SIGNING_SECRET_OPTION = "signingSecret"; + + public static WebhookConfig from(Map options) { + String webhookId = trimmed(options.get(WEBHOOK_ID_OPTION)); + if (webhookId == null) { + throw new IllegalArgumentException("webhook config requires a 'webhookId' option"); + } + if (!WebhookIds.isValidId(webhookId)) { + throw new IllegalArgumentException("webhook config 'webhookId' has an invalid format"); + } + String signingSecret = trimmed(options.get(SIGNING_SECRET_OPTION)); + if (signingSecret == null) { + throw new IllegalArgumentException("webhook config requires a 'signingSecret' option"); + } + return new WebhookConfig(webhookId, signingSecret); + } + + private static String trimmed(Object value) { + if (value == null) { + return null; + } + String text = value.toString().trim(); + return text.isEmpty() ? null : text; + } + + @Override + public String toString() { + return "WebhookConfig[webhookId=" + webhookId + "]"; + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/webhook/WebhookIds.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/webhook/WebhookIds.java new file mode 100644 index 0000000000..098e7d2972 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/webhook/WebhookIds.java @@ -0,0 +1,33 @@ +package stirling.software.proprietary.policy.webhook; + +import java.security.SecureRandom; +import java.util.Base64; +import java.util.regex.Pattern; + +public final class WebhookIds { + + private static final Pattern VALID_ID = Pattern.compile("^[A-Za-z0-9_-]{16,128}$"); + + private static final SecureRandom RANDOM = new SecureRandom(); + private static final Base64.Encoder ENCODER = Base64.getUrlEncoder().withoutPadding(); + + private WebhookIds() {} + + public static String newWebhookId() { + return randomToken(18); + } + + public static String newSigningSecret() { + return randomToken(32); + } + + public static boolean isValidId(String id) { + return id != null && VALID_ID.matcher(id).matches(); + } + + private static String randomToken(int bytes) { + byte[] buffer = new byte[bytes]; + RANDOM.nextBytes(buffer); + return ENCODER.encodeToString(buffer); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/webhook/WebhookReceiverController.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/webhook/WebhookReceiverController.java new file mode 100644 index 0000000000..509c6c7fd3 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/webhook/WebhookReceiverController.java @@ -0,0 +1,150 @@ +package stirling.software.proprietary.policy.webhook; + +import java.io.IOException; +import java.io.InputStream; +import java.util.Arrays; + +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.server.ResponseStatusException; + +import io.swagger.v3.oas.annotations.Hidden; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; + +import jakarta.servlet.http.HttpServletRequest; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import stirling.software.common.model.ApplicationProperties; +import stirling.software.proprietary.policy.source.Source; +import stirling.software.proprietary.policy.source.SourceStore; +import stirling.software.proprietary.policy.trigger.WebhookTrigger; + +@Slf4j +@RestController +@RequestMapping("/api/v1/webhooks") +@Hidden +@RequiredArgsConstructor +@Tag(name = "Webhooks", description = "Inbound webhook source receiver") +public class WebhookReceiverController { + + static final String SIGNATURE_HEADER = "X-Stirling-Signature"; + static final String FILENAME_HEADER = "X-Stirling-Filename"; + private static final String WEBHOOK_TYPE = "webhook"; + + private final SourceStore sourceStore; + private final WebhookSpool spool; + private final WebhookTrigger webhookTrigger; + private final ApplicationProperties applicationProperties; + + @PostMapping("/{webhookId}") + @Operation( + summary = "Deliver a document to a webhook source", + description = + "The body is the raw document; sign it with the source's secret and present" + + " 'sha256=' in the X-Stirling-Signature header. Returns 202 once" + + " the document is spooled for the referencing policies.") + public ResponseEntity receive( + @PathVariable String webhookId, + @RequestHeader(value = SIGNATURE_HEADER, required = false) String signature, + @RequestHeader(value = FILENAME_HEADER, required = false) String filename, + HttpServletRequest request) { + if (!WebhookIds.isValidId(webhookId)) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "No such webhook"); + } + Source source = findWebhookSource(webhookId); + if (source == null) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "No such webhook"); + } + + WebhookConfig config = WebhookConfig.from(source.options()); + byte[] body = readBoundedBody(request); + if (!WebhookSignatures.verify(config.signingSecret(), body, signature)) { + throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Invalid signature"); + } + if (!source.enabled()) { + throw new ResponseStatusException( + HttpStatus.FORBIDDEN, "Webhook source is paused; deliveries are not accepted"); + } + if (body.length == 0) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Empty request body"); + } + + String storedName = stageToSpool(webhookId, filename, body); + + webhookTrigger.fireForWebhook(webhookId); + log.info( + "Accepted webhook delivery '{}' ({} bytes) for {}", + storedName, + body.length, + webhookId); + return ResponseEntity.accepted() + .contentType(MediaType.APPLICATION_JSON) + .body(new WebhookDeliveryResponse(true, storedName, body.length)); + } + + private Source findWebhookSource(String webhookId) { + for (Source source : sourceStore.all()) { + if (!WEBHOOK_TYPE.equals(source.type())) { + continue; + } + Object configured = source.options().get(WebhookConfig.WEBHOOK_ID_OPTION); + if (configured != null && configured.toString().equals(webhookId)) { + return source; + } + } + return null; + } + + private String stageToSpool(String webhookId, String filename, byte[] body) { + try { + return WebhookSpool.displayName( + spool.store(webhookId, filename, body).getFileName().toString()); + } catch (IOException e) { + log.error("Could not spool webhook delivery for {}: {}", webhookId, e.getMessage()); + throw new ResponseStatusException( + HttpStatus.INTERNAL_SERVER_ERROR, "Could not store delivery"); + } + } + + private byte[] readBoundedBody(HttpServletRequest request) { + long maxBytes = applicationProperties.getPolicies().getWebhookMaxBytes(); + long declared = request.getContentLengthLong(); + if (declared < 0) { + throw new ResponseStatusException( + HttpStatus.LENGTH_REQUIRED, "A Content-Length header is required"); + } + if (declared > maxBytes) { + throw new ResponseStatusException( + HttpStatus.PAYLOAD_TOO_LARGE, + "Delivery exceeds the " + maxBytes + "-byte limit"); + } + byte[] body = new byte[(int) declared]; + int total = 0; + try (InputStream in = request.getInputStream()) { + int read; + while (total < body.length + && (read = in.read(body, total, body.length - total)) != -1) { + total += read; + } + if (total == body.length && in.read() != -1) { + throw new ResponseStatusException( + HttpStatus.BAD_REQUEST, "Body exceeds the declared Content-Length"); + } + } catch (IOException e) { + throw new ResponseStatusException( + HttpStatus.BAD_REQUEST, "Could not read request body"); + } + return total == body.length ? body : Arrays.copyOf(body, total); + } + + public record WebhookDeliveryResponse(boolean accepted, String filename, int bytes) {} +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/webhook/WebhookSignatures.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/webhook/WebhookSignatures.java new file mode 100644 index 0000000000..3373bc7503 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/webhook/WebhookSignatures.java @@ -0,0 +1,49 @@ +package stirling.software.proprietary.policy.webhook; + +import java.nio.charset.StandardCharsets; +import java.security.InvalidKeyException; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HexFormat; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; + +public final class WebhookSignatures { + + private static final String ALGORITHM = "HmacSHA256"; + private static final String PREFIX = "sha256="; + + private WebhookSignatures() {} + + public static String sign(String signingSecret, byte[] body) { + return PREFIX + HexFormat.of().formatHex(hmac(signingSecret, body)); + } + + public static boolean verify(String signingSecret, byte[] body, String presented) { + if (signingSecret == null || presented == null || body == null) { + return false; + } + String hex = presented.trim(); + if (hex.regionMatches(true, 0, PREFIX, 0, PREFIX.length())) { + hex = hex.substring(PREFIX.length()); + } + byte[] presentedBytes; + try { + presentedBytes = HexFormat.of().parseHex(hex); + } catch (IllegalArgumentException notHex) { + return false; + } + return MessageDigest.isEqual(hmac(signingSecret, body), presentedBytes); + } + + private static byte[] hmac(String signingSecret, byte[] body) { + try { + Mac mac = Mac.getInstance(ALGORITHM); + mac.init(new SecretKeySpec(signingSecret.getBytes(StandardCharsets.UTF_8), ALGORITHM)); + return mac.doFinal(body); + } catch (NoSuchAlgorithmException | InvalidKeyException e) { + throw new IllegalStateException("HMAC-SHA256 unavailable", e); + } + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/webhook/WebhookSpool.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/webhook/WebhookSpool.java new file mode 100644 index 0000000000..6a8377c67a --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/webhook/WebhookSpool.java @@ -0,0 +1,87 @@ +package stirling.software.proprietary.policy.webhook; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.UUID; + +import org.springframework.stereotype.Component; + +import stirling.software.common.configuration.InstallationPathConfig; + +@Component +public class WebhookSpool { + + private static final String SPOOL_DIR = "policy-webhook-spool"; + private static final String TEMP_SUFFIX = ".part"; + private static final String DEFAULT_NAME = "document.pdf"; + private static final int UNIQUE_LEN = 32; + + private final Path spoolRoot; + + public WebhookSpool() { + this(Path.of(InstallationPathConfig.getPath(), SPOOL_DIR)); + } + + public WebhookSpool(Path spoolRoot) { + this.spoolRoot = spoolRoot.toAbsolutePath().normalize(); + } + + public Path dirFor(String webhookId) { + if (!WebhookIds.isValidId(webhookId)) { + throw new IllegalArgumentException("invalid webhookId"); + } + Path dir = spoolRoot.resolve(webhookId).normalize(); + if (!dir.getParent().equals(spoolRoot)) { + throw new IllegalArgumentException("invalid webhookId"); + } + return dir; + } + + public Path store(String webhookId, String filename, byte[] content) throws IOException { + Path dir = dirFor(webhookId); + Files.createDirectories(dir); + String finalName = spoolName(filename); + Path target = dir.resolve(finalName).normalize(); + Path temp = dir.resolve("." + finalName + TEMP_SUFFIX).normalize(); + if (!target.startsWith(dir) || !temp.startsWith(dir)) { + throw new IllegalArgumentException("invalid delivery name"); + } + Files.write(temp, content); + try { + Files.move(temp, target, StandardCopyOption.ATOMIC_MOVE); + } catch (IOException atomicUnsupported) { + Files.move(temp, target, StandardCopyOption.REPLACE_EXISTING); + } + return target; + } + + static String spoolName(String filename) { + return UUID.randomUUID().toString().replace("-", "") + "-" + sanitize(filename); + } + + public static String displayName(String spoolFileName) { + int dash = spoolFileName.indexOf('-'); + if (dash == UNIQUE_LEN && dash + 1 < spoolFileName.length()) { + return spoolFileName.substring(dash + 1); + } + return spoolFileName; + } + + private static String sanitize(String filename) { + if (filename == null) { + return DEFAULT_NAME; + } + String base = filename.replace('\\', '/'); + int slash = base.lastIndexOf('/'); + if (slash >= 0) { + base = base.substring(slash + 1); + } + base = base.replaceAll("[^A-Za-z0-9._-]", "_").trim(); + while (base.startsWith(".")) { + base = base.substring(1); + } + return base.isEmpty() ? DEFAULT_NAME : base; + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/util/SecretMasker.java b/app/proprietary/src/main/java/stirling/software/proprietary/util/SecretMasker.java index 5a9619c2dd..f7ce2569f2 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/util/SecretMasker.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/util/SecretMasker.java @@ -22,7 +22,7 @@ public final class SecretMasker { .getPattern( // secret[_-]?access[_-]?key precedes plain secret so camelCase keys // like secretAccessKey (no word boundary after "secret") still match. - "(?i)\\b(password|token|secret[_-]?access[_-]?key|secret|api[_-]?key|authorization|auth|jwt|cred|cert)\\b"); + "(?i)\\b(password|token|secret[_-]?access[_-]?key|signing[_-]?secret|secret|api[_-]?key|authorization|auth|jwt|cred|cert)\\b"); private SecretMasker() {} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/input/WebhookInputSourceTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/input/WebhookInputSourceTest.java new file mode 100644 index 0000000000..9255e82d19 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/input/WebhookInputSourceTest.java @@ -0,0 +1,165 @@ +package stirling.software.proprietary.policy.input; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.lenient; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.function.Supplier; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import stirling.software.common.util.FileReadinessChecker; +import stirling.software.proprietary.policy.ledger.InProcessProcessedLedger; +import stirling.software.proprietary.policy.model.InputSpec; +import stirling.software.proprietary.policy.webhook.WebhookConfig; +import stirling.software.proprietary.policy.webhook.WebhookSpool; + +@ExtendWith(MockitoExtension.class) +class WebhookInputSourceTest { + + private static final String POLICY = "p1"; + private static final String WEBHOOK_ID = "testwebhookid1234"; + + @Mock private FileReadinessChecker readinessChecker; + + @TempDir Path tempDir; + + private WebhookSpool spool; + private WebhookInputSource source; + private InProcessProcessedLedger ledger; + private RecordingContext ctx; + + @BeforeEach + void setUp() { + spool = new WebhookSpool(tempDir.resolve("spool")); + source = new WebhookInputSource(spool, readinessChecker); + ledger = new InProcessProcessedLedger(); + ctx = new RecordingContext(); + lenient().when(readinessChecker.isReady(any())).thenReturn(true); + } + + private static InputSpec spec(String mode) { + return new InputSpec( + "webhook", + Map.of("webhookId", WEBHOOK_ID, "signingSecret", "secret", "mode", mode)); + } + + @Test + void consumeRemovesTheDeliveryOnceProcessed() throws IOException { + Path delivered = spool.store(WEBHOOK_ID, "doc.pdf", "data".getBytes()); + + List work = source.resolve(spec("consume"), ctx); + + assertEquals(1, work.size()); + assertEquals("doc.pdf", work.get(0).inputs().primary().get(0).getFilename()); + assertTrue(Files.exists(delivered)); + assertTrue(source.resolve(spec("consume"), ctx).isEmpty()); + + work.get(0).onComplete().accept(true); + assertTrue(Files.notExists(delivered)); + assertTrue(source.resolve(spec("consume"), ctx).isEmpty()); + } + + @Test + void aFailedRunLeavesTheDeliveryInPlace() throws IOException { + Path delivered = spool.store(WEBHOOK_ID, "doc.pdf", "data".getBytes()); + + List work = source.resolve(spec("consume"), ctx); + work.get(0).onComplete().accept(false); + + assertTrue(Files.exists(delivered)); + } + + @Test + void nothingDeliveredIsAnEmptySourceNotAnError() throws IOException { + List work = source.resolve(spec("consume"), ctx); + assertTrue(work.isEmpty()); + assertTrue(ctx.present.isEmpty()); + } + + @Test + void validateRejectsMissingIdOrSecret() { + assertThrows( + IllegalArgumentException.class, + () -> source.validate(new InputSpec("webhook", Map.of("signingSecret", "s")))); + assertThrows( + IllegalArgumentException.class, + () -> source.validate(new InputSpec("webhook", Map.of("webhookId", WEBHOOK_ID)))); + } + + @Test + void prepareMintsIdAndSecretOnCreate() { + Map prepared = + source.prepareOptionsForSave(Map.of("mode", "consume"), true); + + String id = prepared.get(WebhookConfig.WEBHOOK_ID_OPTION).toString(); + String secret = prepared.get(WebhookConfig.SIGNING_SECRET_OPTION).toString(); + assertFalse(id.isBlank()); + assertFalse(secret.isBlank()); + assertEquals("consume", prepared.get("mode")); + Map other = source.prepareOptionsForSave(Map.of(), true); + assertNotEquals(id, other.get(WebhookConfig.WEBHOOK_ID_OPTION).toString()); + } + + @Test + void prepareLeavesAnExistingWebhookUntouchedOnEdit() { + Map existing = Map.of("webhookId", WEBHOOK_ID, "signingSecret", "keepme"); + + Map prepared = source.prepareOptionsForSave(existing, false); + + assertEquals(WEBHOOK_ID, prepared.get("webhookId")); + assertEquals("keepme", prepared.get("signingSecret")); + } + + @Test + void prepareIgnoresClientSuppliedIdAndSecretOnCreate() { + Map prepared = + source.prepareOptionsForSave( + Map.of("webhookId", "client-chosen-id", "signingSecret", "weak"), true); + + assertNotEquals("client-chosen-id", prepared.get(WebhookConfig.WEBHOOK_ID_OPTION)); + assertNotEquals("weak", prepared.get(WebhookConfig.SIGNING_SECRET_OPTION)); + } + + private class RecordingContext implements ResolveContext { + + private final List present = new ArrayList<>(); + + @Override + public boolean claim(String identity, String gate, Supplier contentHash) { + return ledger.claim(POLICY, identity, gate, contentHash); + } + + @Override + public void settle( + String identity, String finalGate, String finalContentHash, boolean success) { + ledger.settle(POLICY, identity, finalGate, finalContentHash, success); + } + + @Override + public boolean allSettledDone(String identity) { + return ledger.allSettledDone(identity); + } + + @Override + public void reportPresent(Collection identities) { + present.addAll(identities); + } + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/source/SourceControllerTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/source/SourceControllerTest.java index 295b4785b8..545ea8e50f 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/policy/source/SourceControllerTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/source/SourceControllerTest.java @@ -1,33 +1,41 @@ package stirling.software.proprietary.policy.source; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import java.nio.file.Path; import java.util.List; import java.util.Map; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; import org.springframework.http.ResponseEntity; import org.springframework.web.server.ResponseStatusException; import stirling.software.common.model.ApplicationProperties; import stirling.software.common.service.UserServiceInterface; +import stirling.software.common.util.FileReadinessChecker; import stirling.software.proprietary.policy.config.PolicyAccessGuard; import stirling.software.proprietary.policy.config.PolicyManagementAuthority; import stirling.software.proprietary.policy.input.InputSource; +import stirling.software.proprietary.policy.input.WebhookInputSource; import stirling.software.proprietary.policy.model.OutputSpec; import stirling.software.proprietary.policy.model.PipelineStep; import stirling.software.proprietary.policy.model.Policy; import stirling.software.proprietary.policy.store.InProcessPolicyStore; import stirling.software.proprietary.policy.store.PolicyStore; import stirling.software.proprietary.policy.trigger.PolicyTriggerManager; +import stirling.software.proprietary.policy.webhook.WebhookSpool; import stirling.software.proprietary.util.SecretMasker; /** @@ -41,6 +49,9 @@ class SourceControllerTest { private final PolicyStore policyStore = new InProcessPolicyStore(); private PolicyTriggerManager triggerManager; private SourceController controller; + private SourceController webhookController; + + @TempDir Path tempDir; @BeforeEach void setUp() { @@ -61,6 +72,8 @@ class SourceControllerTest { // A permissive input source so config validation passes and save can be exercised. InputSource folderInput = mock(InputSource.class); when(folderInput.supports(any())).thenReturn(true); + when(folderInput.prepareOptionsForSave(any(), anyBoolean())) + .thenAnswer(invocation -> invocation.getArgument(0)); controller = new SourceController( sourceStore, @@ -72,6 +85,45 @@ class SourceControllerTest { triggerManager, properties, List.of(folderInput)); + WebhookInputSource webhookInput = + new WebhookInputSource(new WebhookSpool(tempDir), mock(FileReadinessChecker.class)); + webhookController = + new SourceController( + sourceStore, + sourceGuard, + overviewService, + policyStore, + policyGuard, + authority, + triggerManager, + properties, + List.of(webhookInput)); + } + + @Test + void creatingAWebhookRevealsItsSecretOnceThenMasks() { + Source created = + webhookController + .save( + new Source( + null, + "Partner uploads", + "webhook", + Map.of("mode", "consume"), + true, + null, + null)) + .getBody(); + + String secret = String.valueOf(created.options().get("signingSecret")); + String webhookId = String.valueOf(created.options().get("webhookId")); + assertNotEquals(SecretMasker.REDACTED, secret); + assertFalse(secret.isBlank()); + assertFalse(webhookId.isBlank()); + + Source read = webhookController.get(created.id()).getBody(); + assertEquals(SecretMasker.REDACTED, read.options().get("signingSecret")); + assertEquals(webhookId, read.options().get("webhookId")); } @Test diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/trigger/WebhookTriggerTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/trigger/WebhookTriggerTest.java new file mode 100644 index 0000000000..63b721dd6d --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/trigger/WebhookTriggerTest.java @@ -0,0 +1,115 @@ +package stirling.software.proprietary.policy.trigger; + +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import stirling.software.common.model.ApplicationProperties; +import stirling.software.proprietary.policy.engine.PolicyRunner; +import stirling.software.proprietary.policy.engine.SweepKind; +import stirling.software.proprietary.policy.model.OutputSpec; +import stirling.software.proprietary.policy.model.PipelineStep; +import stirling.software.proprietary.policy.model.Policy; +import stirling.software.proprietary.policy.model.TriggerConfig; +import stirling.software.proprietary.policy.source.InProcessSourceStore; +import stirling.software.proprietary.policy.source.Source; +import stirling.software.proprietary.policy.source.SourceStore; +import stirling.software.proprietary.policy.store.PolicyStore; + +@ExtendWith(MockitoExtension.class) +class WebhookTriggerTest { + + private static final String TYPE = "webhook"; + + @Mock private PolicyStore policyStore; + @Mock private PolicyRunner policyRunner; + + private final SourceStore sourceStore = new InProcessSourceStore(); + private WebhookTrigger trigger; + + @BeforeEach + void setUp() { + trigger = + new WebhookTrigger( + policyStore, policyRunner, sourceStore, new ApplicationProperties()); + } + + @Test + void firesOnlyPoliciesReferencingTheDeliveredWebhook() { + Policy matching = webhookPolicy("a", "whkA"); + Policy other = webhookPolicy("b", "whkB"); + when(policyStore.findByTriggerType(TYPE)).thenReturn(List.of(matching, other)); + + trigger.fireForWebhook("whkA"); + + verify(policyRunner).run(matching, SweepKind.LIGHT); + verify(policyRunner, never()).run(other, SweepKind.LIGHT); + } + + @Test + void ignoresADeliveryForAnUnknownWebhookId() { + Policy policy = webhookPolicy("a", "whkA"); + when(policyStore.findByTriggerType(TYPE)).thenReturn(List.of(policy)); + + trigger.fireForWebhook("whkZ"); + + verify(policyRunner, never()).run(any(), any(SweepKind.class)); + } + + @Test + void validateRequiresAWebhookSource() { + assertThrows( + IllegalArgumentException.class, + () -> trigger.validate(policy("p", webhookTriggerConfig(), List.of()))); + trigger.validate(webhookPolicy("p", "whkA")); + } + + private static TriggerConfig webhookTriggerConfig() { + return new TriggerConfig(TYPE, Map.of()); + } + + private Policy webhookPolicy(String id, String webhookId) { + String sourceId = + sourceStore + .save( + new Source( + null, + "hook", + "webhook", + Map.of( + "webhookId", + webhookId, + "signingSecret", + "s", + "mode", + "consume"), + true, + "owner", + null)) + .id(); + return policy(id, webhookTriggerConfig(), List.of(sourceId)); + } + + private static Policy policy(String id, TriggerConfig trigger, List sourceIds) { + return new Policy( + id, + "hook", + "owner", + true, + trigger, + sourceIds, + List.of(new PipelineStep("/api/v1/misc/compress-pdf", Map.of())), + OutputSpec.inline()); + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/webhook/WebhookLocalDeliveryE2eTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/webhook/WebhookLocalDeliveryE2eTest.java new file mode 100644 index 0000000000..774dc8e8bc --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/webhook/WebhookLocalDeliveryE2eTest.java @@ -0,0 +1,137 @@ +package stirling.software.proprietary.policy.webhook; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.function.Supplier; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.springframework.mock.web.MockHttpServletRequest; + +import stirling.software.common.model.ApplicationProperties; +import stirling.software.common.util.FileReadinessChecker; +import stirling.software.proprietary.policy.input.ResolveContext; +import stirling.software.proprietary.policy.input.ResolvedInput; +import stirling.software.proprietary.policy.input.WebhookInputSource; +import stirling.software.proprietary.policy.ledger.InProcessProcessedLedger; +import stirling.software.proprietary.policy.model.InputSpec; +import stirling.software.proprietary.policy.source.InProcessSourceStore; +import stirling.software.proprietary.policy.source.Source; +import stirling.software.proprietary.policy.source.SourceStore; +import stirling.software.proprietary.policy.trigger.WebhookTrigger; + +class WebhookLocalDeliveryE2eTest { + + private static final String POLICY = "p1"; + private static final String WEBHOOK_ID = "localwebhookid12"; + private static final String SECRET = "topsecret"; + + @TempDir Path tempDir; + + private WebhookReceiverController receiver; + private WebhookInputSource inputSource; + private WebhookTrigger trigger; + private InProcessProcessedLedger ledger; + private RecordingContext ctx; + + @BeforeEach + void setUp() { + WebhookSpool spool = new WebhookSpool(tempDir.resolve("spool")); + SourceStore sourceStore = new InProcessSourceStore(); + sourceStore.save( + new Source( + "s1", + "Partner uploads", + "webhook", + Map.of("webhookId", WEBHOOK_ID, "signingSecret", SECRET, "mode", "consume"), + true, + "owner", + null)); + trigger = mock(WebhookTrigger.class); + FileReadinessChecker readiness = mock(FileReadinessChecker.class); + when(readiness.isReady(any())).thenReturn(true); + receiver = + new WebhookReceiverController( + sourceStore, spool, trigger, new ApplicationProperties()); + inputSource = new WebhookInputSource(spool, readiness); + ledger = new InProcessProcessedLedger(); + ctx = new RecordingContext(); + } + + @Test + void aDeliveryIsSpooledFiresTheTriggerThenIsReadAndConsumed() throws IOException { + byte[] body = "a pdf".getBytes(StandardCharsets.UTF_8); + String signature = WebhookSignatures.sign(SECRET, body); + + var response = receiver.receive(WEBHOOK_ID, signature, "invoice.pdf", request(body)); + assertThat(response.getStatusCode().value()).isEqualTo(202); + verify(trigger).fireForWebhook(WEBHOOK_ID); + + List work = inputSource.resolve(spec(), ctx); + assertThat(work).hasSize(1); + assertThat(work.get(0).inputs().primary().get(0).getFilename()).isEqualTo("invoice.pdf"); + assertThat(read(work.get(0))).isEqualTo("a pdf"); + assertThat(inputSource.resolve(spec(), ctx)).isEmpty(); + + work.get(0).onComplete().accept(true); + assertThat(inputSource.resolve(spec(), ctx)).isEmpty(); + } + + private static InputSpec spec() { + return new InputSpec( + "webhook", + Map.of("webhookId", WEBHOOK_ID, "signingSecret", SECRET, "mode", "consume")); + } + + private static MockHttpServletRequest request(byte[] body) { + MockHttpServletRequest req = + new MockHttpServletRequest("POST", "/api/v1/webhooks/" + WEBHOOK_ID); + req.setContent(body); + return req; + } + + private static String read(ResolvedInput unit) throws IOException { + try (InputStream stream = unit.inputs().primary().get(0).getInputStream()) { + return new String(stream.readAllBytes(), StandardCharsets.UTF_8); + } + } + + private class RecordingContext implements ResolveContext { + + private final List present = new ArrayList<>(); + + @Override + public boolean claim(String identity, String gate, Supplier contentHash) { + return ledger.claim(POLICY, identity, gate, contentHash); + } + + @Override + public void settle( + String identity, String finalGate, String finalContentHash, boolean success) { + ledger.settle(POLICY, identity, finalGate, finalContentHash, success); + } + + @Override + public boolean allSettledDone(String identity) { + return ledger.allSettledDone(identity); + } + + @Override + public void reportPresent(Collection identities) { + present.addAll(identities); + } + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/webhook/WebhookReceiverControllerTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/webhook/WebhookReceiverControllerTest.java new file mode 100644 index 0000000000..4a9a94b5ce --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/webhook/WebhookReceiverControllerTest.java @@ -0,0 +1,193 @@ +package stirling.software.proprietary.policy.webhook; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import java.util.stream.Stream; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.springframework.http.ResponseEntity; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.web.server.ResponseStatusException; + +import stirling.software.common.model.ApplicationProperties; +import stirling.software.proprietary.policy.source.InProcessSourceStore; +import stirling.software.proprietary.policy.source.Source; +import stirling.software.proprietary.policy.source.SourceStore; +import stirling.software.proprietary.policy.trigger.WebhookTrigger; +import stirling.software.proprietary.policy.webhook.WebhookReceiverController.WebhookDeliveryResponse; + +class WebhookReceiverControllerTest { + + private static final String WEBHOOK_ID = "receivertestid12"; + private static final String SECRET = "topsecret"; + private static final byte[] BODY = "a pdf".getBytes(StandardCharsets.UTF_8); + + @TempDir Path tempDir; + + private SourceStore sourceStore; + private WebhookSpool spool; + private WebhookTrigger trigger; + private ApplicationProperties properties; + private WebhookReceiverController controller; + + @BeforeEach + void setUp() { + sourceStore = new InProcessSourceStore(); + sourceStore.save(webhookSource(true)); + spool = new WebhookSpool(tempDir.resolve("spool")); + trigger = mock(WebhookTrigger.class); + properties = new ApplicationProperties(); + controller = new WebhookReceiverController(sourceStore, spool, trigger, properties); + } + + private static Source webhookSource(boolean enabled) { + return new Source( + "s1", + "Partner uploads", + "webhook", + Map.of("webhookId", WEBHOOK_ID, "signingSecret", SECRET, "mode", "consume"), + enabled, + "owner", + null); + } + + private static MockHttpServletRequest request(byte[] body) { + MockHttpServletRequest req = + new MockHttpServletRequest("POST", "/api/v1/webhooks/" + WEBHOOK_ID); + req.setContent(body); + return req; + } + + @Test + void aValidDeliveryIsSpooledAndFiresTheTrigger() throws IOException { + String signature = WebhookSignatures.sign(SECRET, BODY); + + ResponseEntity response = + controller.receive(WEBHOOK_ID, signature, "invoice.pdf", request(BODY)); + + assertEquals(202, response.getStatusCode().value()); + assertTrue(response.getBody().accepted()); + assertEquals("invoice.pdf", response.getBody().filename()); + assertEquals(1, spooledFiles().size()); + verify(trigger).fireForWebhook(WEBHOOK_ID); + } + + @Test + void aWrongSignatureIsRejectedAndStoresNothing() { + ResponseStatusException ex = + assertThrows( + ResponseStatusException.class, + () -> + controller.receive( + WEBHOOK_ID, "sha256=deadbeef", "x.pdf", request(BODY))); + + assertEquals(401, ex.getStatusCode().value()); + assertTrue(spooledFiles().isEmpty()); + verify(trigger, never()).fireForWebhook(WEBHOOK_ID); + } + + @Test + void anUnknownWebhookIsNotFound() { + ResponseStatusException ex = + assertThrows( + ResponseStatusException.class, + () -> + controller.receive( + "unknownwebhookid", + WebhookSignatures.sign(SECRET, BODY), + "x.pdf", + request(BODY))); + + assertEquals(404, ex.getStatusCode().value()); + } + + @Test + void aPausedSourceRejectsDeliveries() { + sourceStore.save(webhookSource(false)); + String signature = WebhookSignatures.sign(SECRET, BODY); + + ResponseStatusException ex = + assertThrows( + ResponseStatusException.class, + () -> controller.receive(WEBHOOK_ID, signature, "x.pdf", request(BODY))); + + assertEquals(403, ex.getStatusCode().value()); + assertTrue(spooledFiles().isEmpty()); + } + + @Test + void anEmptyBodyIsRejected() { + byte[] empty = new byte[0]; + String signature = WebhookSignatures.sign(SECRET, empty); + + ResponseStatusException ex = + assertThrows( + ResponseStatusException.class, + () -> controller.receive(WEBHOOK_ID, signature, null, request(empty))); + + assertEquals(400, ex.getStatusCode().value()); + } + + @Test + void anOversizeDeliveryIsRejectedBeforeStoring() { + properties.getPolicies().setWebhookMaxBytes(2); + + ResponseStatusException ex = + assertThrows( + ResponseStatusException.class, + () -> + controller.receive( + WEBHOOK_ID, + WebhookSignatures.sign(SECRET, BODY), + "x.pdf", + request(BODY))); + + assertEquals(413, ex.getStatusCode().value()); + assertTrue(spooledFiles().isEmpty()); + } + + @Test + void aDeliveryWithoutAContentLengthIsRejected() { + MockHttpServletRequest req = + new MockHttpServletRequest("POST", "/api/v1/webhooks/" + WEBHOOK_ID); + ResponseStatusException ex = + assertThrows( + ResponseStatusException.class, + () -> + controller.receive( + WEBHOOK_ID, + WebhookSignatures.sign(SECRET, BODY), + "x.pdf", + req)); + + assertEquals(411, ex.getStatusCode().value()); + assertTrue(spooledFiles().isEmpty()); + } + + private List spooledFiles() { + Path dir = spool.dirFor(WEBHOOK_ID); + if (!Files.isDirectory(dir)) { + return List.of(); + } + try (Stream entries = Files.list(dir)) { + return entries.filter(Files::isRegularFile) + .filter(p -> !p.getFileName().toString().startsWith(".")) + .toList(); + } catch (IOException e) { + throw new RuntimeException(e); + } + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/webhook/WebhookSignaturesTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/webhook/WebhookSignaturesTest.java new file mode 100644 index 0000000000..6b2e45094d --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/webhook/WebhookSignaturesTest.java @@ -0,0 +1,48 @@ +package stirling.software.proprietary.policy.webhook; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.nio.charset.StandardCharsets; + +import org.junit.jupiter.api.Test; + +class WebhookSignaturesTest { + + private static final String SECRET = "whsec_test_secret"; + private static final byte[] BODY = "the document bytes".getBytes(StandardCharsets.UTF_8); + + @Test + void aFreshlySignedBodyVerifies() { + String header = WebhookSignatures.sign(SECRET, BODY); + assertTrue(header.startsWith("sha256=")); + assertTrue(WebhookSignatures.verify(SECRET, BODY, header)); + } + + @Test + void abarehexSignatureVerifiesToo() { + String header = WebhookSignatures.sign(SECRET, BODY); + String bareHex = header.substring("sha256=".length()); + assertTrue(WebhookSignatures.verify(SECRET, BODY, bareHex)); + } + + @Test + void aWrongSecretDoesNotVerify() { + String header = WebhookSignatures.sign(SECRET, BODY); + assertFalse(WebhookSignatures.verify("other-secret", BODY, header)); + } + + @Test + void atamperedBodyDoesNotVerify() { + String header = WebhookSignatures.sign(SECRET, BODY); + byte[] tampered = "the document byteS".getBytes(StandardCharsets.UTF_8); + assertFalse(WebhookSignatures.verify(SECRET, tampered, header)); + } + + @Test + void aMissingOrMalformedHeaderIsFalseNotAnError() { + assertFalse(WebhookSignatures.verify(SECRET, BODY, null)); + assertFalse(WebhookSignatures.verify(SECRET, BODY, "sha256=not-hex")); + assertFalse(WebhookSignatures.verify(SECRET, BODY, "")); + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/util/SecretMaskerTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/util/SecretMaskerTest.java index 415117a446..86d30cebe9 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/util/SecretMaskerTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/util/SecretMaskerTest.java @@ -103,6 +103,17 @@ class SecretMaskerTest { assertEquals("AKIAEXAMPLE", result.get("accessKeyId")); } + @Test + @DisplayName("should mask camelCase signingSecret despite no word boundary") + void shouldMaskCamelCaseSigningSecret() { + Map input = Map.of("signingSecret", "shh", "webhookId", "whk_abc"); + + Map result = SecretMasker.mask(input); + + assertEquals(SecretMasker.REDACTED, result.get("signingSecret")); + assertEquals("whk_abc", result.get("webhookId")); + } + @Test @DisplayName("should mask nested map sensitive keys") void shouldMaskNestedMapSensitiveKeys() { diff --git a/frontend/editor/public/locales/en-US/translation.toml b/frontend/editor/public/locales/en-US/translation.toml index d27e881f2e..b9e1f6b969 100644 --- a/frontend/editor/public/locales/en-US/translation.toml +++ b/frontend/editor/public/locales/en-US/translation.toml @@ -8041,6 +8041,25 @@ label = "Secret access key" [portal.sources.types.unknown] label = "Source" +[portal.sources.types.webhook] +createNote = "A delivery URL and signing secret are generated when you create this webhook. Senders POST documents to that URL and the referencing policies run on arrival." +description = "Receive documents by signed HTTP POST from any external system." +label = "Webhook" + +[portal.sources.types.webhook.detail] +deliveryUrl = "Delivery URL" +secretNote = "The signing secret is shown once, when the webhook is created. Recreate the source to roll it." + +[portal.sources.types.webhook.reveal] +copy = "Copy" +done = "Done" +secret = "Signing secret" +secretHelp = "Sign each delivery's raw body with this key (HMAC-SHA256) and send it as the X-Stirling-Signature header." +secretWarning = "Copy the signing secret now. For your security it is shown only once and cannot be retrieved later." +title = "Webhook created" +url = "Delivery URL" +usage = "POST each document as the raw request body to the delivery URL with a binary content type (application/pdf or application/octet-stream). Referencing policies run automatically on arrival." + [portal.sources.wizard] name = "Name" namePlaceholder = "e.g. Claims intake" diff --git a/frontend/editor/src/portal/api/sources.ts b/frontend/editor/src/portal/api/sources.ts index 9a66aeef2d..47e456d094 100644 --- a/frontend/editor/src/portal/api/sources.ts +++ b/frontend/editor/src/portal/api/sources.ts @@ -32,6 +32,7 @@ export interface SourceView { docsTotal: number; docs24h: number; docs30d: number; + webhookPath?: string | null; } export interface SourceKpi { diff --git a/frontend/editor/src/portal/components/sources/SourceTypeIcon.tsx b/frontend/editor/src/portal/components/sources/SourceTypeIcon.tsx index 606fe45b1e..5ccd70b860 100644 --- a/frontend/editor/src/portal/components/sources/SourceTypeIcon.tsx +++ b/frontend/editor/src/portal/components/sources/SourceTypeIcon.tsx @@ -8,6 +8,7 @@ const PATHS: Record = { folder: "M3 7h6l2 2h10v9a1 1 0 01-1 1H4a1 1 0 01-1-1V7z", s3: "M7 18a4 4 0 010-8 5 5 0 019.6-1.3A3.5 3.5 0 0117 18H7z", + webhook: "M13 2L3 14h9l-1 8 10-12h-9l1-8z", editor: "M4 20h16M14 4l6 6-9 9H5v-6l9-9z", _default: "M14 3H7a1 1 0 00-1 1v16a1 1 0 001 1h10a1 1 0 001-1V7l-4-4zM14 3v4h4", diff --git a/frontend/editor/src/portal/components/sources/sourceTypes.ts b/frontend/editor/src/portal/components/sources/sourceTypes.ts index c1f6049188..89d12c945b 100644 --- a/frontend/editor/src/portal/components/sources/sourceTypes.ts +++ b/frontend/editor/src/portal/components/sources/sourceTypes.ts @@ -22,6 +22,9 @@ export interface SourceTypeMeta { */ export const EDITOR_SOURCE_TYPE = "editor"; +/** The webhook source type. Its delivery URL + signing secret are minted server-side on create. */ +export const WEBHOOK_SOURCE_TYPE = "webhook"; + const SOURCE_TYPE_META: Record = { folder: { labelKey: "portal.sources.types.folder.label", @@ -38,6 +41,11 @@ const SOURCE_TYPE_META: Record = { icon: "☁", accent: "brand", }, + webhook: { + labelKey: "portal.sources.types.webhook.label", + icon: "↯", + accent: "warning", + }, }; const UNKNOWN_TYPE_META: SourceTypeMeta = { @@ -180,6 +188,12 @@ export const CREATABLE_SOURCE_TYPES: CreatableSourceType[] = [ }, ], }, + { + type: WEBHOOK_SOURCE_TYPE, + labelKey: "portal.sources.types.webhook.label", + descriptionKey: "portal.sources.types.webhook.description", + fields: [], + }, ]; /** Default option values for a type's create form. */ diff --git a/frontend/editor/src/portal/mocks/handlers/sources.ts b/frontend/editor/src/portal/mocks/handlers/sources.ts index 4f4dd0e807..3c378a24f2 100644 --- a/frontend/editor/src/portal/mocks/handlers/sources.ts +++ b/frontend/editor/src/portal/mocks/handlers/sources.ts @@ -55,6 +55,17 @@ function seedSources(): StoredSource[] { enabled: false, owner: "data-eng@acme.com", }, + { + id: "src-webhook", + name: "Partner uploads", + type: "webhook", + options: { + webhookId: "whk_demo_5f3a9c21b7", + signingSecret: "whsec_demo_2b8e1d47a9f60c35", + }, + enabled: true, + owner: "you@acme.com", + }, ]; } @@ -76,6 +87,7 @@ const docCounts: Record< "src-contracts": { total: 12840, last24h: 96, last30d: 2310 }, "src-archive": { total: 1180, last24h: 0, last30d: 0 }, "src-legacy": { total: 48600, last24h: 0, last30d: 0 }, + "src-webhook": { total: 3120, last24h: 24, last30d: 640 }, }; function docsFor(id: string): { @@ -96,14 +108,21 @@ function nextId(): string { return `src_${Date.now().toString(36)}_${idCounter}`; } +function randomToken(): string { + const bytes = new Uint8Array(16); + crypto.getRandomValues(bytes); + return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join(""); +} + function refsFor(id: string): SourcePolicyRef[] { return references[id] ?? []; } function configRows(options: Record) { + const secret = /secret|password|token/i; return Object.entries(options).map(([key, value]) => ({ label: key.charAt(0).toUpperCase() + key.slice(1), - value: String(value), + value: secret.test(key) ? "********" : String(value), })); } @@ -120,6 +139,7 @@ function toSourceView( refs: SourcePolicyRef[], ): SourceView { const docs = docsFor(source.id); + const webhookId = source.options.webhookId; return { id: source.id, name: source.name, @@ -131,6 +151,10 @@ function toSourceView( docsTotal: docs.total, docs24h: docs.last24h, docs30d: docs.last30d, + webhookPath: + source.type === "webhook" && typeof webhookId === "string" + ? `/api/v1/webhooks/${webhookId}` + : null, }; } @@ -207,8 +231,17 @@ export const sourcesHandlers = [ ? store.find((s) => s.id === incoming.id) : undefined; const id = existing?.id ?? nextId(); + let options = incoming.options ?? {}; + if (incoming.type === "webhook" && !existing && !options.webhookId) { + options = { + ...options, + webhookId: `whk_${randomToken()}`, + signingSecret: `whsec_${randomToken()}`, + }; + } const saved: StoredSource = { ...incoming, + options, id, owner: existing?.owner ?? "you@acme.com", }; diff --git a/frontend/editor/src/portal/views/SourceBuilder.css b/frontend/editor/src/portal/views/SourceBuilder.css index 985332046d..7a964a2c86 100644 --- a/frontend/editor/src/portal/views/SourceBuilder.css +++ b/frontend/editor/src/portal/views/SourceBuilder.css @@ -85,3 +85,32 @@ justify-content: flex-end; gap: 0.5rem; } + +.portal-source-builder__copy-row { + display: flex; + gap: 0.5rem; + align-items: center; +} + +.portal-source-builder__copy-row > :first-child { + flex: 1 1 auto; + min-width: 0; +} + +.portal-source-builder__reveal { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.portal-source-builder__muted { + margin: 0; + font-size: 0.8125rem; + color: var(--color-text-4); +} + +.portal-source-builder__type-description { + margin: 0; + font-size: 0.875rem; + color: var(--color-text-3); +} diff --git a/frontend/editor/src/portal/views/SourceBuilder.test.tsx b/frontend/editor/src/portal/views/SourceBuilder.test.tsx index 1f9b3341f8..517b28f515 100644 --- a/frontend/editor/src/portal/views/SourceBuilder.test.tsx +++ b/frontend/editor/src/portal/views/SourceBuilder.test.tsx @@ -102,6 +102,39 @@ describe("SourceBuilder", () => { ).toBeDisabled(); }); + it("reveals the delivery URL and signing secret once after creating a webhook", async () => { + createSource.mockResolvedValue({ + id: "wh-1", + options: { webhookId: "whk_abc123", signingSecret: "whsec_topsecret" }, + }); + renderBuilder("/processor/sources/new"); + + fireEvent.change(screen.getByLabelText(/portal\.sources\.wizard\.name/), { + target: { value: "Partner uploads" }, + }); + // Webhook's connection is optional (self-hosted local-disk), so a name is enough to create. + fireEvent.click(screen.getByText("portal.sources.types.webhook.label")); + fireEvent.click(screen.getByText("portal.sources.builder.create")); + + await waitFor(() => expect(createSource).toHaveBeenCalledTimes(1)); + expect(createSource).toHaveBeenCalledWith( + expect.objectContaining({ type: "webhook", name: "Partner uploads" }), + ); + + expect( + await screen.findByDisplayValue("whsec_topsecret"), + ).toBeInTheDocument(); + expect( + screen.getByDisplayValue(/\/api\/v1\/webhooks\/whk_abc123$/), + ).toBeInTheDocument(); + expect(screen.queryByText("sources list")).not.toBeInTheDocument(); + + fireEvent.click( + screen.getByText("portal.sources.types.webhook.reveal.done"), + ); + expect(await screen.findByText("sources list")).toBeInTheDocument(); + }); + it("blocks create until required fields are filled", async () => { renderBuilder("/processor/sources/new"); // Name given but directory (required) still blank -> Create disabled. diff --git a/frontend/editor/src/portal/views/SourceBuilder.tsx b/frontend/editor/src/portal/views/SourceBuilder.tsx index 31b8550354..4da5a96460 100644 --- a/frontend/editor/src/portal/views/SourceBuilder.tsx +++ b/frontend/editor/src/portal/views/SourceBuilder.tsx @@ -27,11 +27,16 @@ import { CREATABLE_SOURCE_TYPES, defaultOptions, sourceTypeMeta, + WEBHOOK_SOURCE_TYPE, type CreatableSourceType, } from "@portal/components/sources/sourceTypes"; import { S3ConnectionPicker } from "@portal/components/sources/S3ConnectionPicker"; import "@portal/views/SourceBuilder.css"; +function webhookUrl(webhookId: string): string { + return `${window.location.origin}/api/v1/webhooks/${webhookId}`; +} + const OFFERED_TYPES = creatableSourceTypes(); /** A source's stored type resolved to its create-form metadata (edit falls back to any type). */ @@ -84,6 +89,10 @@ export function SourceBuilder() { const [error, setError] = useState(null); const [pendingDelete, setPendingDelete] = useState(false); const [deleting, setDeleting] = useState(false); + const [reveal, setReveal] = useState<{ + webhookId: string; + secret: string; + } | null>(null); // Seed once: immediately for a new source, or after the record loads for edit. useEffect(() => { @@ -112,18 +121,38 @@ export function SourceBuilder() { ); const canSave = name.trim() !== "" && requiredComplete && !submitting; + const editingWebhookId = + isEdit && sourceState.data?.type === WEBHOOK_SOURCE_TYPE + ? String(sourceState.data.options?.webhookId ?? "") + : ""; + const revealUrl = reveal ? webhookUrl(reveal.webhookId) : ""; + const revealSecret = reveal ? reveal.secret : ""; + + function dismissReveal() { + setReveal(null); + navigate(listPath); + } + async function save() { if (!canSave) return; setSubmitting(true); setError(null); try { - await createSource({ + const saved = await createSource({ id: isEdit ? id : undefined, name: name.trim(), type: type.type, options, enabled, }); + if (!isEdit && type.type === WEBHOOK_SOURCE_TYPE) { + const webhookId = String(saved.options?.webhookId ?? ""); + const secret = String(saved.options?.signingSecret ?? ""); + if (webhookId && secret) { + setReveal({ webhookId, secret }); + return; + } + } navigate(listPath); } catch (e) { setError(errorMessage(e)); @@ -131,6 +160,10 @@ export function SourceBuilder() { } } + function copy(text: string) { + void navigator.clipboard?.writeText(text); + } + async function confirmDelete() { if (!id || deleting) return; setDeleting(true); @@ -254,6 +287,18 @@ export function SourceBuilder() { )} + {!isEdit && ( +

+ {t(type.descriptionKey)} +

+ )} + + {!isEdit && type.type === WEBHOOK_SOURCE_TYPE && ( +

+ {t("portal.sources.types.webhook.createNote")} +

+ )} + {type.fields.map((field) => ( ))} + {editingWebhookId && ( + +
+ e.currentTarget.select()} + /> + +
+
+ )} + {error && } @@ -321,6 +388,69 @@ export function SourceBuilder() { >

{t("portal.sources.delete.body", { name })}

+ + + + + } + > + {reveal && ( +
+ + +
+ e.currentTarget.select()} + /> + +
+
+ +
+ e.currentTarget.select()} + /> + +
+
+

+ {t("portal.sources.types.webhook.reveal.usage")} +

+
+ )} +
); } diff --git a/frontend/editor/src/portal/views/Sources.css b/frontend/editor/src/portal/views/Sources.css index c092905fe2..f0dea22e1c 100644 --- a/frontend/editor/src/portal/views/Sources.css +++ b/frontend/editor/src/portal/views/Sources.css @@ -95,6 +95,11 @@ .portal-sources__type-dot--warning { --dot-c: var(--color-amber-dark); } +/* Dark: subtle tint + bright icon; amber-light/amber-dark are too close to read. */ +[data-theme="dark"] .portal-sources__type-dot--warning { + background: color-mix(in srgb, var(--color-amber) 20%, transparent); + color: var(--color-amber); +} .portal-sources__type-dot--danger { --dot-c: var(--color-red-dark); } diff --git a/testing/cucumber/features/environment.py b/testing/cucumber/features/environment.py index b8fcad1486..208e45b23b 100644 --- a/testing/cucumber/features/environment.py +++ b/testing/cucumber/features/environment.py @@ -19,6 +19,9 @@ _JWT_DEPENDENT_TAGS = frozenset({ "jwt", "user_mgmt", "admin_settings", "audit", "signature", "team", }) +# Tags for scenarios that require the policies feature (policies.enabled=true). +_POLICIES_DEPENDENT_TAGS = frozenset({"policies", "webhook"}) + def _check_jwt_available(): """Probe the server to determine whether JWT Bearer auth is functional. @@ -85,6 +88,38 @@ def _capture_docker_logs_window(start_line, scenario_name): pass +def _check_policies_available(): + """Probe whether webhook sources can be created (proprietary policy feature). + + Creates a throwaway webhook source: a 200 with a minted webhookId means the + webhook beans are present (a proprietary build). The probe source is + best-effort deleted afterwards. + """ + try: + resp = requests.post( + f"{_BASE_URL}/api/v1/sources", + headers={"X-API-KEY": "123456789", "Content-Type": "application/json"}, + json={"name": "policies-probe", "type": "webhook", "options": {}, "enabled": True}, + timeout=10, + ) + if resp.status_code != 200: + return False + source_id = resp.json().get("id") + has_webhook = bool(resp.json().get("options", {}).get("webhookId")) + if source_id: + try: + requests.delete( + f"{_BASE_URL}/api/v1/sources/{source_id}", + headers={"X-API-KEY": "123456789"}, + timeout=10, + ) + except Exception: + pass + return has_webhook + except Exception: + return False + + def before_all(context): context.endpoint = None context.request_data = None @@ -97,6 +132,12 @@ def before_all(context): "(server likely running with V2=false). " "Scenarios tagged with JWT-dependent tags will be skipped." ) + context.policies_available = _check_policies_available() + if not context.policies_available: + print( + "\n[POLICIES] Webhook sources are not available in this environment " + "(e.g. a core-only build). Scenarios tagged @policies/@webhook will be skipped." + ) def before_scenario(context, scenario): @@ -110,6 +151,13 @@ def before_scenario(context, scenario): ) return + if _POLICIES_DEPENDENT_TAGS & scenario_tags and not context.policies_available: + scenario.skip( + "Webhook sources not available in this environment (e.g. a core-only build). " + "Run against a proprietary build to execute these scenarios." + ) + return + context.files = {} context.multi_files = [] context.json_parts = {} diff --git a/testing/cucumber/features/steps/webhook_step_definitions.py b/testing/cucumber/features/steps/webhook_step_definitions.py new file mode 100644 index 0000000000..bcf88bd715 --- /dev/null +++ b/testing/cucumber/features/steps/webhook_step_definitions.py @@ -0,0 +1,84 @@ +import hashlib +import hmac + +import requests +from behave import given, when, then + +BASE_URL = "http://localhost:8080" +API_HEADERS = {"X-API-KEY": "123456789"} + + +def _sign(secret, body): + digest = hmac.new(secret.encode(), body.encode(), hashlib.sha256).hexdigest() + return "sha256=" + digest + + +@given('I create a webhook source named "{name}"') +def step_create_webhook_source(context, name): + resp = requests.post( + f"{BASE_URL}/api/v1/sources", + headers={**API_HEADERS, "Content-Type": "application/json"}, + json={"name": name, "type": "webhook", "options": {}, "enabled": True}, + timeout=15, + ) + assert resp.status_code == 200, f"create source failed: {resp.status_code} {resp.text}" + context.webhook_create_response = resp + body = resp.json() + context.webhook_source_id = body["id"] + context.webhook_id = body["options"]["webhookId"] + context.webhook_secret = body["options"]["signingSecret"] + + +@when('I deliver "{payload}" to the webhook with a valid signature') +def step_deliver_signed(context, payload): + signature = _sign(context.webhook_secret, payload) + context.webhook_response = requests.post( + f"{BASE_URL}/api/v1/webhooks/{context.webhook_id}", + headers={"Content-Type": "application/pdf", "X-Stirling-Signature": signature}, + data=payload.encode(), + timeout=15, + ) + + +@when('I deliver "{payload}" to the webhook with signature "{signature}"') +def step_deliver_with_signature(context, payload, signature): + context.webhook_response = requests.post( + f"{BASE_URL}/api/v1/webhooks/{context.webhook_id}", + headers={"Content-Type": "application/pdf", "X-Stirling-Signature": signature}, + data=payload.encode(), + timeout=15, + ) + + +@when('I deliver "{payload}" to webhook id "{webhook_id}"') +def step_deliver_to_id(context, payload, webhook_id): + context.webhook_response = requests.post( + f"{BASE_URL}/api/v1/webhooks/{webhook_id}", + headers={"Content-Type": "application/pdf", "X-Stirling-Signature": "sha256=00"}, + data=payload.encode(), + timeout=15, + ) + + +@then("the webhook response status should be {status:d}") +def step_check_status(context, status): + actual = context.webhook_response.status_code + assert actual == status, f"expected {status}, got {actual}: {context.webhook_response.text}" + + +@then("the webhook create response includes a signing secret") +def step_secret_present(context): + secret = context.webhook_create_response.json()["options"].get("signingSecret", "") + assert secret and secret != "********", f"expected a revealed secret, got '{secret}'" + + +@then("reading the webhook source back masks the signing secret") +def step_secret_masked(context): + resp = requests.get( + f"{BASE_URL}/api/v1/sources/{context.webhook_source_id}", + headers=API_HEADERS, + timeout=15, + ) + assert resp.status_code == 200, f"get source failed: {resp.status_code} {resp.text}" + secret = resp.json()["options"].get("signingSecret", "") + assert secret != context.webhook_secret, "secret was returned in clear text on read" diff --git a/testing/cucumber/features/webhook_source.feature b/testing/cucumber/features/webhook_source.feature new file mode 100644 index 0000000000..cd4ce0e38f --- /dev/null +++ b/testing/cucumber/features/webhook_source.feature @@ -0,0 +1,25 @@ +@policies @webhook +Feature: Webhook input source + # Requires the proprietary policy feature (webhook sources). Scenarios are + # skipped automatically when webhook sources are unavailable (see environment.py). + # A webhook source mints a delivery URL + signing secret; senders POST signed + # documents which are spooled for the referencing policies. + + Scenario: A validly signed delivery is accepted + Given I create a webhook source named "Cucumber webhook" + When I deliver "hello from cucumber" to the webhook with a valid signature + Then the webhook response status should be 202 + + Scenario: A wrongly signed delivery is rejected + Given I create a webhook source named "Cucumber webhook reject" + When I deliver "tampered body" to the webhook with signature "sha256=deadbeef" + Then the webhook response status should be 401 + + Scenario: Delivering to an unknown webhook id is not found + When I deliver "orphan" to webhook id "doesnotexistwebhook0" + Then the webhook response status should be 404 + + Scenario: The signing secret is revealed once on create then masked on read + Given I create a webhook source named "Cucumber webhook secret" + Then the webhook create response includes a signing secret + And reading the webhook source back masks the signing secret