SRI の integrity が効かない書き方を仕様と Chromium で確かめる

CDN から読み込む JavaScript や CSS に integrity="sha384-..." を付けておくと、配信元でファイルが差し替えられたときにブラウザが実行を止めてくれます。これが Subresource Integrity(SRI) です。

ところが integrity 属性は、書き間違えると「ブロックされる」だけでなく「検証そのものが外れて素通りする」 ことがあります。後者はページが普通に動くので気づけません。

hashitosystem の SRIハッシュ生成 は、ファイルから integrity の値と crossorigin 付きのタグを作るツールです。この記事では、ツールが出す値の形がなぜそうなっているのかを、W3C の仕様の判定手順と、実際のブラウザ(Chromium 152)の挙動の両方で確かめます。

仕様の判定は「最も強いアルゴリズムだけ」「どれか1つ一致で合格」

W3C の Subresource Integrity 仕様(Editor’s Draft) は、integrity 属性を次の順で判定します。

  1. 属性を空白で区切り、各トークンの - より前をアルゴリズム名として読む
  2. アルゴリズム名を ASCII 小文字にして sha256 / sha384 / sha512 のどれでもなければ、そのトークンは黙って読み飛ばす
  3. 読めたトークンが1つも無ければ true(合格)を返す
  4. 読めたトークンのうち最も強いアルゴリズムのものだけを残す(sha256 < sha384 < sha512)
  5. 残った値のうちどれか1つがダウンロードした中身のハッシュと一致すれば合格、1つも一致しなければ不合格

ハッシュ値はダイジェストのバイト列を Base64 で書いたものです。MDN の Subresource Integrity も同じ説明で、openssl dgst -sha384 -binary | openssl base64 -A で値を作る手順を載せています。

ここから、次の3つが仕様上そのまま導けます。

書き方 仕様上の結果 理由
sha384- の後ろに16進のハッシュを書く ブロック Base64 と文字列で比較するので一致しない
sha256-正しい値 sha384-古い値 ブロック sha384 だけで判定し、sha256 は見ない
sha384-古い値 sha384-新しい値 実行 同じ強さの値はどれか1つ一致すればよい
md5-... など知らないアルゴリズムだけ 実行(検証なし) 読めるトークンが0件なので合格扱い

最後の行が「黙って外れる」形です。知らないアルゴリズム名は書き間違いでも同じ扱いになるので、綴りを誤るとエラーではなく「integrity 属性が無いのと同じ」状態になります。

別オリジンでは CORS が要る

仕様は「SRI は CORS を必要とし、CORS なしで使うのは論理的な誤り」と注記しています。MDN も、crossorigin 属性の無い別オリジンのリクエストは no-cors モードになり、SRI を使えないので常に失敗すると説明しています。

no-cors のレスポンスはページから中身を読めない(不透明な)レスポンスで、仕様はハッシュの一致・不一致を見せることが別オリジンの中身の漏えいにつながる点をセキュリティ上の考慮事項として挙げています。そのため別オリジンの CDN に SRI を掛けるには、タグに crossorigin="anonymous" を付け、配信側が Access-Control-Allow-Origin を返す必要があります。SRIハッシュ生成 が出すタグに crossorigin="anonymous" が必ず入っているのはこのためです。

Chromium 152 で10通りを実測した結果

仕様どおりに動くかを、後述の「実際に試す」のスクリプトで Chromium 152(UA: Chrome/152.0.7977.130、Windows 11)に読ませました。すべて同じ console.log("hello"); の1行ファイルで、「実行された」は onload、「ブロック」は onerror が発火したことを表します。

# integrity の書き方 仕様から予想 Chromium 152 の実測
1 正しい sha384 実行 実行された
2 16進で書いた sha384 ブロック ブロック
3 sha256 正 + sha384 誤 ブロック ブロック
4 sha384 旧(誤)+ 新(正) 実行 実行された
5 md5-AAAA だけ 実行(検証なし) 実行された
6 SHA384-AAAA(大文字・誤った値) ブロック 実行された
7 sha-384-AAAA(ハイフン入り・誤った値) 実行(検証なし) ブロック
8 別オリジン・crossorigin なし ブロック ブロック
9 別オリジン・crossorigin あり・CORS ヘッダなし ブロック ブロック
10 別オリジン・crossorigin あり・CORS ヘッダあり 実行 実行された

