From 598fcd3be77bf205012d738dc104fea7015584f3 Mon Sep 17 00:00:00 2001 From: Uzair Waseem Date: Wed, 15 Jul 2026 19:03:43 +0100 Subject: [PATCH 1/2] fix: harden date handling and HTTP boundaries --- .github/workflows/ci.yml | 7 +++- README.md | 12 ++++-- SECURITY.md | 4 +- api/_response.js | 20 +++++++++- docs/architecture.md | 5 ++- src/domain.js | 25 +++++++++--- src/server.js | 22 ++++++++--- test/domain.test.js | 36 +++++++++++++++++ test/server.test.js | 84 ++++++++++++++++++++++++++++++++++++++++ 9 files changed, 194 insertions(+), 21 deletions(-) create mode 100644 test/server.test.js diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ff4b71a..a11b365 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,6 +3,9 @@ name: CI on: push: branches: ["main"] + +permissions: + contents: read pull_request: branches: ["main"] @@ -11,10 +14,10 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - name: Setup Node - uses: actions/setup-node@v4 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: node-version: 22 diff --git a/README.md b/README.md index 8daf124..a8ed9f9 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ SecureTaskOps exposes a dashboard and Node.js API that model workflow items, sco - `POST /api/tasks` workflow item creation with validation - `GET /api/release-readiness` release summary and status signal - Risk scoring based on severity, blocked work, due date and security-sensitive changes -- Tests for validation, filtering, risk scoring and release readiness +- Domain and HTTP tests for validation, date boundaries, filtering, risk scoring, release readiness, response contracts and browser headers - Dockerfile, Docker Compose, `.env.example`, CI workflow, changelog and security notes ## Architecture @@ -56,6 +56,7 @@ Main modules: - `src/domain.js` contains validation, risk scoring, filtering and release summary logic. - `src/sample-data.js` provides deterministic sample workflow items. - `test/domain.test.js` covers core logic and edge cases. +- `test/server.test.js` exercises health, filtering, item creation, malformed JSON and static response headers over HTTP. ## Local Setup @@ -112,14 +113,17 @@ npm test Covered: - Required-field validation +- Strict date-only validation and reference-clock behavior - Risk scoring boundaries - Blocked and security-sensitive risk reasons - Query-style filtering - Release-readiness summary logic +- HTTP response contracts and error handling +- Restrictive static browser headers Not covered yet: -- Browser UI tests +- Browser interaction tests in this repository; cross-project browser/API checks live in QA Automation Lab - Persistent database integration - Authentication and role-based authorization @@ -140,7 +144,9 @@ The repository includes GitHub Actions for syntax checks and tests on pushes and - Request JSON is validated before workflow items are created. - `.env.example` documents configuration without committing secrets. - The deployed demo does not yet include authentication, authorization, rate limiting, persistent storage, or audit logging. -- Future production hardening should add auth, RBAC, request size limits, schema validation, database migrations, structured logs, and deployment secrets management. +- Request bodies are capped at 64 KB and malformed JSON returns a bounded validation error without stack details. +- Static responses use a restrictive Content Security Policy; API responses are non-cacheable and use `nosniff` and no-referrer headers. +- Future production hardening should add auth, RBAC, database migrations, structured logs, rate limiting and deployment secrets management. ## Tradeoffs diff --git a/SECURITY.md b/SECURITY.md index 6c402a6..0bd2272 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -8,6 +8,9 @@ SecureTaskOps is a public product demo. Security-sensitive claims are kept inten - No secrets committed to the repository - `.env.example` documents expected configuration - Security-sensitive workflow items are surfaced in the risk model +- Request bodies are limited to 64 KB and malformed JSON receives a bounded validation error +- Static browser responses use a restrictive Content Security Policy +- API responses are non-cacheable and include `nosniff` and no-referrer headers - Known limitations are documented in the README ## Not Implemented Yet @@ -16,7 +19,6 @@ SecureTaskOps is a public product demo. Security-sensitive claims are kept inten - Authorization and role-based access - Persistent audit logs - Rate limiting -- Request size limits - Production secrets management ## Reporting Issues diff --git a/api/_response.js b/api/_response.js index 63aff39..047ef7c 100644 --- a/api/_response.js +++ b/api/_response.js @@ -4,6 +4,8 @@ export function sendJson(response, statusCode, body) { response.statusCode = statusCode; response.setHeader("cache-control", "no-store"); response.setHeader("content-type", "application/json; charset=utf-8"); + response.setHeader("x-content-type-options", "nosniff"); + response.setHeader("referrer-policy", "no-referrer"); response.end(JSON.stringify(body, null, 2)); } @@ -24,7 +26,21 @@ export function methodNotAllowed(response, methods) { } export async function readBody(request) { - if (request.body && typeof request.body === "object") return request.body; - if (typeof request.body === "string" && request.body.trim()) return JSON.parse(request.body); + if (request.body && typeof request.body === "object") { + if (Buffer.byteLength(JSON.stringify(request.body), "utf8") > 65_536) { + throw new ValidationError("Request body must not exceed 64 KB."); + } + return request.body; + } + if (typeof request.body === "string" && request.body.trim()) { + if (Buffer.byteLength(request.body, "utf8") > 65_536) { + throw new ValidationError("Request body must not exceed 64 KB."); + } + try { + return JSON.parse(request.body); + } catch { + throw new ValidationError("Request body must be valid JSON."); + } + } return {}; } diff --git a/docs/architecture.md b/docs/architecture.md index 23616e1..338d50c 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -4,7 +4,7 @@ SecureTaskOps is intentionally small so reviewers can inspect the complete syste ## Request Flow -1. A client calls `src/server.js`. +1. A client calls `src/server.js` locally or a handler in `api/` on Vercel. 2. The server maps the HTTP method and path to a route handler. 3. Route handlers call domain functions from `src/domain.js`. 4. Domain logic validates input, calculates risk, filters workflow items, or summarizes release readiness. @@ -15,7 +15,8 @@ SecureTaskOps is intentionally small so reviewers can inspect the complete syste - HTTP concerns stay in `src/server.js`. - Business rules stay in `src/domain.js`. - Sample data stays in `src/sample-data.js`. -- Tests exercise the domain layer directly because that is where the important logic lives. +- Domain tests exercise the business rules directly. +- HTTP tests exercise route behavior, error envelopes, cache policy and static security headers. ## Future Database Boundary diff --git a/src/domain.js b/src/domain.js index b1a15c3..6691a3b 100644 --- a/src/domain.js +++ b/src/domain.js @@ -9,7 +9,7 @@ const allowedStatuses = new Set(["todo", "in_progress", "blocked", "review", "do const allowedSeverities = new Set(Object.keys(severityWeights)); export function createWorkflowItem(input, now = new Date()) { - const item = normalizeInput(input); + const item = normalizeInput(input, now); const risk = calculateRisk(item, now); return { @@ -69,8 +69,11 @@ export function calculateRisk(item, now = new Date()) { reasons.push("security-sensitive change requires extra review"); } - const dueSoon = daysUntil(item.dueDate, now) <= 3; - if (dueSoon && item.status !== "done") { + const dueInDays = daysUntil(item.dueDate, now); + if (dueInDays < 0 && item.status !== "done") { + score += 10; + reasons.push("open item is overdue"); + } else if (dueInDays <= 3 && item.status !== "done") { score += 10; reasons.push("open item is due within three days"); } @@ -83,7 +86,7 @@ export function calculateRisk(item, now = new Date()) { }; } -function normalizeInput(input) { +function normalizeInput(input, now) { if (!input || typeof input !== "object") { throw new ValidationError("Request body must be a JSON object."); } @@ -92,13 +95,13 @@ function normalizeInput(input) { const owner = cleanText(input.owner); const status = input.status || "todo"; const severity = input.severity || "medium"; - const dueDate = input.dueDate || new Date().toISOString().slice(0, 10); + const dueDate = input.dueDate || toDateOnly(now); if (!title) throw new ValidationError("title is required."); if (!owner) throw new ValidationError("owner is required."); if (!allowedStatuses.has(status)) throw new ValidationError(`status must be one of: ${[...allowedStatuses].join(", ")}.`); if (!allowedSeverities.has(severity)) throw new ValidationError(`severity must be one of: ${[...allowedSeverities].join(", ")}.`); - if (Number.isNaN(Date.parse(dueDate))) throw new ValidationError("dueDate must be a valid date."); + if (!isDateOnly(dueDate)) throw new ValidationError("dueDate must be a valid YYYY-MM-DD date."); return { id: typeof input.id === "string" ? input.id : undefined, @@ -131,6 +134,16 @@ function daysUntil(dateString, now) { return Math.ceil((target.getTime() - today.getTime()) / 86_400_000); } +function isDateOnly(value) { + if (typeof value !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(value)) return false; + const parsed = new Date(`${value}T00:00:00.000Z`); + return !Number.isNaN(parsed.getTime()) && toDateOnly(parsed) === value; +} + +function toDateOnly(date) { + return date.toISOString().slice(0, 10); +} + function cleanText(value) { return typeof value === "string" ? value.trim().slice(0, 160) : ""; } diff --git a/src/server.js b/src/server.js index e9eab44..5450249 100644 --- a/src/server.js +++ b/src/server.js @@ -1,6 +1,6 @@ import { createServer } from "node:http"; import { readFile } from "node:fs/promises"; -import { dirname, join } from "node:path"; +import { dirname, join, resolve } from "node:path"; import { URL } from "node:url"; import { fileURLToPath } from "node:url"; import { createWorkflowItem, filterItems, summarizeRelease, ValidationError } from "./domain.js"; @@ -49,7 +49,9 @@ export default async function handler(request, response) { export const server = createServer(handler); -if (process.env.NODE_ENV !== "test") { +const isDirectExecution = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url); + +if (isDirectExecution) { server.listen(port, () => { console.log(`SecureTaskOps API running on http://localhost:${port}`); }); @@ -70,7 +72,9 @@ function notFound() { function send(response, statusCode, body) { response.writeHead(statusCode, { "content-type": "application/json; charset=utf-8", - "cache-control": "no-store" + "cache-control": "no-store", + "x-content-type-options": "nosniff", + "referrer-policy": "no-referrer" }); response.end(JSON.stringify(body, null, 2)); } @@ -81,7 +85,10 @@ async function serveStatic(response, pathname) { const contents = await readFile(filePath); response.writeHead(200, { "content-type": contentType(fileName), - "cache-control": fileName.endsWith(".html") ? "no-store" : "public, max-age=3600" + "cache-control": fileName.endsWith(".html") ? "no-store" : "public, max-age=3600", + "content-security-policy": "default-src 'self'; script-src 'self'; style-src 'self'; connect-src 'self'; img-src 'self' data:; base-uri 'none'; frame-ancestors 'none'; form-action 'self'", + "x-content-type-options": "nosniff", + "referrer-policy": "no-referrer" }); response.end(contents); } @@ -98,7 +105,12 @@ function contentType(fileName) { async function readJson(request) { const chunks = []; - for await (const chunk of request) chunks.push(chunk); + let size = 0; + for await (const chunk of request) { + size += chunk.length; + if (size > 65_536) throw new ValidationError("Request body must not exceed 64 KB."); + chunks.push(chunk); + } const raw = Buffer.concat(chunks).toString("utf8"); if (!raw.trim()) return {}; try { diff --git a/test/domain.test.js b/test/domain.test.js index 5dc6310..6adbbc0 100644 --- a/test/domain.test.js +++ b/test/domain.test.js @@ -11,6 +11,22 @@ test("createWorkflowItem validates required fields", () => { ); }); +test("createWorkflowItem uses the supplied clock for its default due date", () => { + const item = createWorkflowItem({ title: "Clocked item", owner: "Uzair", severity: "low" }, now); + + assert.equal(item.dueDate, "2026-07-05"); + assert.match(item.riskReasons.join(" "), /due within three days/); +}); + +test("createWorkflowItem rejects impossible or non-date-only due dates", () => { + for (const dueDate of ["2026-02-30", "2026-07-05T10:00:00Z", "not-a-date"]) { + assert.throws( + () => createWorkflowItem({ title: "Invalid date", owner: "Uzair", dueDate }, now), + /valid YYYY-MM-DD date/ + ); + } +}); + test("calculateRisk raises risk for blocked security-sensitive work due soon", () => { const item = createWorkflowItem( { @@ -69,3 +85,23 @@ test("calculateRisk remains bounded", () => { assert.equal(risk.score, 100); }); + +test("calculateRisk distinguishes overdue work from the due-soon boundary", () => { + const overdue = calculateRisk( + { status: "todo", severity: "low", dueDate: "2026-07-04", securitySensitive: false }, + now + ); + const dueInFourDays = calculateRisk( + { status: "todo", severity: "low", dueDate: "2026-07-09", securitySensitive: false }, + now + ); + const completed = calculateRisk( + { status: "done", severity: "low", dueDate: "2026-07-04", securitySensitive: false }, + now + ); + + assert.match(overdue.reasons.join(" "), /overdue/); + assert.equal(overdue.score, 20); + assert.equal(dueInFourDays.score, 10); + assert.equal(completed.score, 10); +}); diff --git a/test/server.test.js b/test/server.test.js new file mode 100644 index 0000000..b328779 --- /dev/null +++ b/test/server.test.js @@ -0,0 +1,84 @@ +import test, { after, before } from "node:test"; +import assert from "node:assert/strict"; +import { server } from "../src/server.js"; + +let baseUrl; + +before(async () => { + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + baseUrl = `http://127.0.0.1:${address.port}`; +}); + +after(async () => { + await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))); +}); + +test("health route returns a non-cacheable service contract", async () => { + const response = await fetch(`${baseUrl}/api/health`); + + assert.equal(response.status, 200); + assert.equal(response.headers.get("cache-control"), "no-store"); + assert.equal(response.headers.get("x-content-type-options"), "nosniff"); + assert.deepEqual(await response.json(), { + status: "ok", + service: "securetaskops-workflow-platform" + }); +}); + +test("task route filters deterministic sample data", async () => { + const response = await fetch(`${baseUrl}/api/tasks?status=blocked`); + const body = await response.json(); + + assert.equal(response.status, 200); + assert.equal(body.count, 1); + assert.equal(body.data[0].status, "blocked"); +}); + +test("task creation rejects malformed JSON without exposing a stack trace", async () => { + const response = await fetch(`${baseUrl}/api/tasks`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: "{broken" + }); + const body = await response.json(); + + assert.equal(response.status, 400); + assert.equal(body.error, "ValidationError"); + assert.match(body.message, /valid JSON/); + assert.equal(JSON.stringify(body).includes("stack"), false); +}); + +test("task creation validates and returns a reviewable workflow item", async () => { + const response = await fetch(`${baseUrl}/api/tasks`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + title: "Review session boundary", + owner: "QA fixture", + status: "review", + severity: "medium", + dueDate: "2030-01-01", + securitySensitive: true + }) + }); + const body = await response.json(); + + assert.equal(response.status, 201); + assert.equal(body.data.title, "Review session boundary"); + assert.equal(body.data.owner, "QA fixture"); + assert.equal(body.data.riskScore, 44); + assert.deepEqual(body.data.riskReasons, [ + "medium severity baseline", + "security-sensitive change requires extra review" + ]); +}); + +test("static dashboard ships restrictive browser headers", async () => { + const response = await fetch(`${baseUrl}/`); + + assert.equal(response.status, 200); + assert.match(response.headers.get("content-security-policy"), /frame-ancestors 'none'/); + assert.equal(response.headers.get("referrer-policy"), "no-referrer"); + assert.match(await response.text(), /SecureTaskOps/); +}); From 012a48fd271eefb5d3509663b61305429e7f113f Mon Sep 17 00:00:00 2001 From: Uzair Waseem Date: Wed, 15 Jul 2026 19:05:32 +0100 Subject: [PATCH 2/2] build: add reproducible npm lockfile --- package-lock.json | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 package-lock.json diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..93f8c5d --- /dev/null +++ b/package-lock.json @@ -0,0 +1,16 @@ +{ + "name": "securetaskops-workflow-platform", + "version": "0.2.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "securetaskops-workflow-platform", + "version": "0.2.0", + "license": "MIT", + "engines": { + "node": ">=20" + } + } + } +}