Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package com.riskified.samples.notificationServer;

/**
* Reads the Riskified auth token for the notification samples from the environment.
*
* The samples used to carry the token as a source literal. A sample is published in a public
* repository, so a literal there is a disclosed credential - and anyone holding the token can
* forge a notification that passes HMAC verification. Supply it at run time instead:
*
* <pre>
* export RISKIFIED_AUTH_TOKEN='&lt;the auth token from Settings in the Riskified web app&gt;'
* </pre>
*/
public final class SampleAuthToken {

private static final String ENV_VAR = "RISKIFIED_AUTH_TOKEN";

private SampleAuthToken() {
}

/**
* @return the auth token from the RISKIFIED_AUTH_TOKEN environment variable
* @throws IllegalStateException if the variable is unset or empty, rather than starting a
* server that would reject every notification it receives
*/
public static String fromEnvironment() {
String token = System.getenv(ENV_VAR);
if (token == null || token.trim().isEmpty()) {
throw new IllegalStateException(
ENV_VAR + " is not set. Export your Riskified auth token (Settings tab in the "
+ "Riskified web app) before starting this sample.");
}
return token;
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.riskified.samples.notificationServer.servlet;

import com.riskified.samples.notificationServer.SampleAuthToken;
import org.eclipse.jetty.server.Server;
import org.eclipse.jetty.servlet.ServletContextHandler;
import org.eclipse.jetty.servlet.ServletHolder;
Expand All @@ -13,7 +14,7 @@ public static void main(String[] args) throws Exception {
myContext.setContextPath("/");
server.setHandler(myContext);

myContext.addServlet(new ServletHolder(new NotificationServlet("636b3045e083eddf4ea9f0b7f2ed4a26")), "/*");
myContext.addServlet(new ServletHolder(new NotificationServlet(SampleAuthToken.fromEnvironment())), "/*");

server.start();
server.join();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,11 @@ public class HTTPPOSTServer extends Thread {
Socket connectedClient = null;
BufferedReader inFromClient = null;
BufferedWriter outToClient = null;
private final String authKey;

public HTTPPOSTServer(Socket client) {
public HTTPPOSTServer(Socket client, String authKey) {
connectedClient = client;
this.authKey = authKey;
}

public void run() {
Expand Down Expand Up @@ -61,7 +63,7 @@ public void run() {
break;
}
}
NotificationHandler formatter = new NotificationHandler("26faa0eb6eacf889e300944c297640b68789b11c");
NotificationHandler formatter = new NotificationHandler(authKey);
NotificationOrder notification = formatter.toObject(body, hash).getOrder();
sendResponse(200, "<HTML><BODY>Merchant Received Notification For Order " + notification.getId()
+ " with status " + notification.getStatus() + " and description " + notification.getDescription()
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package com.riskified.samples.notificationServer.socket;

import com.riskified.samples.notificationServer.SampleAuthToken;

import java.net.InetAddress;
import java.net.ServerSocket;
import java.net.Socket;
Expand All @@ -8,12 +10,16 @@ public class Server {

public static void main(String[] args) throws Exception {

// Resolved once, up front, so a misconfigured environment fails at startup rather than
// on the first notification - and so the token is not re-read per connection.
String authKey = SampleAuthToken.fromEnvironment();

ServerSocket server = new ServerSocket(5000, 10, InetAddress.getByName("127.0.0.1"));
System.out.println("HTTP Server Waiting for client on port 5000");

while (true) {
Socket connected = server.accept();
(new HTTPPOSTServer(connected)).start();
(new HTTPPOSTServer(connected, authKey)).start();
}
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.riskified.notifications;

import java.io.*;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;

import javax.servlet.http.HttpServletRequest;
Expand Down Expand Up @@ -30,16 +31,25 @@ public NotificationHandler(String authKey) throws RiskifiedError {
/**
* Convert string to notification object
* @param data the string to convert
* @param hash the sha256 of the string
* @param hash the sha256 of the string, as received on the X-Riskified-Hmac-Sha256 header
* @return Notification
* @throws AuthError the hash doesn't match the was calced sha256 for the string
* @throws AuthError the hash is absent, or doesn't match the sha256 calculated for the string
* @throws UnsupportedEncodingException unsupported encoding exception
* @throws IllegalStateException illegal state exception
* @throws JsonSyntaxException json syntax exception
*/
public Notification toObject(String data, String hash) throws AuthError, JsonSyntaxException, IllegalStateException, UnsupportedEncodingException {
if (hash == null) {
// An unsigned request is unauthorized, not a programming error. Without this guard
// hash.getBytes() throws NullPointerException, which is not AuthError and so escapes
// every caller that catches the documented exception. Fails closed either way; the
// type is what matters. Matches the .NET reference (Utils/HttpUtils.cs).
throw new AuthError();
}
String calcHash = sha256Handler.createSHA256(data.getBytes("UTF-8"));
if (MessageDigest.isEqual(hash.getBytes(), calcHash.getBytes()))
// Both operands are hex ASCII, so pin the charset rather than inheriting whatever the
// platform default happens to be on the deployment host.
if (MessageDigest.isEqual(hash.getBytes(StandardCharsets.US_ASCII), calcHash.getBytes(StandardCharsets.US_ASCII)))
return gson.fromJson(data, Notification.class);
else
throw new AuthError();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
package com.riskified.notifications;

import com.riskified.SHA256Handler;
import org.junit.Test;

import java.nio.charset.StandardCharsets;

import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.fail;

/**
* Regression tests for {@link NotificationHandler} HMAC verification.
*
* SECURITY-10974. The sibling PHP SDK disclosed the server-computed HMAC in its authorization
* failure, which let an unauthenticated caller learn a valid signature for a payload of its
* choosing. This SDK never had that defect, and these tests are what keeps it that way.
*/
public class NotificationHandlerTest {

private static final String AUTH_KEY = "notification-test-secret";
private static final String BODY =
"{\"order\":{\"id\":\"ord-1\",\"status\":\"approved\",\"old_status\":\"pending\"}}";

private String validHmacFor(String body) throws Exception {
return new SHA256Handler(AUTH_KEY).createSHA256(body.getBytes(StandardCharsets.UTF_8));
}

@Test
public void correctlySignedNotificationIsParsed() throws Exception {
NotificationHandler handler = new NotificationHandler(AUTH_KEY);

Notification notification = handler.toObject(BODY, validHmacFor(BODY));

assertEquals("ord-1", notification.getOrder().getId());
assertEquals("approved", notification.getOrder().getStatus());
}

@Test(expected = AuthError.class)
public void wrongHmacIsRejected() throws Exception {
new NotificationHandler(AUTH_KEY).toObject(BODY, "not-the-right-hmac");
}

@Test(expected = AuthError.class)
public void hmacForADifferentBodyIsRejected() throws Exception {
String otherBody = "{\"order\":{\"id\":\"ord-2\",\"status\":\"declined\"}}";

new NotificationHandler(AUTH_KEY).toObject(BODY, validHmacFor(otherBody));
}

@Test(expected = AuthError.class)
public void hmacFromADifferentAuthKeyIsRejected() throws Exception {
String foreignHmac = new SHA256Handler("someone-elses-secret")
.createSHA256(BODY.getBytes(StandardCharsets.UTF_8));

new NotificationHandler(AUTH_KEY).toObject(BODY, foreignHmac);
}

/**
* A request that carries no X-Riskified-Hmac-Sha256 header arrives here as a null hash.
* It must fail as AuthError - the documented exception - and not as NullPointerException,
* which would escape every caller that catches only AuthError.
*/
@Test
public void missingHmacThrowsAuthErrorNotNullPointerException() throws Exception {
try {
new NotificationHandler(AUTH_KEY).toObject(BODY, null);
fail("Expected AuthError for a notification with no HMAC header");
} catch (AuthError expected) {
assertEquals("Request HMAC signature was either missing or incorrect", expected.getMessage());
} catch (NullPointerException e) {
fail("Missing HMAC header must raise AuthError, not NullPointerException");
}
}

@Test(expected = AuthError.class)
public void emptyHmacIsRejected() throws Exception {
new NotificationHandler(AUTH_KEY).toObject(BODY, "");
}

/**
* The failure must not tell the caller what the correct signature was, nor echo the body
* back at it. That disclosure is the whole of SECURITY-10974.
*/
@Test
public void authErrorDisclosesNeitherHmacNorBody() throws Exception {
String computed = validHmacFor(BODY);

try {
new NotificationHandler(AUTH_KEY).toObject(BODY, "not-the-right-hmac");
fail("Expected AuthError");
} catch (AuthError e) {
String message = String.valueOf(e.getMessage());
assertFalse("computed HMAC must not appear in the error", message.contains(computed));
assertFalse("received HMAC must not appear in the error", message.contains("not-the-right-hmac"));
assertFalse("request body must not appear in the error", message.contains(BODY));
assertFalse("order id must not appear in the error", message.contains("ord-1"));
}
}
}