11

I'm implementing interactive messages on Slack, which contains some action buttons. Using Slack App I'm able to handle Slack users clicking the buttons on my Java Springboot API.

To this moment, everything is fine. However, I struggle to compute matching request signature (digest) to verify, that it actually comes from Slack. I read all the documentation for that on Slack verification documentation page.

The page decribes, that the signature has to be computed as a HMAC SHA256 hash, using Signing Secret as a key and content as concatenation of slack version, timestamp and request body, for example:

v0:123456789:command=/weather&text=94070

On the page is stated:

...Evaluate only the raw HTTP request body when computing signatures.

... so I'm not encoding/deserializing the request before hash computing (I've attached my received request from Slack below)

To compute the hash I use the code found on StackOverflow:

private String computeMessageDigest(String content) {
    final String ALGORITHM = "HmacSHA256";
    final String UTF_8 = "UTF-8";

    try {
        Key signingKey = new SecretKeySpec(signingSecret.getBytes(UTF_8), ALGORITHM);
        Mac mac = Mac.getInstance(ALGORITHM);
        mac.init(signingKey);

        return Hex.encodeHexString(mac.doFinal(content.getBytes(UTF_8)));
    } catch (Exception e) {
        throw new RuntimeException(e);
    }
}

I tried also this online hash generator to compare the results, and they were the same.

The request received from Slack looks like this:

{
    "headers": {
        "x-forwarded-for": ["::ffff:52.72.111.29"],
        "x-forwarded-proto": ["https"],
        "x-pagekite-port": ["443"],
        "host": ["inqool.pagekite.me"],
        "user-agent": ["Slackbot 1.0 (+https://api.slack.com/robots)"],
        "accept-encoding": ["gzip,deflate"],
        "accept": ["application/json,*/*"],
        "x-slack-signature": ["v0=87fbffb089501ba823991cc20058df525767a8a2287b3809f9afff3e3b600dd8"],
        "x-slack-request-timestamp": ["1531221943"],
        "content-length": ["2731"],
        "Content-Type": ["application/x-www-form-urlencoded;charset=UTF-8"]
    },
    "body": "payload=%7B%22type%22%3A%22interactive_message%22%2C%22actions%22%3A%5B%7B%22name%22%3A%22reject_btn%22%2C%22type%22%3A%22button%22%2C%22value%22%3A%22false%22%7D%5D%2C%22callback_id%22%3A%22artwork%3D40d7a87f-466c-4fc9-b454-09ce020d4465%22%2C%22team%22%3A%7B%22id%22%3A%22T03NP6SA7%22%2C%22domain%22%3A%22artstaq%22%7D%2C%22channel%22%3A%7B%22id%22%3A%22G8F2WR4FJ%22%2C%22name%22%3A%22privategroup%22%7D%2C%22user%22%3A%7B%22id%22%3A%22U66T9QX60%22%2C%22name%22%3A%22majo%22%7D%2C%22action_ts%22%3A%221531221943.512498%22%2C%22message_ts%22%3A%221531221198.000225%22%2C%22attachment_id%22%3A%221%22%2C%22token%22%3A%22ZABrZDXgJCOOLNau5mXnfNQR%22%2C%22is_app_unfurl%22%3Afalse%2C%22original_message%22%3A%7B%22text%22%3A%22User+just+put+item+on+*EXCHANGE*.%22%2C%22bot_id%22%3A%22BBM1W4QEL%22%2C%22attachments%22%3A%5B%7B%22author_name%22%3A%22Slack+Test%3B+slack%40test.com%22%2C%22callback_id%22%3A%22artwork%3D40d7a87f-466c-4fc9-b454-09ce020d4465%22%2C%22fallback%22%3A%22Slack+Test%3B+%3Cmailto%3Aslack%40test.com%7Cslack%40test.com%3E+just+put+item+Panenka+%5C%2F+Doll+by+artist+Jaroslav+Vale%5Cu010dka+into+ON+REQUEST+mode%22%2C%22text%22%3A%22%3Chttp%3A%5C%2F%5C%2Flocalhost%3A8080%5C%2Fartist%5C%2F609cd328-d533-4ab0-b982-ec2f104476f2%7CJaroslav+Vale%5Cu010dka%3E%22%2C%22title%22%3A%22Panenka+%5C%2F+Doll%22%2C%22footer%22%3A%22ARTSTAQ+Slack+Reporter%22%2C%22id%22%3A1%2C%22title_link%22%3A%22http%3A%5C%2F%5C%2Flocalhost%3A8080%5C%2Fartwork%5C%2F40d7a87f-466c-4fc9-b454-09ce020d4465%22%2C%22color%22%3A%22f0d0ad%22%2C%22fields%22%3A%5B%7B%22title%22%3A%22Trading+type%22%2C%22value%22%3A%22ON+REQUEST%22%2C%22short%22%3Atrue%7D%5D%2C%22actions%22%3A%5B%7B%22id%22%3A%221%22%2C%22name%22%3A%22approve_btn%22%2C%22text%22%3A%22APPROVE%22%2C%22type%22%3A%22button%22%2C%22value%22%3A%22true%22%2C%22style%22%3A%22primary%22%2C%22confirm%22%3A%7B%22text%22%3A%22Do+you+really+want+to+approve+this+artwork%3F%22%2C%22title%22%3A%22Approve+artwork%22%2C%22ok_text%22%3A%22Yes%22%2C%22dismiss_text%22%3A%22Cancel%22%7D%7D%2C%7B%22id%22%3A%222%22%2C%22name%22%3A%22reject_btn%22%2C%22text%22%3A%22REJECT%22%2C%22type%22%3A%22button%22%2C%22value%22%3A%22false%22%2C%22style%22%3A%22danger%22%2C%22confirm%22%3A%7B%22text%22%3A%22Do+you+really+want+to+reject+this+artwork%3F%22%2C%22title%22%3A%22Reject+artwork%22%2C%22ok_text%22%3A%22Yes%22%2C%22dismiss_text%22%3A%22Cancel%22%7D%7D%5D%7D%5D%2C%22type%22%3A%22message%22%2C%22subtype%22%3A%22bot_message%22%2C%22ts%22%3A%221531221198.000225%22%7D%2C%22response_url%22%3A%22https%3A%5C%2F%5C%2Fhooks.slack.com%5C%2Factions%5C%2FT03NP6SA7%5C%2F395760858899%5C%2FGlP9jsNQak7FqEciEHhscx4L%22%2C%22trigger_id%22%3A%22395632563524.3771230347.851ab60578de033398338a9faeb41a15%22%7D"
}

