Skip to content
Merged
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
6 changes: 3 additions & 3 deletions .github/workflows/codeql.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@ jobs:
analyze:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: github/codeql-action/init@v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: github/codeql-action/init@5595ccaf912efad79be6eef63a5619ff05969be3 # v4
with:
languages: javascript-typescript
- uses: github/codeql-action/analyze@v4
- uses: github/codeql-action/analyze@5595ccaf912efad79be6eef63a5619ff05969be3 # v4
4 changes: 2 additions & 2 deletions .github/workflows/dependency-review.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,5 @@ jobs:
review:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/dependency-review-action@v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5.0.0
4 changes: 2 additions & 2 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@ jobs:
runs-on: ubuntu-latest
environment: npm
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 24
registry-url: https://registry.npmjs.org
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@ jobs:
node-version: [20, 22, 24]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: ${{ matrix.node-version }}
package-manager-cache: false
Expand Down
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,13 @@

All notable changes will be documented here. The project follows Semantic Versioning after `1.0.0`; pre-1.0 releases may refine APIs while preserving documented security behavior where possible.

## 0.1.1 - 2026-08-09

- Removed all legacy MD5 and MD5-hash paths from the Vonage adapter.
- Pinned GitHub Actions to immutable commits and updated their major releases.
- Added Cloudflare Pages security headers, including a strict Content Security Policy.
- Updated release status, website metadata, and security regression coverage.

## 0.1.0 - 2026-08-09

- Added Twilio, Telnyx, Vonage, Plivo V3, and Generic HMAC validators.
Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

Security-first webhook validation for applications that receive SMS events from Twilio, Telnyx, Vonage, Plivo, or a custom HMAC provider. The package combines signature verification, timestamp checks, replay protection, payload validation, PII redaction, Express middleware, and a defensive localhost tester.

> Status: `0.1.0` release candidate. Validate the exact provider configuration and test in staging before production use.
> Current release: `0.1.1`. The API is pre-1.0; validate the exact provider configuration and test in staging before production use.

## Why

Expand Down Expand Up @@ -96,7 +96,7 @@ Use `timestamp` and `timestampSeparator` when your signing contract includes a t
| Plivo V3 | HMAC-SHA256 over URL, sorted parameters, and nonce | Unique nonce plus message UUID |
| Generic HMAC | SHA-256 by default; configurable encoding/prefix | Configurable timestamp and event ID |

Vonage's legacy MD5 modes are rejected by default. If migration compatibility is unavoidable, pass `allowLegacyMd5: true` explicitly and document the risk.
Vonage's legacy MD5 modes are not supported. Configure HMAC-SHA256 or stronger in the provider dashboard.

## Payload validation and logging

Expand Down Expand Up @@ -147,7 +147,7 @@ See [CLI safety and interpretation](docs/cli.md).
- Constant-time comparison for shared-secret signatures.
- Raw-body and exact-URL validation where required.
- Atomic replay-store interface.
- CodeQL, dependency review, Dependabot, tests, and release provenance workflow.
- CodeQL, dependency review, Dependabot, tests, and trusted npm publishing with provenance.
- Responsible disclosure process in [SECURITY.md](SECURITY.md).

This project reduces common implementation mistakes; it is not a security certification. Review provider documentation, proxy URL handling, key rotation, storage availability, and application side effects for your environment.
Expand Down
2 changes: 1 addition & 1 deletion docs/providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ Official reference: <https://developers.telnyx.com/docs/messaging/messages/recei

## Vonage SMS

Signed inbound SMS webhooks must be enabled for the account. Vonage supports legacy MD5 and HMAC variants. Configure HMAC-SHA256 or stronger; this project rejects MD5 modes unless `allowLegacyMd5` is explicitly enabled.
Signed inbound SMS webhooks must be enabled for the account. Configure HMAC-SHA256 or stronger. Legacy MD5 modes are intentionally unsupported.

