Skip to content

Commit

Permalink
Initial commit
Browse files Browse the repository at this point in the history
  • Loading branch information
devinstewart committed Sep 17, 2023
0 parents commit 3a34c48
Show file tree
Hide file tree
Showing 19 changed files with 2,238 additions and 0 deletions.
1 change: 1 addition & 0 deletions .github/FUNDING.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
github: devinstewart
6 changes: 6 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
version: 2
updates:
- package-ecosystem: "npm"
directory: "/" # Location of package manifests
schedule:
interval: "daily"
29 changes: 29 additions & 0 deletions .github/workflows/ci-plugin.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
name: ci

on:
push:
branches:
- main
pull_request:

jobs:
test:
strategy:
fail-fast: false
matrix:
os: [ubuntu, windows, macos]
node: ['20', '18']

runs-on: ${{ matrix.os }}-latest
name: ${{ matrix.os }} node@${{ matrix.node }}
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: ${{ matrix.node }}
- name: install
run: npm install
- name: lint
run: npm run lint
- name: test
run: npm test
13 changes: 13 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
**/node_modules
**/package-lock.json

coverage.*

**/.DS_Store
**/._*

**/.vs
**/.vscode
**/.idea

.dccache
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
**v1.0.0 - Initial Release**
26 changes: 26 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# Contributing
First of all, thanks for your interest in contributing to this project.

I am open to contributions, and I will be happy to accept any pull request that meets the needs of this module. You can also open an issue, and I will be happy to help you out, or even add a new feature.

### Why does this project exist?
While maintining [sns-payload-validator](https://www.npmjs.com/package/sns-payload-validator), I was asked on a few occasions if the module could support Cloudflare's worker platform. Due to Cloudflare not including `crypto` in their worker platform, I was unable to support it.

So I decided to create a new module that used the WebCrypto API, which is supported by Cloudflare workers.
mostly use it for the Intellisense).

