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/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" \ 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'); + }); +}); diff --git a/app/tests/test-security-hardening.js b/app/tests/test-security-hardening.js new file mode 100644 index 0000000..1151b01 --- /dev/null +++ b/app/tests/test-security-hardening.js @@ -0,0 +1,79 @@ +'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('no ECDH importKey call uses extractable: true', function() { + assert.ok( + !html.includes('namedCurve: "P-521" } , true,'), + 'No ECDH importKey call may use extractable: true' + ); + }); + + 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'); + }); +}); + +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' + ); + }); + + 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' + ); + }); +}); 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); } 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 } ); 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); });