Official reference: <https://developer.vonage.com/en/getting-started/concepts/signing-messages>

Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "secure-sms-webhook",
"version": "0.1.0",
"version": "0.1.1",
"description": "Security-first SMS webhook signature validation, replay protection, payload checks, PII redaction, and a defensive local tester.",
"type": "module",
"main": "./src/index.js",
Expand Down Expand Up @@ -56,7 +56,7 @@
"type": "git",
"url": "git+https://github.com/receive-sms-live/secure-sms-webhook.git"
},
"homepage": "https://github.com/receive-sms-live/secure-sms-webhook#readme",
"homepage": "https://secure-sms-webhook.pages.dev/",
"bugs": {
"url": "https://github.com/receive-sms-live/secure-sms-webhook/issues"
},
Expand Down
10 changes: 10 additions & 0 deletions site/_headers
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
/*
Content-Security-Policy: default-src 'self'; base-uri 'none'; connect-src 'none'; font-src 'self'; form-action 'none'; frame-ancestors 'none'; img-src 'self' data:; object-src 'none'; script-src 'self' 'sha256-Kv6W6QJhArV5y6iipxf2v0T8NzCHSw8ojkc8GayXxFs='; style-src 'self'; upgrade-insecure-requests
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Resource-Policy: same-origin
Permissions-Policy: camera=(), geolocation=(), microphone=(), payment=(), usb=()
Referrer-Policy: strict-origin-when-cross-origin
Strict-Transport-Security: max-age=31536000; includeSubDomains
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
X-XSS-Protection: 0
3 changes: 1 addition & 2 deletions src/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,8 +61,7 @@ export interface VonageOptions {
params: Record<string, unknown>;
signature?: string;
secret: string;
algorithm?: 'md5hash' | 'md5' | 'sha1' | 'sha256' | 'sha512';
allowLegacyMd5?: boolean;
algorithm?: 'sha1' | 'sha256' | 'sha512';
maxAgeSeconds?: number | false;
maxFutureSkewSeconds?: number;
now?: number;
Expand Down
14 changes: 0 additions & 14 deletions src/providers/vonage.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { createHash } from 'node:crypto';
import {
canonicalPairs,
configurationFailure,
Expand All @@ -12,8 +11,6 @@ import {
import { validateTimestamp } from '../policies/timestamp.js';

const VONAGE_ALGORITHMS = new Map([
['md5hash', 'md5hash'],
['md5', 'md5'],
['sha1', 'sha1'],
['sha256', 'sha256'],
['sha512', 'sha512'],
Expand All @@ -36,9 +33,6 @@ export function createVonageSignature({ params, secret, algorithm = 'sha256' })
throw new TypeError(`Unsupported Vonage algorithm: ${algorithm}`);
}
const data = canonicalVonageParams(params);
if (normalized === 'md5hash') {
return createHash('md5').update(`${data}${secret}`).digest('hex');
}
return hmacBytes(normalized, secret, data).toString('hex');
}

Expand All @@ -50,20 +44,12 @@ export function verifyVonageWebhook(options) {
signature = params?.sig,
secret,
algorithm = 'sha256',
allowLegacyMd5 = false,
maxAgeSeconds = 300,
maxFutureSkewSeconds = 30,
now,
} = options || {};
if (!signature) return invalid(provider, 'MISSING_SIGNATURE', 'Vonage sig parameter is missing.');
const normalized = String(algorithm).toLowerCase().replace(/[^a-z0-9]/g, '');
if (!allowLegacyMd5 && (normalized === 'md5' || normalized === 'md5hash')) {
return invalid(
provider,
'INSECURE_ALGORITHM',
'Legacy MD5 validation is disabled. Configure Vonage HMAC-SHA256 or opt in explicitly.',
);
}
if (params?.timestamp === undefined) {
return invalid(provider, 'MISSING_TIMESTAMP', 'Vonage timestamp parameter is missing.');
}
Expand Down
9 changes: 9 additions & 0 deletions tests/integration.test.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import assert from 'node:assert/strict';
import { createHash } from 'node:crypto';
import { readFileSync } from 'node:fs';
import { spawnSync } from 'node:child_process';
import test from 'node:test';
Expand Down Expand Up @@ -68,11 +69,19 @@ test('CLI help exits successfully', () => {

test('documentation site is static, disclosed, and contains no secret form fields', () => {
const html = readFileSync('site/index.html', 'utf8');
const headers = readFileSync('site/_headers', 'utf8');
const structuredData = html.match(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/)?.[1];
const structuredDataHash = createHash('sha256').update(structuredData).digest('base64');
assert.match(html, /Secure SMS Webhook Validator/);
assert.match(html, /receive-smss\.live\/sms\/us/);
assert.match(html, /maintained by the same team/i);
assert.doesNotMatch(html, /type=["']password/);
assert.doesNotMatch(html, /<input[^>]+name=["'][^"']*(secret|token|key)/i);
assert.match(headers, /Content-Security-Policy:/);
assert.match(headers, /frame-ancestors 'none'/);
assert.match(headers, /X-Frame-Options: DENY/);
assert.ok(headers.includes(`'sha256-${structuredDataHash}'`));
assert.doesNotMatch(headers, /unsafe-inline|unsafe-eval/);
});

test('package surface does not contain placeholder runtime imports', () => {
Expand Down
18 changes: 14 additions & 4 deletions tests/providers.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -120,11 +120,21 @@ test('Vonage HMAC-SHA256 matches canonical parameter vector', () => {
}).valid, true);
});

test('Vonage rejects legacy MD5 unless explicitly allowed', () => {
test('Vonage does not expose legacy MD5 algorithms', () => {
const params = { timestamp: '1700000000', text: 'hello' };
const signature = createVonageSignature({ params, secret: 'secret', algorithm: 'md5hash' });
assert.equal(verifyVonageWebhook({ params, signature, secret: 'secret', algorithm: 'md5hash', maxAgeSeconds: false }).code, 'INSECURE_ALGORITHM');
assert.equal(verifyVonageWebhook({ params, signature, secret: 'secret', algorithm: 'md5hash', allowLegacyMd5: true, maxAgeSeconds: false }).valid, true);
for (const algorithm of ['md5', 'md5hash']) {
assert.throws(
() => createVonageSignature({ params, secret: 'secret', algorithm }),
/Unsupported Vonage algorithm/,
);
assert.equal(verifyVonageWebhook({
params,
signature: '00'.repeat(16),
secret: 'secret',
algorithm,
maxAgeSeconds: false,
}).code, 'CONFIGURATION_ERROR');
}
});

test('Plivo V3 matches a fixed HMAC-SHA256 vector and token rotation list', () => {
Expand Down