### Coding Style
I have adapted the coding style guide of [hapijs](https://hapi.dev/policies/styleguide/), as I do work with the fine folks in that project.

### Dependencies
As a DevSecOps engineer, I love modules without a lot of dependencies. If there is a feature that you would like to add that requires a dependency, please open an issue. We will come to one of three decisions:
- We add the dependency.
- We include the funtionality needed in the module.
- We create a separate module maintained here that includes the functionality needed.

This module only has a dependency on [lru-cache](https://www.npmjs.com/package/lru-cache) to cache the certificate keys from AWS. This is a perfect example of using an external dependency, as lru-cache is a long proven and well maintained module. There is no reason for us to recreate the wheel of caching.

By contrast, I have adapted code from [node-forge](https://www.npmjs.com/package/node-forge) to extract the public key from the x509 certificate. I did this because node-forge has a lot of functionality, making it large, and I only needed a small portion of the module to complete this task. So I adapted the code to fit my needs keeping the module small.

### Conclusion
I hope that this module is useful to you, and I hope that you will contribute to the project. -- Devin Stewart
19 changes: 19 additions & 0 deletions LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
Copyright (c) 2023 Devin Stewart

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
68 changes: 68 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
# AWS SNS Cloudflare Validator
A package for Cloudflare Workers that validates AWS SNS requests. The request body is parsed and the signature is verified. If the signature is valid, the payload is returned. If the signature is invalid, an error is thrown.

## Installation
```bash
npm install sns-cloudflare-validator
```
**Please note:** This package is intended to be used with [Cloudflare Workers](https://workers.cloudflare.com/). To validate AWS SNS requests in Node.js, please use [sns-payload-validator](https://github.com/devinstewart/sns-payload-validator)

## Usage
```javascript
import { Validator } from 'sns-cloudflare-validator';

export default {
async fetch(request) {
try {
const validator = new Validator();
const payload = await validator.validate(request);
return new Response(JSON.stringify(payload), {
status: 200,
headers: {
'Content-Type': 'application/json'
}
});
}
catch (error) {
return new Response(error.message, {
status: 400,
headers: {
'Content-Type': 'text/plain'
}
});
}
}
};
```

## Settings
There are four setting that can be passed to the constructor:
- `autoSubscribe` - A message type of `SubscriptionConfirmation` automatically subscribes the endpoint to the topic after validation, default `true`.
- `autoResubscribe` - A message type of `UnsubscribeConfirmation` automatically resubscribes the endpoint to the topic after validation, default `true`.
- `useCache` - The plugin uses a cache to store the certificate for each topic. This is enabled by default, but can be disabled if you don't want to use the cache. If disabled, the certificate will be fetched from the SNS service for each request.
- `maxCerts` - The maximum number of certificates to store in the cache. This is only used if `useCache` is enabled. The default is `5000`.

All settings can be passed to the constructor as an object:
```javascript
const validator = new Validator({
autoSubscribe: false,
autoResubscribe: false,
useCache: false,
maxCerts: 100
});
```

## Additional Information
The returned payload will have the following properties:
- `Type` - The message type: `Notification`, `SubscriptionConfirmation` or `UnsubscribeConfirmation`.
- `MessageId` - A uuid provided by the SNS service for each message.
- `Token` - The token that must be passed to the `SubscribeURL` to confirm the subscription when the message type is `SubscriptionConfirmation` or `UnsubscribeConfirmation`.
- `TopicArn` - The ARN of the topic the message was sent from.
- `Subject` - The subject of the message when the message type is `Notification`. This is not present if a Subject was not provided when the message was published.
- `Message` - The message body when the message type is `Notification`.
- `Timestamp` - The time the message was sent.
- `SignatureVersion` - The version of the signature algorithm used to sign the message. Defaults to `1`, can also be `2`.
- `Signature` - The signature of the message used to verify the message integrity.
- `SigningCertURL` - The URL of the certificate used to sign the message.
- `SubscribeURL` - The URL used to subscribe the route when the message type is `SubscriptionConfirmation` or `UnsubscribeConfirmation`.
- `UnsubscribeURL` - The URL used to unsubscribe the route when the message type is `Notification`.
152 changes: 152 additions & 0 deletions __tests__/index.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
import { describe, beforeAll, afterAll, it, expect } from 'vitest';
import { unstable_dev } from 'wrangler';
import { payloads } from './mocks/payloads';

describe('test validate()', () => {

let worker;
beforeAll(async () => {

worker = await unstable_dev('./__tests__/worker.js', {

experimental: { disableExperimentalWarning: true }
});
});

afterAll(async () => {

await worker.stop();
});

it('succussfully validates Notification SignatureVersion 1', async () => {

const resp = await worker.fetch('https://127.0.0.1', {
method: 'POST',
body: JSON.stringify(payloads.validNotificationSv1)
});
const json = await resp.json();

expect(json).toStrictEqual(payloads.validNotificationSv1);
expect(resp.status).toStrictEqual(200);
});

it('succussfully validates Notification SignatureVersion 2', async () => {

const resp = await worker.fetch('https://127.0.0.1', {
method: 'POST',
body: JSON.stringify(payloads.validNotificationSv2)
});
const json = await resp.json();
expect(json).toStrictEqual(payloads.validNotificationSv2);
expect(resp.status).toStrictEqual(200);
});

it('succussfully validates Notification with Subject', async () => {

const resp = await worker.fetch('https://127.0.0.1', {
method: 'POST',
body: JSON.stringify(payloads.validNotificationWithSubject)
});
const json = await resp.json();
expect(json).toStrictEqual(payloads.validNotificationWithSubject);
expect(resp.status).toStrictEqual(200);
});

it('succussfully validates SubscriptionConfirmation', async () => {

const resp = await worker.fetch('https://127.0.0.1', {
method: 'POST',
body: JSON.stringify(payloads.validSubscriptionConfirmation)
});
const json = await resp.json();
expect(json).toStrictEqual(payloads.validSubscriptionConfirmation);
expect(resp.status).toStrictEqual(200);
});

it('succussfully validates UnsubscribeConfirmation', async () => {

const resp = await worker.fetch('https://127.0.0.1', {
method: 'POST',
body: JSON.stringify(payloads.validUnsubscribeConfirmation)
});
const json = await resp.json();
expect(json).toStrictEqual(payloads.validUnsubscribeConfirmation);
expect(resp.status).toStrictEqual(200);
});

it('fails on invalid JSON', async () => {

const resp = await worker.fetch('https://127.0.0.1', {
method: 'POST',
body: 'invalid'
});
const text = await resp.text();
expect(text).toStrictEqual('Invalid JSON');
expect(resp.status).toStrictEqual(400);
});

it('fails on unsupported Type', async () => {

const resp = await worker.fetch('https://127.0.0.1', {
method: 'POST',
body: JSON.stringify(payloads.invalidType)
});
const text = await resp.text();
expect(text).toStrictEqual('Invalid Type');
expect(resp.status).toStrictEqual(400);
});

it('fails on invalid SignatureVersion', async () => {

const resp = await worker.fetch('https://127.0.0.1', {
method: 'POST',
body: JSON.stringify(payloads.invalidSignatureVersion)
});
const text = await resp.text();
expect(text).toStrictEqual('Invalid SignatureVersion');
expect(resp.status).toStrictEqual(400);
});

it('fails on invalid SigningCertURL', async () => {

const resp = await worker.fetch('https://127.0.0.1', {
method: 'POST',
body: JSON.stringify(payloads.invalidSigningCertURL)
});
const text = await resp.text();
expect(text).toStrictEqual('Invalid SigningCertURL');
expect(resp.status).toStrictEqual(400);
});

it('fails on invalid Signature', async () => {

const resp = await worker.fetch('https://127.0.0.1', {
method: 'POST',
body: JSON.stringify(payloads.invalidSignature)
});
const text = await resp.text();
expect(text).toStrictEqual('Invalid Signature');
expect(resp.status).toStrictEqual(400);
});

it('fails on null Signature', async () => {

const resp = await worker.fetch('https://127.0.0.1', {
method: 'POST',
body: JSON.stringify(payloads.invalidSignatureNull)
});
const text = await resp.text();
expect(text).toStrictEqual('Invalid Signature');
expect(resp.status).toStrictEqual(400);
});

it('fails on a GET', async () => {

const resp = await worker.fetch('https://127.0.0.1', {
method: 'GET'
});
const text = await resp.text();
expect(text).toStrictEqual('Method must be POST');
expect(resp.status).toStrictEqual(400);
});
});

0 comments on commit 3a34c48

Please sign in to comment.