mirror of
https://github.com/kou029w/websri.git
synced 2025-01-17 23:55:14 +00:00
create subresourceintegrity
This commit is contained in:
parent
87a3f47c4e
commit
a7c7e41d6e
12 changed files with 6216 additions and 0 deletions
35
.github/workflows/release.yml
vendored
Normal file
35
.github/workflows/release.yml
vendored
Normal file
|
@ -0,0 +1,35 @@
|
|||
name: release
|
||||
run-name: "${{ inputs.version }}"
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: 'Increment "major", "minor", "patch", or "pre*" version; or specify version'
|
||||
default: patch
|
||||
required: true
|
||||
jobs:
|
||||
main:
|
||||
if: github.ref == 'refs/heads/main'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: lts/*
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- run: npm test
|
||||
- name: Release
|
||||
run: |
|
||||
git config user.email "${{ github.actor }}@users.noreply.github.com"
|
||||
git config user.name "${{ github.actor }}"
|
||||
npm run release -- "${{ github.event.inputs.version }}"
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NODE_AUTH_TOKEN }}
|
21
.github/workflows/test.yml
vendored
Normal file
21
.github/workflows/test.yml
vendored
Normal file
|
@ -0,0 +1,21 @@
|
|||
name: test
|
||||
on: push
|
||||
jobs:
|
||||
nodejs:
|
||||
name: Node.js v${{ matrix.node-version }}
|
||||
strategy:
|
||||
matrix:
|
||||
node-version:
|
||||
- 18
|
||||
- 20
|
||||
- 22
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- run: npm test
|
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
|
@ -0,0 +1,2 @@
|
|||
dist
|
||||
node_modules
|
15
.release-it.json
Normal file
15
.release-it.json
Normal file
|
@ -0,0 +1,15 @@
|
|||
{
|
||||
"$schema": "https://unpkg.com/release-it@17/schema/release-it.json",
|
||||
"git": {
|
||||
"tagName": "v${version}"
|
||||
},
|
||||
"github": {
|
||||
"release": true
|
||||
},
|
||||
"plugins": {
|
||||
"@release-it/keep-a-changelog": {
|
||||
"addUnreleased": true,
|
||||
"addVersionUrl": true
|
||||
}
|
||||
}
|
||||
}
|
8
CHANGELOG.md
Normal file
8
CHANGELOG.md
Normal file
|
@ -0,0 +1,8 @@
|
|||
# Changelog
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [Unreleased]
|
50
README.md
Normal file
50
README.md
Normal file
|
@ -0,0 +1,50 @@
|
|||
# subresourceintegrity
|
||||
|
||||
[![NPM Version](https://img.shields.io/npm/v/subresourceintegrity)](https://www.npmjs.com/package/subresourceintegrity) [![jsDocs.io](https://img.shields.io/badge/jsDocs.io-reference-blue)](https://www.jsdocs.io/package/subresourceintegrity)
|
||||
|
||||
`subresourceintegrity` is a utility designed for Subresource Integrity that works across various web-interoperable runtimes, including Node.js, browsers, Cloudflare Workers, Deno, Bun, and others.
|
||||
|
||||
## Usage
|
||||
|
||||
Install package:
|
||||
|
||||
```sh
|
||||
# npm
|
||||
npm install subresourceintegrity
|
||||
|
||||
# yarn
|
||||
yarn add subresourceintegrity
|
||||
|
||||
# pnpm
|
||||
pnpm install subresourceintegrity
|
||||
|
||||
# bun
|
||||
bun install subresourceintegrity
|
||||
```
|
||||
|
||||
[Integrity Metadata](https://www.w3.org/TR/SRI/#integrity-metadata):
|
||||
|
||||
```ts
|
||||
import { createIntegrityMetadata } from "subresourceintegrity";
|
||||
|
||||
const res = new Response("Hello, world!");
|
||||
const data = await res.arrayBuffer();
|
||||
const integrityMetadata = await createIntegrityMetadata("sha256", data);
|
||||
|
||||
console.log(integrityMetadata.toString());
|
||||
// sha256-MV9b23bQeMQ7isAGTkoBZGErH853yGk0W/yUx1iU7dM=
|
||||
```
|
||||
|
||||
## Documentation
|
||||
|
||||
[API Reference](https://www.jsdocs.io/package/subresourceintegrity)
|
||||
|
||||
## FAQ
|
||||
|
||||
### What is Subresource Integrity?
|
||||
|
||||
[Subresource Integrity (SRI)](https://www.w3.org/TR/SRI/) is a security feature that allows user agents to verify that a fetched resource has not been manipulated unexpectedly.
|
||||
|
||||
## License
|
||||
|
||||
`subresourceintegrity` is released under the MIT License.
|
5827
package-lock.json
generated
Normal file
5827
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
38
package.json
Normal file
38
package.json
Normal file
|
@ -0,0 +1,38 @@
|
|||
{
|
||||
"name": "subresourceintegrity",
|
||||
"version": "0.0.0",
|
||||
"description": "Subresource Integrity",
|
||||
"license": "MIT",
|
||||
"author": "Kohei Watanabe <nebel@fogtype.com>",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/kou029w/subresourceintegrity.git"
|
||||
},
|
||||
"type": "module",
|
||||
"exports": {
|
||||
"import": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/index.d.cts",
|
||||
"default": "./dist/index.cjs"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsup",
|
||||
"prepublishOnly": "tsup",
|
||||
"test": "tsup && node --test",
|
||||
"release": "release-it --"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@release-it/keep-a-changelog": "5.0.0",
|
||||
"@types/node": "22.5.2",
|
||||
"release-it": "17.6.0",
|
||||
"tsup": "8.2.4",
|
||||
"typescript": "5.5.4"
|
||||
}
|
||||
}
|
4
renovate.json
Normal file
4
renovate.json
Normal file
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||
"extends": ["config:best-practices", ":automergeAll"]
|
||||
}
|
167
src/index.ts
Normal file
167
src/index.ts
Normal file
|
@ -0,0 +1,167 @@
|
|||
/** Content Security Policy Level 2, section 4.2 */
|
||||
export type HashAlgorithm = "sha256" | "sha384" | "sha512";
|
||||
|
||||
export const supportedHashAlgorithm: ReadonlyArray<HashAlgorithm> = [
|
||||
"sha512",
|
||||
"sha384",
|
||||
"sha256",
|
||||
];
|
||||
|
||||
export const supportedHashAlgorithmName = {
|
||||
sha256: "SHA-256",
|
||||
sha384: "SHA-384",
|
||||
sha512: "SHA-512",
|
||||
} satisfies Record<HashAlgorithm, string>;
|
||||
|
||||
export type PrioritizedHash = "" | HashAlgorithm;
|
||||
|
||||
export function getPrioritizedHash(
|
||||
a: HashAlgorithm,
|
||||
b: HashAlgorithm,
|
||||
): PrioritizedHash {
|
||||
if (a === b) return "";
|
||||
if (!supportedHashAlgorithm.includes(a)) return "";
|
||||
if (!supportedHashAlgorithm.includes(b)) return "";
|
||||
return a < b ? b : a;
|
||||
}
|
||||
|
||||
export const IntegrityMetadataRegex =
|
||||
/^(?<alg>sha256|sha384|sha512)-(?<val>(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?)(?:[?](?<opts>[\x21-\x7e]*))?$/;
|
||||
|
||||
export const SeparatorRegex = /[^\x21-\x7e]+/;
|
||||
|
||||
/** Integrity Metadata */
|
||||
export class IntegrityMetadata {
|
||||
alg: PrioritizedHash;
|
||||
val: string;
|
||||
opt: string[];
|
||||
|
||||
constructor(integrity: string) {
|
||||
const {
|
||||
alg = "",
|
||||
val = "",
|
||||
opt,
|
||||
} = IntegrityMetadataRegex.exec(integrity)?.groups ?? {};
|
||||
|
||||
Object.assign(this, {
|
||||
alg,
|
||||
val,
|
||||
opt: opt?.split("?") ?? [],
|
||||
});
|
||||
}
|
||||
|
||||
match(integrity: { alg: PrioritizedHash; val: string }): boolean {
|
||||
return integrity.alg === this.alg && integrity.val === this.val;
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
return IntegrityMetadata.stringify(this);
|
||||
}
|
||||
|
||||
toJSON(): string {
|
||||
return this.toString();
|
||||
}
|
||||
|
||||
static stringify(integrity: {
|
||||
alg: PrioritizedHash;
|
||||
val: string;
|
||||
opt: string[];
|
||||
}): string {
|
||||
if (!integrity.alg) return "";
|
||||
if (!integrity.val) return "";
|
||||
if (!supportedHashAlgorithm.includes(integrity.alg)) return "";
|
||||
return `${integrity.alg}-${[integrity.val, ...integrity.opt].join("?")}`;
|
||||
}
|
||||
}
|
||||
|
||||
export async function createIntegrityMetadata(
|
||||
hashAlgorithm: HashAlgorithm,
|
||||
data: ArrayBuffer,
|
||||
opt: string[] = [],
|
||||
): Promise<string> {
|
||||
const alg = hashAlgorithm.toLowerCase() as HashAlgorithm;
|
||||
|
||||
if (!supportedHashAlgorithm.includes(alg)) return "";
|
||||
|
||||
const arrayBuffer = await crypto.subtle.digest(
|
||||
supportedHashAlgorithmName[alg.toLowerCase()],
|
||||
data,
|
||||
);
|
||||
|
||||
const val = btoa(String.fromCharCode(...new Uint8Array(arrayBuffer)));
|
||||
|
||||
return IntegrityMetadata.stringify({
|
||||
alg,
|
||||
val,
|
||||
opt,
|
||||
});
|
||||
}
|
||||
|
||||
/** Subresource Integrity */
|
||||
export class SubresourceIntegrity extends Map<
|
||||
HashAlgorithm,
|
||||
IntegrityMetadata
|
||||
> {
|
||||
getPrioritizedHash: (a: HashAlgorithm, b: HashAlgorithm) => PrioritizedHash;
|
||||
|
||||
constructor(integrity: string, options = { getPrioritizedHash }) {
|
||||
super();
|
||||
|
||||
const integrityMetadata = integrity.split(SeparatorRegex);
|
||||
|
||||
for (const integrity of integrityMetadata.filter(Boolean)) {
|
||||
const integrityMetadata = new IntegrityMetadata(integrity);
|
||||
|
||||
if (integrityMetadata.alg) {
|
||||
this.set(integrityMetadata.alg, integrityMetadata);
|
||||
}
|
||||
}
|
||||
|
||||
this.getPrioritizedHash = options.getPrioritizedHash;
|
||||
}
|
||||
|
||||
get strongest(): IntegrityMetadata {
|
||||
const [hashAlgorithm = supportedHashAlgorithm[0]]: HashAlgorithm[] = [
|
||||
...this.keys(),
|
||||
].sort((a, b) => {
|
||||
switch (this.getPrioritizedHash(a, b)) {
|
||||
default:
|
||||
case "":
|
||||
return 0;
|
||||
case a:
|
||||
return -1;
|
||||
case b:
|
||||
return +1;
|
||||
}
|
||||
});
|
||||
|
||||
return this.get(hashAlgorithm) ?? new IntegrityMetadata("");
|
||||
}
|
||||
|
||||
match(integrity: { alg: PrioritizedHash; val: string }): boolean {
|
||||
return this.strongest.match(integrity);
|
||||
}
|
||||
|
||||
join(separator = " ") {
|
||||
return [...this.values()].map(String).join(separator);
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
return this.join();
|
||||
}
|
||||
|
||||
toJSON(): string {
|
||||
return this.toString();
|
||||
}
|
||||
}
|
||||
|
||||
export async function createSubresourceIntegrity(
|
||||
hashAlgorithms: HashAlgorithm[],
|
||||
data: ArrayBuffer,
|
||||
): Promise<SubresourceIntegrity> {
|
||||
const integrityMetadata = await Promise.all(
|
||||
hashAlgorithms.map((alg) => createIntegrityMetadata(alg, data)),
|
||||
);
|
||||
|
||||
return new SubresourceIntegrity(integrityMetadata.join(" "));
|
||||
}
|
39
test/index.js
Normal file
39
test/index.js
Normal file
|
@ -0,0 +1,39 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import {
|
||||
createIntegrityMetadata,
|
||||
createSubresourceIntegrity,
|
||||
SubresourceIntegrity,
|
||||
} from "../dist/index.js";
|
||||
|
||||
test("createIntegrityMetadata()", async function () {
|
||||
const res = new Response("Hello, world!");
|
||||
const data = await res.arrayBuffer();
|
||||
const integrityMetadata = await createIntegrityMetadata("sha256", data);
|
||||
|
||||
assert.strictEqual(
|
||||
integrityMetadata.toString(),
|
||||
"sha256-MV9b23bQeMQ7isAGTkoBZGErH853yGk0W/yUx1iU7dM=",
|
||||
);
|
||||
});
|
||||
|
||||
test("createSubresourceIntegrity()", async function () {
|
||||
const res = new Response("Hello, world!");
|
||||
const data = await res.arrayBuffer();
|
||||
const sri = await createSubresourceIntegrity(["sha384", "sha512"], data);
|
||||
|
||||
assert.strictEqual(
|
||||
sri.toString(),
|
||||
"sha384-VbxVaw0v4Pzlgrpf4Huq//A1ZTY4x6wNVJTCpkwL6hzFczHHwSpFzbyn9MNKCJ7r sha512-wVJ82JPBJHc9gRkRlwyP5uhX1t9dySJr2KFgYUwM2WOk3eorlLt9NgIe+dhl1c6ilKgt1JoLsmn1H256V/eUIQ==",
|
||||
);
|
||||
});
|
||||
|
||||
test("SubresourceIntegrity.strongest", async function () {
|
||||
const { strongest } = new SubresourceIntegrity(`
|
||||
sha256-MV9b23bQeMQ7isAGTkoBZGErH853yGk0W/yUx1iU7dM=
|
||||
sha384-VbxVaw0v4Pzlgrpf4Huq//A1ZTY4x6wNVJTCpkwL6hzFczHHwSpFzbyn9MNKCJ7r
|
||||
sha512-wVJ82JPBJHc9gRkRlwyP5uhX1t9dySJr2KFgYUwM2WOk3eorlLt9NgIe+dhl1c6ilKgt1JoLsmn1H256V/eUIQ==
|
||||
`);
|
||||
|
||||
assert.strictEqual(strongest.alg, "sha512");
|
||||
});
|
10
tsup.config.ts
Normal file
10
tsup.config.ts
Normal file
|
@ -0,0 +1,10 @@
|
|||
import { defineConfig } from "tsup";
|
||||
|
||||
export default defineConfig(() => {
|
||||
return {
|
||||
clean: true,
|
||||
dts: true,
|
||||
entry: ["src"],
|
||||
format: ["cjs", "esm"],
|
||||
};
|
||||
});
|
Loading…
Add table
Reference in a new issue