1〜5 と 8〜10 は仕様どおりでした。6 と 7 は仕様の予想と逆になりました。

  • 6(大文字): 仕様はアルゴリズム名を ASCII 小文字にしてから照合するので、SHA384- は sha384 として読まれ、誤った値ならブロックされるはずです。Chromium 152 はコンソールに「integrity 属性の解析エラー。アルゴリズムは sha256 / sha384 / sha512 のいずれか」という趣旨のエラーを出し、検証しないまま実行しました。値が誤っていても動くので、大文字で書くと保護が外れます
  • 7(ハイフン入り): 仕様では sha という未知のアルゴリズムとして読み飛ばされ、検証なしで実行されるはずです。Chromium 152 は sha-384- を sha384 として扱い、誤った値をブロックしました

つまり「大文字で書く」「ハイフンを入れる」は、ブラウザ実装によって結果が割れる書き方です。小文字の sha256 / sha384 / sha512 以外を書かないのが唯一安全な書き方で、ツールも常に小文字の接頭辞で出力しています。なお Firefox と Safari は今回試していないので、両者の挙動はここでは断定しません。

8 と 9 はどちらもブロックですが、コンソールの理由は違います。8 は「integrity 属性があるのに CORS でないので検証できず、ブロックした」、9 は CORS ポリシー違反(Access-Control-Allow-Origin ヘッダが無い)でした。

もう1つの落とし穴: 改行コードで値が変わる

SRI のハッシュはファイルのバイト列そのものに対して計算します。同じ見た目のスクリプトでも、改行が LF か CRLF かで値は変わります(下の「実際に試す」で確かめます)。Windows の手元で CRLF に変換されたファイルからハッシュを作り、CDN には LF のファイルが載っている、という組み合わせでは本番でブロックされます。

値は配信されるファイルそのものから作るのが確実です。ツールで作る場合も、テキストを貼り付けるより「ファイルを選ぶ」ほうがバイト列を変えずにハッシュ化できます。

実際に試す

前提: Node.js 18 以降(node:crypto と node:http だけを使い、外部パッケージは不要)。筆者は Node.js v24.11.1、OpenSSL 3.2.3(Git for Windows 同梱)、Windows 11 で実行しました。

1. integrity の値を作り、仕様の判定を再現する

次のファイルを sri.mjs として保存します。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
// sri.mjs — Node.js 18 以降(node:crypto と node:fs だけで動く。依存なし)
import { createHash } from "node:crypto";
import { readFileSync, writeFileSync } from "node:fs";

const ORDER = ["sha256", "sha384", "sha512"]; // 仕様の «valid SRI hash algorithm token set»(弱い順)

// integrity の値を作る: アルゴリズム名 + "-" + ダイジェストのバイト列を Base64
export function integrity(bytes, alg = "sha384") {
return `${alg}-${createHash(alg).update(bytes).digest("base64")}`;
}

// 仕様の «parse metadata» を簡略化したもの(? 以降のオプションは捨てる)
function parse(attr) {
const out = [];
for (const item of attr.split(/\s+/).filter(Boolean)) {
const expr = item.split("?")[0];
const i = expr.indexOf("-");
if (i < 0) continue;
const alg = expr.slice(0, i).toLowerCase();
if (!ORDER.includes(alg)) continue; // 知らないアルゴリズムは黙って読み飛ばす
out.push({ alg, val: expr.slice(i + 1) });
}
return out;
}

// 仕様の «do bytes match metadataList?»
export function matches(bytes, attr) {
const parsed = parse(attr);
if (parsed.length === 0) return true; // 何も読めなければ «検証しない» = 通す
const strongest = Math.max(...parsed.map((m) => ORDER.indexOf(m.alg)));
return parsed
.filter((m) => ORDER.indexOf(m.alg) === strongest) // 最も強いアルゴリズムの値だけ使う
.some((m) => createHash(m.alg).update(bytes).digest("base64") === m.val);
}