When I computed the HMAC SHA256 hash, I got 561034bb6860c07a6b4eaf245b6da3ea869c7806c7f7be20b1a830b6d25c54c8 but I should get 87fbffb089501ba823991cc20058df525767a8a2287b3809f9afff3e3b600dd8, as in the request header.

I also tried to compute the hash from the URL decoded body, but still not be able to get the matching signature.

Am I doing something wrong? Thanks for the answers/hints.


EDIT: here's the whole source code of my REST controller and request verifier:

package com.artstaq.resource;

import com.artstaq.integration.slack.SlackRequestVerifier;
import org.springframework.http.HttpEntity;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RestController;

import javax.inject.Inject;

@RestController
@RequestMapping("/content_admin")
public class ContentAdminResource {

    private SlackRequestVerifier slackVerifier;


    @RequestMapping(value = "/slack/artwork/resolve", method = RequestMethod.POST, consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE)
    public void resolve(HttpEntity<String> request) {
        slackVerifier.verifySlackRequest(request);
    }


    @Inject
    public void setSlackVerifier(SlackRequestVerifier slackVerifier) {
        this.slackVerifier = slackVerifier;
    }
}

package com.artstaq.integration.slack;

import com.artstaq.exception.SignatureVerificationException;
import com.artstaq.exception.TimestampTooOldException;
import org.apache.commons.codec.binary.Hex;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpEntity;
import org.springframework.stereotype.Component;

import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.security.Key;
import java.time.Instant;
import java.time.temporal.ChronoUnit;

/**
 * Class providing request verification received from Slack
 */
@Component
public class SlackRequestVerifier {

