Skip to content

Commit

Permalink
Add inline script loader
Browse files Browse the repository at this point in the history
This (re-)adds an inline script loader.

This is a workaround for [SRI](https://developer.mozilla.org/en-US/docs/Web/Security/Subresource_Integrity) not working very well with whitelisted scripts in the CSP. See `inlineScriptsPlugin` for a description of the limitation.
  • Loading branch information
nmattia committed Feb 12, 2024
1 parent 48cd955 commit 802217d
Show file tree
Hide file tree
Showing 6 changed files with 47 additions and 84 deletions.
1 change: 1 addition & 0 deletions Cargo.lock

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

13 changes: 1 addition & 12 deletions package-lock.json

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

1 change: 1 addition & 0 deletions src/internet_identity/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ serde_bytes.workspace = true
serde_cbor.workspace = true
serde_json = { version = "1.0", default-features = false, features = ["std"] }
sha2.workspace = true
base64.workspace = true

# Captcha deps
lodepng = "*"
Expand Down
55 changes: 29 additions & 26 deletions src/internet_identity/src/assets.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,22 +4,29 @@
use crate::http::security_headers;
use crate::state;
use asset_util::{collect_assets, Asset, CertifiedAssets, ContentEncoding, ContentType};
use base64::engine::general_purpose::STANDARD as BASE64;
use base64::Engine;
use ic_cdk::api;
use include_dir::{include_dir, Dir};
use sha2::Digest;

// used both in init and post_upgrade
pub fn init_assets() {
state::assets_mut(|certified_assets| {
let assets = get_static_assets();

// Extract the integrity hashes from all the HTML files
// i.e. go through the HTML and grab INTEGRITY from all the <script integrity="INTEGRITY">
// Extract integrity hashes for all inlined scripts, from all the HTML files.
let integrity_hashes = assets
.iter()
.filter(|asset| asset.content_type == ContentType::HTML)
.fold(vec![], |mut acc: Vec<String>, e| {
let content = std::str::from_utf8(&e.content).unwrap().to_string();
acc.append(&mut extract_integrity_hashes(content));
for inlined in extract_inline_scripts(content).iter() {
let hash = sha2::Sha384::digest(inlined.as_bytes());
let hash = BASE64.encode(hash);
let hash = format!("sha384-{hash}");
acc.push(hash);
}
acc
});

Expand All @@ -31,11 +38,9 @@ pub fn init_assets() {
// Fix up HTML pages, by injecting canister ID
fn fixup_html(html: &str) -> String {
let canister_id = api::id();
// XXX: to match, we rely on the fact that our bundle injects an 'integrity' attribute
// before all other attributes
html.replace(
r#"<script integrity="#,
&format!(r#"<script data-canister-id="{canister_id}" integrity="#),
r#"<script "#,
&format!(r#"<script data-canister-id="{canister_id}" "#),
)
}

Expand All @@ -57,35 +62,33 @@ pub fn get_static_assets() -> Vec<Asset> {
}

/// Extract all integrity hashes from the given HTML
fn extract_integrity_hashes(content: String) -> Vec<String> {
// Extract anything between 'integrity="' and '"'; simpler & more efficient
fn extract_inline_scripts(content: String) -> Vec<String> {
// Extract content (C to D~ below) of <script> tags; simpler & more efficient
// than parsing the HTML and works well enough
let hash_prefix = r#"integrity=""#;
let hash_suffix = r#"""#;
//
// <script foo=bar>var foo = 42;</script>
// ^ ^^ ^
// A BC D
content
.match_indices(hash_prefix)
.map(|(size, _)| {
let offset = size + hash_prefix.len();
let len = content[offset..].find(hash_suffix).unwrap();
content[offset..(offset + len)].to_string()
.match_indices("<script")
.map(|(tag_open_start /* A */, _)| {
let tag_open_len = content[tag_open_start..].find('>').unwrap(); /* B */
let inline_start /* C */ = tag_open_start + tag_open_len + 1;
let tag_close_start /* D */ = content[inline_start..].find("</script>").unwrap();
content[(inline_start)..(inline_start + tag_close_start)].to_string()
})
.collect()
}

#[test]
fn test_extract_integrity_hashes() {
fn test_extract_inline_scripts() {
let expected: Vec<String> = vec![];
let actual = extract_integrity_hashes(r#"<head></head>"#.to_string());
let actual = extract_inline_scripts(r#"<head></head>"#.to_string());
assert_eq!(expected, actual);

let expected: Vec<String> = vec!["hello-world".to_string()];
let actual =
extract_integrity_hashes(r#"<script integrity="hello-world"></script>"#.to_string());
assert_eq!(expected, actual);

let expected: Vec<String> = vec!["one".to_string(), "two".to_string()];
let actual = extract_integrity_hashes(
r#"<script integrity="one"></script><script foo=bar integrity="two" broken"#.to_string(),
let expected: Vec<String> = vec!["this is content".to_string()];
let actual = extract_inline_scripts(
r#"<script integrity="hello-world">this is content</script>"#.to_string(),
);
assert_eq!(expected, actual);
}
57 changes: 13 additions & 44 deletions src/vite-plugins/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,4 @@
import { isNullish, nonNullish } from "@dfinity/utils";
import { createHash } from "crypto";
import { writeFileSync } from "fs";
import { minify } from "html-minifier-terser";
import { extname } from "path";
import type { Plugin, ViteDevServer } from "vite";
Expand Down Expand Up @@ -142,52 +140,23 @@ export const replicaForwardPlugin = ({
},
});

/** Update the HTML files to include integrity hashes for script.
* i.e.: `<script src="foo.js">` becomes `<script integrity="<hash(./foo.js)>" src="foo.js">`.
/** Update the HTML files to inline script imports.
* i.e.: `<script src="foo.js"></script>` becomes `<script>... insert new script node...</script>`.
*
* This allows us to use the `strict-dynamic` CSP directive.
* Otherwise, the directive requires `integrity=...` attributes which (in Chrome) does not easily allow importing
* other scripts. https://github.com/WICG/import-maps/issues/174#issuecomment-987678704
*/
export const integrityPlugin: Plugin = {
export const inlineScriptsPlugin: Plugin = {
name: "integrity",
apply: "build" /* only use during build, not serve */,

// XXX We use writeBundle as opposed to transformIndexHtml because transformIndexHtml still
// includes some variables (VITE_PRELOAD) that will be replaced later (by vite), changing
// the effective checksum. By the time writeBundle is called, the bundle has already been
// written so we update the files directly on the filesystem.
writeBundle(options: any, bundle: any) {
// Matches a script tag, grouping all the attributes (re-injected later) and extracting
// the 'src' attribute
transformIndexHtml(html: string): string {
const rgx =
/<script(?<attrs>(?:\s+[^>]+)*\s+src="?(?<src>[^"]+)"?(?:\s+[^>]+)*)>/g;

const distDir = options.dir;

for (const filename in bundle) {
// If this is not HTML, skip
if (!filename.endsWith(".html")) {
continue;
}

// Grab the source, match all the script tags, inject the hash, and write the updated
// HTML to the filesystem
const html: string = bundle[filename].source;
const replaced = html.replace(rgx, (match, attrs, src) => {
const subresourcePath = src.slice(1); /* drop leading slash */
const item =
bundle[subresourcePath]; /* grab the item from the bundle */
const content = item.source || item.code;
const HASH_ALGO = "sha384" as const;
const integrityHash = createHash(HASH_ALGO)
.update(content)
.digest()
.toString("base64"); /* Compute the hash */

const integrityValue = `${HASH_ALGO}-${integrityHash}`;
return `<script integrity="${integrityValue}"${attrs}>`;
});

// Write the new content to disk
const filepath = [distDir, filename].join("/");
writeFileSync(filepath, replaced);
}
/<script(?<attrs>(?<beforesrc>\s+[^>]+)*\s+src="?(?<src>[^"]+)"?(?<aftersrc>\s+[^>]+)*)>/g;
return html.replace(rgx, (match, _attrs, beforesrc, src, aftersrc) => {
const tag = ["script", beforesrc, aftersrc].filter(Boolean).join(" ");
return `<${tag}>let s = document.createElement('script');s.type = 'module';s.src = '${src}';document.head.appendChild(s);`;
});
},
};
4 changes: 2 additions & 2 deletions vite.config.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import {
compression,
injectCanisterIdPlugin,
integrityPlugin,
inlineScriptsPlugin,
minifyHTML,
replicaForwardPlugin,
} from "@dfinity/internet-identity-vite-plugins";
Expand Down Expand Up @@ -64,7 +64,7 @@ export default defineConfig(({ mode }: UserConfig): UserConfig => {
},
},
plugins: [
integrityPlugin,
inlineScriptsPlugin,
[
...(mode === "development"
? [injectCanisterIdPlugin({ canisterName: "internet_identity" })]
Expand Down

0 comments on commit 802217d

Please sign in to comment.