const lf = Buffer.from('console.log("hello");\n');
const crlf = Buffer.from('console.log("hello");\r\n');
writeFileSync("lib.js", lf);

const good384 = integrity(readFileSync("lib.js"));
const good256 = integrity(lf, "sha256");
const hex384 = "sha384-" + createHash("sha384").update(lf).digest("hex");
const other384 = integrity(Buffer.from("// v2\n"));

console.log("sha384 :", good384);
console.log("LF と CRLF :", good384 === integrity(crlf) ? "同じ" : "違う");
const cases = {
"正しい sha384": good384,
"16進で書いた sha384": hex384,
"sha256 正 + sha384 誤": `${good256} ${other384}`,
"sha384 旧 + sha384 新": `${other384} ${good384}`,
"sha-384- と綴り違い": good384.replace("sha384-", "sha-384-"),
"SHA384(大文字)": good384.replace("sha384-", "SHA384-"),
};
for (const [label, attr] of Object.entries(cases)) {
console.log(`${label.padEnd(20)} -> ${matches(lf, attr) ? "通る" : "ブロック"}`);
}

実行し、同じ lib.js を OpenSSL(MDN に載っている手順)でもハッシュ化して突き合わせます。

1
2
node sri.mjs
cat lib.js | openssl dgst -sha384 -binary | openssl base64 -A; echo

実際の出力です(全角文字を含むラベルは padEnd で揃わないため、桁がずれています)。

1
2
3
4
5
6
7
8
9
sha384       : sha384-M5mGpKxRozBpvsX+PXs0ssm1NdoBYDPN4gQsyCwq+RTqmuLt5T6LWQKklQd4sArc
LF と CRLF : 違う
正しい sha384 -> 通る
16進で書いた sha384 -> ブロック
sha256 正 + sha384 誤 -> ブロック
sha384 旧 + sha384 新 -> 通る
sha-384- と綴り違い -> 通る
SHA384(大文字) -> 通る
M5mGpKxRozBpvsX+PXs0ssm1NdoBYDPN4gQsyCwq+RTqmuLt5T6LWQKklQd4sArc

Node.js で作った値と OpenSSL の値が一致しています。sha-384- と SHA384 の2行は、ここでは正しいハッシュ値を入れているので、仕様のモデルでは「読み飛ばして検証なしで通る」「小文字にして検証して通る」と、同じ「通る」でも理由が違います。ブラウザでの差は次の手順で確かめます。

2. 同じケースをブラウザに読ませる

次のファイルを sri-lab.mjs として保存します。localhost と 127.0.0.1 は別オリジンになるので、1つのサーバーで別オリジンの読み込みも試せます。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
// sri-lab.mjs — Node.js 18 以降(依存なし)。http://localhost:8765/ をブラウザで開く
import http from "node:http";
import { createHash } from "node:crypto";

const lib = Buffer.from('console.log("hello");\n');
const b64 = (alg) => createHash(alg).update(lib).digest("base64");
const good = `sha384-${b64("sha384")}`;
const X = "http://127.0.0.1:8765/lib.js"; // localhost から見ると別オリジン

// [ケース名, src, integrity, crossorigin を付けるか]
const cases = [
["正しい sha384", "lib.js?1", good, false],
["16進で書いた sha384", "lib.js?2", `sha384-${createHash("sha384").update(lib).digest("hex")}`, false],
["sha256 正 + sha384 誤", "lib.js?3", `sha256-${b64("sha256")} sha384-AAAA`, false],
["sha384 旧(誤) + 新(正)", "lib.js?4", `sha384-AAAA ${good}`, false],
["md5 だけ(誤)", "lib.js?5", "md5-AAAA", false],
["SHA384 大文字(誤)", "lib.js?6", "SHA384-AAAA", false],
["sha-384 ハイフン入り(誤)", "lib.js?7", "sha-384-AAAA", false],
["別オリジン・crossorigin なし", `${X}?8`, good, false],
["別オリジン・CORS ヘッダなし", `${X}?9`, good, true],
["別オリジン・CORS あり", `${X}?cors`, good, true],
];

