Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
.git
.worktrees
docs
app/tests
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
@@ -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" \
Expand Down
48 changes: 48 additions & 0 deletions app/tests/test-infra.js
Original file line number Diff line number Diff line change
@@ -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');
});
});
79 changes: 79 additions & 0 deletions app/tests/test-security-hardening.js
Original file line number Diff line number Diff line change
@@ -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'
);
});
});
5 changes: 4 additions & 1 deletion src/js/ui/file-import.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
4 changes: 2 additions & 2 deletions src/js/worker/ecdh.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
);
Expand Down Expand Up @@ -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 }
);
Expand Down
2 changes: 1 addition & 1 deletion src/js/worker/encryption.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Expand Down
Loading