    @Value("${integration.slack.version:v0}")
    private String version;

    @Value("${integration.slack.signingSecret}")
    private String signingSecret;

    /**
     * Verifies the integrity of received Slack request.
     */
    public void verifySlackRequest(HttpEntity<String> request) {
        String timestamp = request.getHeaders().getFirst(SlackHeaders.TIMESTAMP);
        Instant timeInstant = Instant.ofEpochSecond(Long.valueOf(timestamp));
        if (timeInstant.plus(5, ChronoUnit.MINUTES).compareTo(Instant.now()) < 0) {
            throw new TimestampTooOldException(timeInstant);
        }

        String expectedDigest = request.getHeaders().getFirst(SlackHeaders.SIGNATURE);

        String basestring = String.join(":", version, timestamp, request.getBody());
        String computedDigest = version + "=" + computeMessageDigest(basestring);

        if (!computedDigest.equals(expectedDigest)) {
            throw new SignatureVerificationException(expectedDigest, computedDigest);
        }
    }

    /**
     * Compute HMAC SHA256 digest for given content using defined slack signing secret
     */
    private String computeMessageDigest(String content) {
        final String ALGORITHM = "HmacSHA256";
        final String UTF_8 = "UTF-8";

        try {
            Key signingKey = new SecretKeySpec(signingSecret.getBytes(UTF_8), ALGORITHM);
            Mac mac = Mac.getInstance(ALGORITHM);
            mac.init(signingKey);

            return Hex.encodeHexString(mac.doFinal(content.getBytes(UTF_8)));
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }


    private static class SlackHeaders {
        private static final String TIMESTAMP = "X-Slack-Request-Timestamp";
        private static final String SIGNATURE = "X-Slack-Signature";
    }
}
Majo
  • 111
  • 1
  • 5
  • 1
    I'm glad to see I'm not the only one having problems. I've been talking to Slack support about this, and we verified that my HMAC implementation is good, and I can no longer see what the issue is on my end. If this is a particularly new feature, perhaps they have an issue on their end? Anyway, perhaps try writing to support as your implementation looks generally ok to me. – Christopher Orr Jul 10 '18 at 23:16
  • @ChristopherOrr Yes, I also wrote to the Slack support, they will look on that soon – Majo Jul 11 '18 at 07:10

9 Answers9

5

I stumbled upon the very same problem on a Node.js implementation, and found this Medium article which states the following:

Note: We cannot use the built-in querystring Node package because it only supports RFC3986 space encoding and Slack requires us to implement RFC1738 space encoding.

What's the difference between both encodings? The way spaces are parsed:

