Skip to content

Commit 2983dd1

Browse files
Web3NLclaude
andauthored
feat: harden frontend crate with security headers, asset validation, and gzip compression (#228)
* feat: add default security and privacy HTTP headers Implement 8 security/privacy headers in create_default_headers(): - X-Content-Type-Options: nosniff - X-Frame-Options: DENY - Referrer-Policy: no-referrer - X-XSS-Protection: 0 (disabled, rely on CSP instead) - Strict-Transport-Security: max-age=31536000; includeSubDomains - Permissions-Policy: deny camera, mic, geolocation, etc. - Cross-Origin-Opener-Policy: same-origin - Cross-Origin-Resource-Policy: same-origin Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: add asset validation with file type allowlist, size limits, and path checks - Add DEFAULT_ALLOWED_EXTENSIONS allowlist (22 web frontend extensions) - Add 2MB default file size limit (DEFAULT_MAX_FILE_SIZE) - Validate paths: reject .., //, and invalid URL characters - Detect duplicate asset paths - Add FrontendConfig for customization (extra extensions, custom size limit) - Add setup_frontend_with_config() and asset_router_configs_with_config() - asset_router_configs() now returns Result to surface validation errors Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: add gzip compression for compressible frontend assets - Add flate2 dependency for gzip compression - Compress text-based assets (HTML, JS, CSS, JSON, SVG, XML, TXT, source maps) at init time and serve both identity and gzip variants - AssetConfig for compressible files now includes both Identity and Gzip encodings - IC asset router automatically negotiates encoding via Accept-Encoding header Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * test: add security header and gzip compression assertions to acceptance suite - Verify all 8 security/privacy headers on frontend fallback response - Verify gzip compression: request with Accept-Encoding returns gzip magic bytes - Verify content-encoding: gzip header in compressed response Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * docs: update changelogs and README for frontend crate improvements Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: use same-origin-allow-popups for COOP header to support II popups Cross-Origin-Opener-Policy: same-origin blocks communication between the dapp window and the Internet Identity popup (different canister origins). Change to same-origin-allow-popups which still protects against being opened by other origins but allows the dapp to open II authentication popups that can communicate back. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent a58644e commit 2983dd1

8 files changed

Lines changed: 619 additions & 49 deletions

File tree

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,3 +33,4 @@ serde_json = "1.0.149"
3333
sha2 = "0.10.9"
3434
num-traits = "0.2.19"
3535
clap = { version = "4", features = ["derive"] }
36+
flate2 = "1.1"

packages-rs/canister-dapp-test/CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44

55
### Added
66

7+
- Security header validation: all 8 security/privacy headers verified on frontend fallback responses
8+
- Gzip compression test: verifies Accept-Encoding negotiation returns compressed responses with correct headers
79
- CLI binary: `canister-dapp-test <wasm-path>` runs the full acceptance suite against any dapp WASM.
810
- HTTP response header validation: Content-Type for HTML/JS/CSS, Content-Security-Policy for dashboard HTML.
911
- SPA fallback test: unknown paths serve index.html with 200 (client-side routing).

packages-rs/canister-dapp-test/src/lib.rs

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -369,6 +369,56 @@ pub fn run_acceptance_suite(wasm_bytes: &[u8], wasm_label: &str) {
369369
);
370370
assert_header_contains(&fallback_resp, "content-type", "text/html");
371371

372+
// ─── Security/privacy headers on frontend responses ─────────────────
373+
// The frontend crate adds 8 security headers to every asset response.
374+
assert_header_contains(&fallback_resp, "x-content-type-options", "nosniff");
375+
assert_header_contains(&fallback_resp, "x-frame-options", "deny");
376+
assert_header_contains(&fallback_resp, "referrer-policy", "no-referrer");
377+
assert_header_contains(&fallback_resp, "x-xss-protection", "0");
378+
assert_header_contains(
379+
&fallback_resp,
380+
"strict-transport-security",
381+
"max-age=31536000",
382+
);
383+
assert_header_contains(&fallback_resp, "permissions-policy", "accelerometer=()");
384+
assert_header_contains(
385+
&fallback_resp,
386+
"cross-origin-opener-policy",
387+
"same-origin-allow-popups",
388+
);
389+
assert_header_contains(
390+
&fallback_resp,
391+
"cross-origin-resource-policy",
392+
"same-origin",
393+
);
394+
println!("Frontend security headers OK (8/8 verified on fallback response)");
395+
396+
// ─── Gzip compression ───────────────────────────────────────────────
397+
// Request with Accept-Encoding: gzip should return compressed content.
398+
let (gzip_resp,) = query_candid::<_, (HttpResponse,)>(
399+
&pic,
400+
canister_id,
401+
"http_request",
402+
(HttpRequest::get("/this-path-does-not-exist")
403+
.with_headers(vec![("Accept-Encoding".into(), "gzip".into())])
404+
.build(),),
405+
)
406+
.expect("http_request with gzip encoding failed");
407+
assert_eq!(
408+
gzip_resp.status_code(),
409+
200,
410+
"Gzip fallback should return 200"
411+
);
412+
let body = gzip_resp.body();
413+
assert!(
414+
body.len() >= 2 && body[0] == 0x1f && body[1] == 0x8b,
415+
"Response body should be gzip-compressed (expected magic bytes 0x1f 0x8b, got 0x{:02x} 0x{:02x})",
416+
body.first().unwrap_or(&0),
417+
body.get(1).unwrap_or(&0),
418+
);
419+
assert_header_contains(&gzip_resp, "content-encoding", "gzip");
420+
println!("Gzip compression OK (verified on fallback response)");
421+
372422
// ─── manage_alternative_origins ──────────────────────────────────────
373423

374424
// Verify the initial origins JSON is valid and doesn't contain our test origins.

packages-rs/my-canister-frontend/CHANGELOG.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,24 @@
22

33
## [Unreleased]
44

5+
### Added
6+
7+
- 8 default security/privacy HTTP headers on all asset responses: X-Content-Type-Options, X-Frame-Options, Referrer-Policy, X-XSS-Protection, Strict-Transport-Security, Permissions-Policy, Cross-Origin-Opener-Policy, Cross-Origin-Resource-Policy
8+
- Asset validation: file type allowlist, 2 MB size limit, duplicate path detection, path traversal and malformed URL rejection
9+
- Gzip compression for text-based assets (HTML, JS, CSS, JSON, SVG, XML, TXT, source maps)
10+
- `FrontendConfig` struct for customization (extra allowed extensions, custom size limit)
11+
- `setup_frontend_with_config()` for configurable frontend initialization
12+
- `asset_router_configs_with_config()` for configurable asset processing
13+
14+
### Changed
15+
16+
- `asset_router_configs()` now returns `Result` to surface validation errors (breaking)
17+
- Compressible assets are served with both Identity and Gzip encodings
18+
19+
### Dependencies
20+
21+
- Added `flate2` for gzip compression
22+
523
## [0.2.1] - 2025-09-11
624

725
### Changed

packages-rs/my-canister-frontend/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ ic-http-certification.workspace = true
2121
ic-cdk.workspace = true
2222
include_dir.workspace = true
2323
mime_guess.workspace = true
24+
flate2.workspace = true
2425

2526
[package.metadata.release]
2627
pre-release-replacements = [

packages-rs/my-canister-frontend/README.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,9 +37,34 @@ fn http_request(req: HttpRequest) -> HttpResponse {
3737
- Automatic MIME type detection using `mime_guess`
3838
- `index.html` auto-configured as fallback for `/`
3939
- Certification using [`ic-asset-certification`](https://docs.rs/ic-asset-certification)
40+
- **Security headers**: 8 default security/privacy headers on all responses (HSTS, X-Frame-Options, Referrer-Policy, etc.)
41+
- **Asset validation**: File type allowlist, 2 MB size limit, path traversal protection, duplicate detection
42+
- **Gzip compression**: Automatic gzip for text-based assets (HTML, JS, CSS, JSON, SVG)
43+
- **Configurable**: Use `FrontendConfig` to allow additional file types or adjust size limits
4044
- Exposes `with_asset_router` and `with_asset_router_mut` for access to the asset router, see example below.
4145
- Exposes `asset_router_configs` if you want to implement your own asset router, see [this example](https://github.com/Web3NL/my-canister-dapp/blob/e8fdc0ac81f7bdc702418c05130ace3f9f5399fb/examples/my-hello-world/src/backend/src/lib.rs).
4246

47+
## Configuration
48+
49+
To allow additional file types or customize settings:
50+
51+
```rust,ignore
52+
use ic_cdk::init;
53+
use include_dir::{include_dir, Dir};
54+
use my_canister_frontend::{setup_frontend_with_config, FrontendConfig};
55+
56+
static ASSETS: Dir = include_dir!("$CARGO_MANIFEST_DIR/../dapp-frontend/dist");
57+
58+
#[init]
59+
fn init() {
60+
let config = FrontendConfig {
61+
extra_allowed_extensions: vec!["webmanifest".to_string()],
62+
..Default::default()
63+
};
64+
setup_frontend_with_config(&ASSETS, &config).expect("Failed to setup frontend");
65+
}
66+
```
67+
4368
## Usage with [`my_canister_dashboard`](https://crates.io/crates/my-canister-dashboard)
4469

4570
```rust,ignore

0 commit comments

Comments
 (0)