From e933c78e1371d81b6da182f67378b87141bc7924 Mon Sep 17 00:00:00 2001 From: jacaudi <47005674+jacaudi@users.noreply.github.com> Date: Fri, 13 Mar 2026 00:46:45 -0700 Subject: [PATCH 1/6] fix: set ECDH importKey extractable to false (#36) Both importKey calls in ecdh.js (private key PKCS#8 and public key raw) used extractable: true with no corresponding exportKey call anywhere in the codebase. Setting extractable to false prevents key material from being exported via the Web Crypto API, reducing attack surface. Co-Authored-By: Claude Sonnet 4.6 --- app/tests/test-security-hardening.js | 37 ++++++++++++++++++++++++++++ src/js/worker/ecdh.js | 4 +-- 2 files changed, 39 insertions(+), 2 deletions(-) create mode 100644 app/tests/test-security-hardening.js diff --git a/app/tests/test-security-hardening.js b/app/tests/test-security-hardening.js new file mode 100644 index 0000000..46dd336 --- /dev/null +++ b/app/tests/test-security-hardening.js @@ -0,0 +1,37 @@ +'use strict'; +const { describe, it } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const path = require('path'); + +const html = fs.readFileSync(path.join(__dirname, '../index.html'), 'utf8'); + +describe('ECDH key non-extractable (#36)', function() { + it('private key importKey does not use extractable: true', function() { + assert.ok( + !html.includes(', true, ["deriveKey"'), + 'ECDH private key must not use extractable: true' + ); + }); + + it('private key importKey uses extractable: false', function() { + assert.ok( + html.includes(', false, ["deriveKey"'), + 'ECDH private key must use extractable: false' + ); + }); + + it('ecdh.js public key importKey does not use extractable: true', function() { + assert.ok( + !html.includes('rawPublicKey.buffer, { name: "ECDH", namedCurve: "P-521" } , true, []'), + 'ECDH public key (ecdh.js convertPublicKeyToRaw) must not use extractable: true' + ); + }); + + it('ecdh.js public key importKey uses extractable: false', function() { + assert.ok( + html.includes('rawPublicKey.buffer, { name: "ECDH", namedCurve: "P-521" } , false, []'), + 'ECDH public key (ecdh.js convertPublicKeyToRaw) must use extractable: false' + ); + }); +}); diff --git a/src/js/worker/ecdh.js b/src/js/worker/ecdh.js index 29a50c0..65e18e4 100644 --- a/src/js/worker/ecdh.js +++ b/src/js/worker/ecdh.js @@ -165,7 +165,7 @@ pubKeyBytes.set(yBytes, 67); const pkcs8=buildPKCS8WithPublicKey(privKeyBytes, pubKeyBytes); crypto.subtle.importKey( "pkcs8", pkcs8.buffer, { name: "ECDH", namedCurve: "P-521" } -, true, ["deriveKey", "deriveBits"] ).then(function(key) { +, false, ["deriveKey", "deriveBits"] ).then(function(key) { inner_cb({ success: true, key, pkcs8Buffer: pkcs8.buffer } ); @@ -202,7 +202,7 @@ rawPublicKey.set(yBytes, 67); try { crypto.subtle.importKey( "raw", rawPublicKey.buffer, { name: "ECDH", namedCurve: "P-521" } -, true, [] ).then(function(key){ +, false, [] ).then(function(key){ inner_cb({ success: true, key, rawBuffer: rawPublicKey.buffer } ); From 594e4082a4330a46684ab2b1993c0cffd98208b7 Mon Sep 17 00:00:00 2001 From: jacaudi <47005674+jacaudi@users.noreply.github.com> Date: Fri, 13 Mar 2026 19:36:42 -0700 Subject: [PATCH 2/6] fix: set importEcdhPub extractable to false, strengthen test coverage (#36) The importEcdhPub function in encryption.js was importing external peer public keys with extractable: true, making it the most attacker-exposed ECDH importKey in the codebase. Changed to extractable: false. Broadened test 3/4 in test-security-hardening.js from a variable-specific pattern to a generic P-521 namedCurve pattern that catches ALL ECDH importKey calls regardless of which source file they originate from. Co-Authored-By: Claude Opus 4.6 --- app/tests/test-security-hardening.js | 15 +++++++-------- src/js/worker/encryption.js | 2 +- 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/app/tests/test-security-hardening.js b/app/tests/test-security-hardening.js index 46dd336..76bdb2c 100644 --- a/app/tests/test-security-hardening.js +++ b/app/tests/test-security-hardening.js @@ -21,17 +21,16 @@ describe('ECDH key non-extractable (#36)', function() { ); }); - it('ecdh.js public key importKey does not use extractable: true', function() { + it('no ECDH importKey call uses extractable: true', function() { assert.ok( - !html.includes('rawPublicKey.buffer, { name: "ECDH", namedCurve: "P-521" } , true, []'), - 'ECDH public key (ecdh.js convertPublicKeyToRaw) must not use extractable: true' + !html.includes('namedCurve: "P-521" } , true,'), + 'No ECDH importKey call may use extractable: true' ); }); - it('ecdh.js public key importKey uses extractable: false', function() { - assert.ok( - html.includes('rawPublicKey.buffer, { name: "ECDH", namedCurve: "P-521" } , false, []'), - 'ECDH public key (ecdh.js convertPublicKeyToRaw) must use extractable: false' - ); + it('all ECDH importKey calls use extractable: false', function() { + // Verify no remaining true patterns + const trueMatches = (html.match(/namedCurve: "P-521" } , true,/g) || []).length; + assert.strictEqual(trueMatches, 0, 'No ECDH importKey should use extractable: true'); }); }); diff --git a/src/js/worker/encryption.js b/src/js/worker/encryption.js index b12330e..6b261fa 100644 --- a/src/js/worker/encryption.js +++ b/src/js/worker/encryption.js @@ -56,7 +56,7 @@ this.importEcdhPub=importEcdhPub; function importEcdhPub(pub_buff, cb){ crypto.subtle.importKey( "raw", pub_buff, { name: "ECDH", namedCurve: "P-521" } -, true, [] ).then(cb).catch(function(e){ +, false, [] ).then(cb).catch(function(e){ fk_log('error', 'crypto', 'importEcdhPub failed: ' + e.toString()); cb(null); }); From 52df0b9326dd3a0d4cfce107ddb996ba8e311d09 Mon Sep 17 00:00:00 2001 From: jacaudi <47005674+jacaudi@users.noreply.github.com> Date: Fri, 13 Mar 2026 19:44:56 -0700 Subject: [PATCH 3/6] fix: sanitize filenames in download_ab (#37) Co-Authored-By: Claude Opus 4.6 --- app/tests/test-security-hardening.js | 29 ++++++++++++++++++++++++++++ src/js/ui/file-import.js | 5 ++++- 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/app/tests/test-security-hardening.js b/app/tests/test-security-hardening.js index 76bdb2c..063087c 100644 --- a/app/tests/test-security-hardening.js +++ b/app/tests/test-security-hardening.js @@ -34,3 +34,32 @@ describe('ECDH key non-extractable (#36)', function() { assert.strictEqual(trueMatches, 0, 'No ECDH importKey should use extractable: true'); }); }); + +describe('Filename sanitization (#37)', function() { + it('sanitizeFilename function is present in built output', function() { + assert.ok( + html.includes('function sanitizeFilename('), + 'sanitizeFilename must be defined in built output' + ); + }); + + it('sanitizeFilename truncates to 255 characters', function() { + const idx = html.indexOf('function sanitizeFilename('); + assert.ok(idx !== -1, 'sanitizeFilename must exist'); + const body = html.slice(idx, idx + 300); + assert.ok( + body.includes('.slice(0,255)') || body.includes('.slice(0, 255)'), + 'sanitizeFilename must truncate to 255 chars' + ); + }); + + it('download_ab calls sanitizeFilename', function() { + const idx = html.indexOf('function download_ab('); + assert.ok(idx !== -1, 'download_ab must exist'); + const body = html.slice(idx, idx + 500); + assert.ok( + body.includes('sanitizeFilename('), + 'download_ab must call sanitizeFilename' + ); + }); +}); diff --git a/src/js/ui/file-import.js b/src/js/ui/file-import.js index 94561d6..86ff4d8 100644 --- a/src/js/ui/file-import.js +++ b/src/js/ui/file-import.js @@ -201,13 +201,16 @@ function handleNewFiles() { } } } +function sanitizeFilename(file_name) { + return file_name.replace(/[/\\]/g, '').replace(/\x00/g, '').slice(0, 255); +} function download_ab(file_name, array_buff) { const blob = new Blob([array_buff],{ type: "application/octet-stream" }); const link = document.createElement("a"); link.href = URL.createObjectURL(blob); - link.download = file_name; + link.download = sanitizeFilename(file_name); link.click(); URL.revokeObjectURL(link.href); } From cc947d046fa98d1afd8ed43db1b755146cca08e8 Mon Sep 17 00:00:00 2001 From: jacaudi <47005674+jacaudi@users.noreply.github.com> Date: Fri, 13 Mar 2026 23:41:37 -0700 Subject: [PATCH 4/6] test: assert sanitizeFilename strips slashes and null bytes (#37) Co-Authored-By: Claude Sonnet 4.6 --- app/tests/test-security-hardening.js | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/app/tests/test-security-hardening.js b/app/tests/test-security-hardening.js index 063087c..1151b01 100644 --- a/app/tests/test-security-hardening.js +++ b/app/tests/test-security-hardening.js @@ -62,4 +62,18 @@ describe('Filename sanitization (#37)', function() { 'download_ab must call sanitizeFilename' ); }); + + it('sanitizeFilename strips path separators and null bytes', function() { + const idx = html.indexOf('function sanitizeFilename('); + assert.ok(idx !== -1, 'sanitizeFilename must exist'); + const body = html.slice(idx, idx + 300); + assert.ok( + body.includes("replace(/[/\\\\]/g,'')") || body.includes("replace(/[/\\\\]/g, '')"), + 'must strip slashes' + ); + assert.ok( + body.includes("replace(/\\x00/g,'')") || body.includes("replace(/\\x00/g, '')"), + 'must strip null bytes' + ); + }); }); From ed099168bfca32afb083f907602eea9905a7e91e Mon Sep 17 00:00:00 2001 From: jacaudi <47005674+jacaudi@users.noreply.github.com> Date: Fri, 10 Apr 2026 23:36:47 -0700 Subject: [PATCH 5/6] chore: add .dockerignore to reduce build context (#43) Closes #43 Co-Authored-By: Claude Opus 4.6 --- .dockerignore | 4 ++++ app/tests/test-infra.js | 48 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+) create mode 100644 .dockerignore create mode 100644 app/tests/test-infra.js diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..15c1260 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,4 @@ +.git +.worktrees +docs +app/tests diff --git a/app/tests/test-infra.js b/app/tests/test-infra.js new file mode 100644 index 0000000..f465ebc --- /dev/null +++ b/app/tests/test-infra.js @@ -0,0 +1,48 @@ +'use strict'; +const { describe, it } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const path = require('path'); + +const DOCKERIGNORE = path.resolve(__dirname, '../../.dockerignore'); +const DOCKERFILE = path.resolve(__dirname, '../../Dockerfile'); +const hasDockerignore = fs.existsSync(DOCKERIGNORE); +const hasDockerfile = fs.existsSync(DOCKERFILE); + +describe('.dockerignore build context (#43)', function() { + it('file exists', { skip: !hasDockerignore && 'requires full repo access' }, function() { + assert.ok(fs.existsSync(DOCKERIGNORE)); + }); + + it('excludes .git', { skip: !hasDockerignore && 'requires full repo access' }, function() { + const c = fs.readFileSync(DOCKERIGNORE, 'utf8'); + assert.ok(c.includes('.git'), 'must exclude .git'); + }); + + it('excludes .worktrees', { skip: !hasDockerignore && 'requires full repo access' }, function() { + const c = fs.readFileSync(DOCKERIGNORE, 'utf8'); + assert.ok(c.includes('.worktrees'), 'must exclude .worktrees'); + }); + + it('excludes app/tests', { skip: !hasDockerignore && 'requires full repo access' }, function() { + const c = fs.readFileSync(DOCKERIGNORE, 'utf8'); + assert.ok(c.includes('app/tests'), 'must exclude app/tests'); + }); + + it('excludes docs', { skip: !hasDockerignore && 'requires full repo access' }, function() { + const c = fs.readFileSync(DOCKERIGNORE, 'utf8'); + assert.ok(c.includes('docs'), 'must exclude docs'); + }); +}); + +describe('Pinned alpine base image (#44)', function() { + it('fonts stage uses FROM alpine:3', { skip: !hasDockerfile && 'requires full repo access' }, function() { + const c = fs.readFileSync(DOCKERFILE, 'utf8'); + assert.ok(c.includes('FROM alpine:3'), 'alpine must be pinned to :3'); + }); + + it('does not use bare FROM alpine', { skip: !hasDockerfile && 'requires full repo access' }, function() { + const c = fs.readFileSync(DOCKERFILE, 'utf8'); + assert.ok(!c.match(/FROM alpine\n/) && !c.match(/FROM alpine /), 'must not use bare FROM alpine'); + }); +}); From 24f28717e89967c21dda90cfe448c8ed81efb0c9 Mon Sep 17 00:00:00 2001 From: jacaudi <47005674+jacaudi@users.noreply.github.com> Date: Fri, 10 Apr 2026 23:36:50 -0700 Subject: [PATCH 6/6] chore: pin alpine base image to :3 in fonts stage (#44) Closes #44 Co-Authored-By: Claude Opus 4.6 --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index c596d11..765d8e6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,7 @@ # ============================================================= # Stage 1 — Download Inter Variable font # ============================================================= -FROM alpine AS fonts +FROM alpine:3 AS fonts RUN apk add --no-cache curl unzip && \ curl -fsSL "https://github.com/rsms/inter/releases/download/v4.0/Inter-4.0.zip" \