  • RFC3986 will convert " " to "%20"
  • RFC1738 will convert " " to "+"

For Node.js, it suggests to install qs and to use it like this:

qs.stringify(req.body, { format : 'RFC1738' });
  • I was having an issue with verifying the signature from a call request and this was because of a library (typeorm) that transforms the initial request adding this to my middleware solved this issue. Thank you so much. – Nige Apr 27 '20 at 14:14
3

The following worked for us:

public enum SigningVerification {
    VERIFIED,
    DENIED
}

public SigningVerification verify(ImmutableSigningSecretRequest request) {
    String basestring = String.join(":", "v0", request.timestamp(), request.body());
    SecretKeySpec secret_key = new SecretKeySpec(signingSecret.getBytes(), "HmacSHA256");
    Mac sha256_HMAC = Try.of(() -> Mac.getInstance("HmacSHA256")).getOrElseThrow((SupplierRuntimeException) RuntimeException::new);
    Try.run(() -> sha256_HMAC.init(secret_key));
    String hash = "v0=" + Hex.encodeHexString(sha256_HMAC.doFinal(basestring.getBytes()));
    return hash.equals(request.verificationSignature()) ? VERIFIED : DENIED;
}

Controller:

@PostMapping("/command")
public RichMessage postCommand(@RequestHeader(value = "X-Slack-Request-Timestamp") String timestamp,
                               @RequestHeader(value = "X-Slack-Signature") String signature,
                               @RequestParam(value = "text", required = false) String message,
                               @RequestBody String body) {
    SigningSecretVerification.SigningVerification verification = verifier.verify(ImmutableSigningSecretRequest
            .builder()
            .timestamp(timestamp)
            .verificationSignature(signature)
            .body(body)
            .build()
    );
    return new RichMessage(message);

}

We basically just followed the steps in the Slack doc and it works fine.

Stefan
  • 214
  • 1
  • 12
  • Not quite sure how this is working for you. If I use your code my POST body comes through with params ordered incorrectly and the HMAC isn't calculated correctly. Wonder where my stuff is going wrong... – davepgreene Dec 04 '18 at 03:34
3

I had the same problem, using Spring's @RequestBody.

After going to the trouble of setting up a mitmproxy between Slack and my Spring app in order to compare request bodies, it turned out that Spring was decoding e.g. asterisk characters instead of leaving them as %2A.

The fix for me was to switch to asking for a [HttpServletRequest](https://github.com/boclips/terry/commit/c51382a5a6a9e96d5b19e22b038654bfb19b65b0#diff-79f3c274c9fa96261f8c9e09306a088bR37) (doesn't need a Spring annotation) and reading the raw body from it using `request.reader.use { it.readText() }` (using Kotlin's `use` to close the reader object after reading).

EDIT: the above technique doesn't work, and getting the raw request body from Spring is a mission in itself! Ongoing.

Andrew Bruce
  • 224
  • 1
  • 6
1

we just stumbled along with the exact same problem. Your tip about the asterisk decoding helped us a lot! I don't know if you already solved your issue with caching the request, but maybe you want to take a look on our open source SlackBot SDK for Spring boot, where we were able to solve that issue: https://github.com/kreait/slack-spring-boot-starter/blob/master/starter/slack-spring-boot/src/main/kotlin/io/olaph/slack/broker/security/VerificationMethodArgumentResolver.kt This VerificationMethodArgumentResolver basically receives the request, wraps it in a ContentCachingRequestWrapper, and invokes the internalResolveArgument of the normal ArgumentResolvers and verifies the request using the cached request. The tricky part here is, that the cache is empty until you've requested its parameterMap. So it is important to validate the signing after you've consumed the request.

Ben Denger
  • 228
  • 1
  • 11
1

I also got bitten by this. Using @RequestBody does not give you back the original body as @andrew-bruce also noted. Specifically for me it failed on the original %2A ending up as an unencoded * when getting the body like that. Obviously that fails the verification.

I ended up with this solution:

  1. a filter combined with a HttpServletRequest wrapper that allows to read the body multiple times:
import org.springframework.web.filter.OncePerRequestFilter
import javax.servlet.FilterChain
import javax.servlet.http.HttpServletRequest
import javax.servlet.http.HttpServletResponse

/**
 * To verify if requests are coming from Slack we need to implement this:
 * https://api.slack.com/authentication/verifying-requests-from-slack. Luckily the Bolt framework already implements
 * this for us, however we need to provide it with a body that is unaltered. Somewhere in Springs filterchain Spring
 * will already have consumed the [HttpServletRequest#inputstream], so we cannot get it from the [HttpServletRequest]
 * directly. Spring obviously provides a [org.springframework.web.bind.annotation.RequestBody] annotation, but this is 
 * slightly different from the original body. This servlet filter will be put as the very first in
 * the chain (see [SlackConfig#multiReadRequestFilter] should make sure
 * that we can re-read it and construct the raw body, so that the verification doesn't fail.
 */
class MultiReadHttpServletFilter : OncePerRequestFilter() {

    override fun doFilterInternal(request: HttpServletRequest, response: HttpServletResponse, filterChain: FilterChain) {
        val multiReadHttpServletRequest = MultiReadHttpServletRequest(request)

        filterChain.doFilter(multiReadHttpServletRequest, response)
    }
}

import org.apache.commons.io.IOUtils
import java.io.ByteArrayInputStream
import java.io.IOException
import javax.servlet.ReadListener
import javax.servlet.ServletInputStream
import javax.servlet.http.HttpServletRequest
import javax.servlet.http.HttpServletRequestWrapper


class MultiReadHttpServletRequest(request: HttpServletRequest) : HttpServletRequestWrapper(request) {

    private var body: ByteArray = IOUtils.toByteArray(request.inputStream)

    @Throws(IOException::class)
    override fun getInputStream(): ServletInputStream {
        return object : ServletInputStream() {
            val bais = ByteArrayInputStream(body)

            override fun isReady(): Boolean = true

            override fun isFinished(): Boolean = bais.available() == 0

            override fun read(): Int = bais.read()

            override fun setReadListener(readListener: ReadListener) {
                throw NotImplementedError("Not implemented!")
            }
        }
    }
}
  1. Configure it as first in the chain. Because this filter actually consumes things in-memory I've specifically targeted a specific path, so it's only for the incoming Slack events in this case.
    @Bean
    fun multiReadRequestFilter(): FilterRegistrationBean<MultiReadHttpServletFilter> {
        // this needs to match the path(s) of the controller SlackAppController
        val urlPatterns = slacks.allByKey().keys.map { "/v2/$it/slack/events" }.toTypedArray()

        val registrationBean = FilterRegistrationBean<MultiReadHttpServletFilter>()
        registrationBean.filter = MultiReadHttpServletFilter()
        registrationBean.addUrlPatterns(*urlPatterns)
        registrationBean.order = Ordered.HIGHEST_PRECEDENCE

        return registrationBean
    }
  1. Now I can use it to retrieve the 'real' raw body:
@RestController
@RequestMapping("/v2/{slackId}/slack/events")
class SlackAppController() {

    // ...

    @PostMapping
    fun handle(
            @PathVariable("slackId") slackId: String,
            httpServletRequest: HttpServletRequest,
            @RequestParam queryStringParams: MultiValueMap<String, String>,
            @RequestHeader headers: MultiValueMap<String, String>): ResponseEntity<*> {

        val body = IOUtils.toString(httpServletRequest.inputStream, StandardCharsets.UTF_8)

        // ...
    }
}
Auke
  • 534
  • 5
  • 16
0

I was having the same problem, In my controller I was receiving the body of the request as a Map, I received all the values but when I was calculating the hash I saw that the slack-signature and my hash wasn't the same.

I tried to receive the request body as a String just like the @Stefan solution and that works for me, so, instead using HttpEntity<String> in your controller, you must receive the body as plain String with @RequestBody String body in your method argument, the reason is that slack sends encoded values in the request, %2F or %3A, with HttpEntity or Map, spring interprets that values as / and : and this is the reason why your hash is not equals as slack signature.

Hope this help you.

snake_404
  • 111
  • 5
  • 15
0

Here's what I found out on the topic:

  • Spring MVC will read the body of the request and will return a different body, where the order of parameters is changed. The reading part usually happens inside one of the first filters on the chain HiddenHttpMethodFilter and this is the main reason why my signature verification failed.
  • The request is "reconstructed" incorrectly here ServletServerHttpRequest I'm not sure if this should be filed as a bug or not, but it's definitely messed up.
  • If you use an injected HttpEntity<String> or @RequestBody String body you will receive the wrong body, not the raw content but the "reconstructed" one

And now the solution:

  1. Create a filter for verifying Slack signatures and register it with the highest priority so it's on to top of the filter chain:
@Bean
public FilterRegistrationBean<SlackVerificationFilter> slackVerificationFilterRegistrationBean() {
    String path = "/slack";
    FilterRegistrationBean<SlackVerificationFilter> frb = new FilterRegistrationBean<>(new SlackVerificationFilter());
    frb.setDispatcherTypes(EnumSet.of(DispatcherType.REQUEST));
    frb.setName("csrfFilter");
    frb.setAsyncSupported(true);
    frb.addUrlPatterns(path);
    frb.setMatchAfter(false);
    frb.setEnabled(true);
    frb.setOrder(Ordered.HIGHEST_PRECEDENCE);
    return frb;
}
  1. Inside the filter, wrap the request with some sort of HttpServletRequestWrapper like this:
public class SlackVerificationFilter extends GenericFilterBean {
    public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException {
        final BufferedRequestWrapper request = new BufferedRequestWrapper((HttpServletRequest) req);
        final HttpServletResponse response = (HttpServletResponse) res;

        String rawBody = IOUtils.toString(request.getInputStream(), "UTF-8");
        // do signature verification here
        chain.doFilter(request, response);
    }
}

I won't go into details about the request wrapper. There are lots of examples of it on this site and elsewhere.

  1. Your HttpServletRequestWrapper must implement the following methods:
public ServletInputStream getInputStream();
public BufferedReader getReader() throws IOException;
public Map<String, String[]> getParameterMap();
public String getParameter(String name);

After this you should no longer have problems verifying Slack signatures.

In my case, I didn't have issues with any of the encoded characters mentioned above (%20, %2A, etc). I only had problems verifying signatures of slash command requests. Message action request were verified correctly because they only had 1 request parameter in the body (payload).

albogdano
  • 2,710
  • 2
  • 33
  • 43
0

I had this same issue using @RequestBody and not being able to validate the request. Here's how I fixed it in Kotlin. It should translate to Java fairly easily.

import org.springframework.web.bind.annotation.*
import org.apache.commons.io.IOUtils
import javax.crypto.Mac
import javax.crypto.spec.SecretKeySpec
import javax.servlet.http.HttpServletRequest
import org.apache.commons.codec.binary.Hex


fun validateSlackWebhook(
    @RequestHeader("X-Slack-Request-Timestamp") slackRequestTimestamp: String,
    @RequestHeader("X-Slack-Signature") slackSignature: String,
    request: HttpServletRequest,
    ) {
        val body = IOUtils.toString(request.reader)
        val basestring = "v0:$slackRequestTimestamp:$body"

        val sha256Hmac = Mac.getInstance("HmacSHA256")
        val secretKey = SecretKeySpec(slackSigningSecret.toByteArray(), "HmacSHA256")
        sha256Hmac.init(secretKey)

        val finalHex = "v0=${Hex.encodeHexString(sha256Hmac.doFinal(basestring.toByteArray()))}"
        val validated = finalHex.compareTo(slackSignature) == 0

        if (!validated) {
            // Code to run if request was not validated
            return
        }

        // Code to run if request was validated
    }
HeffZilla
  • 306
  • 1
  • 5
0

I finally solved it by looking at the official SDK source code of Slack!

https://github.com/slackapi/java-slack-sdk/blob/f283e45601157a0d2483ea3d3e8074e80b81a0e6/slack-app-backend/src/main/java/com/slack/api/app_backend/SlackSignature.java#L88-L122

public String generate(String slackRequestTimestamp, String requestBody) {
            if (slackRequestTimestamp == null) {
                return null;
            }

            // 1) Retrieve the X-Slack-Request-Timestamp header on the HTTP request, and the body of the request.
            // "slackRequestTimestamp" here

            // 2) Concatenate the version number, the timestamp, and the body of the request to form a basestring.
            //    Use a colon as the delimiter between the three elements.
            //    For example, v0:123456789:command=/weather&text=94070. The version number right now is always v0.
            String baseString = "v0:" + slackRequestTimestamp + ":" + requestBody;

            // 3) With the help of HMAC SHA256 implemented in your favorite programming, hash the above basestring,
            //    using the Slack Signing Secret as the key.
            SecretKeySpec sk = new SecretKeySpec(slackSigningSecret.getBytes(), ALGORITHM);
            try {
                Mac mac = Mac.getInstance(ALGORITHM);
                mac.init(sk);
                byte[] macBytes = mac.doFinal(baseString.getBytes());
                StringBuilder hashValue = new StringBuilder(2 * macBytes.length);
                for (byte macByte : macBytes) {
                    hashValue.append(String.format("%02x", macByte & 0xff));
                }
                return "v0=" + hashValue.toString();

                // 4) Compare this computed signature to the X-Slack-Signature header on the request.

            } catch (NoSuchAlgorithmException | InvalidKeyException e) {
                log.error("Failed to hash the base string value with HMAC-SHA256 because {}", e.getMessage(), e);
                return null;
            }
        }
hdsuperman
  • 182
  • 2
  • 6