const page = `<!doctype html><meta charset="utf-8"><title>SRI lab</title><pre id="out"></pre>
<script>const R = {};</script>
${cases.map(([name, src, integ, co], i) =>
`<script src="${src}" integrity="${integ}"${co ? ' crossorigin="anonymous"' : ""}
onload="R[${i}]='実行された'" onerror="R[${i}]='ブロック'"></script>`).join("\n")}
<script>
const names = ${JSON.stringify(cases.map((c) => c[0]))};
out.textContent = navigator.userAgent + "\\n" + names.map((n, i) => n + " -> " + R[i]).join("\\n");
</script>`;

http.createServer((req, res) => {
const [path, query] = req.url.split("?");
if (path === "/lib.js") {
const h = { "content-type": "text/javascript" };
if (query === "cors") h["access-control-allow-origin"] = "*";
res.writeHead(200, h).end(lib);
} else {
res.writeHead(200, { "content-type": "text/html; charset=utf-8" }).end(page);
}
}).listen(8765, () => console.log("open http://localhost:8765/"));

起動して、ブラウザで http://localhost:8765/ を開きます(止めるときは Ctrl+C)。

1
node sri-lab.mjs

Chromium 152 で開いたときにページに表示された内容です。

1
2
3
4
5
6
7
8
9
10
11
Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Claude/2.9939.2 Chrome/152.0.7977.130 Safari/537.36 MSIX
正しい sha384 -> 実行された
16進で書いた sha384 -> ブロック
sha256 正 + sha384 誤 -> ブロック
sha384 旧(誤) + 新(正) -> 実行された
md5 だけ(誤) -> 実行された
SHA384 大文字(誤) -> 実行された
sha-384 ハイフン入り(誤) -> ブロック
別オリジン・crossorigin なし -> ブロック
別オリジン・CORS ヘッダなし -> ブロック
別オリジン・CORS あり -> 実行された

「md5 だけ」と「SHA384 大文字」は値が誤っているのに実行されています。開発者ツールのコンソールには integrity 属性の解析エラーが出ますが、ページは普通に動きます。お使いのブラウザで開けば、そのブラウザでの結果が同じ形で表示されます。

3. 本番のタグで使う値を作る

本番のタグに付ける値は、配信されるファイルそのものから作ります。jQuery の公式 CDN で試すと、公開されている値と突き合わせられます。

1
curl -s https://code.jquery.com/jquery-3.7.1.min.js | openssl dgst -sha256 -binary | openssl base64 -A; echo

2026-09-28 に実行した出力は /JqT3SQfawRcv/BIHPThkBvs0OEvtFFmqPF/lYI/Cxo= で、jQuery の配布ページが jquery-3.7.1.min.js に載せている sha256-/JqT3SQfawRcv/BIHPThkBvs0OEvtFFmqPF/lYI/Cxo= と一致しました。自分のファイルに使うときは URL を置き換え、-sha256 を -sha384 にして先頭に sha384- を付けます。ブラウザで済ませたい場合は SRIハッシュ生成 にファイルを選ぶと、小文字の sha384- 付きの値と crossorigin="anonymous" 付きの script / link タグがそのまま出ます。計算は WebCrypto でブラウザ内だけで行われます。16進のハッシュが必要な別用途にはハッシュ計算を使い分けてください。

まとめ

  • integrity の値は 小文字のアルゴリズム名-Base64。16進を貼るとブロックされる
  • 複数書いたときは最も強いアルゴリズムだけで判定される。更新の移行期は同じアルゴリズムで旧値と新値を並べる
  • 知らないアルゴリズムしか書かれていない属性は、仕様上も Chromium 152 でも検証なしで実行される
  • 大文字の SHA384- は、Chromium 152 では解析エラーのまま実行された(仕様上は検証される書き方)
  • 別オリジンでは crossorigin="anonymous" と配信側の CORS ヘッダが両方要る

参考にした一次情報