-
Notifications
You must be signed in to change notification settings - Fork 1
Security Audit
This page documents all security findings for Cornerstone, maintained by the security-engineer agent. Findings are organized by implementation phase.
OWASP Category: A07 — Identification & Authentication Failures Severity: Medium Status: Open Date Found: 2026-02-16
Description:
The /api/auth/login, /api/auth/setup, and password-change endpoints have no rate limiting. An attacker could attempt credential stuffing or brute-force attacks at unlimited speed.
Affected Files:
-
server/src/routes/auth.ts— no rate limit preHandler on login and setup routes
Proof of Concept:
Send unlimited POST requests to /api/auth/login with varying passwords; no throttling occurs.
Remediation:
Install @fastify/rate-limit and apply a preHandler on auth endpoints:
await app.register(import('@fastify/rate-limit'), {
max: 10,
timeWindow: '1 minute',
keyGenerator: (req) => req.ip,
});Apply with { config: { rateLimit: { max: 5, timeWindow: '1 minute' } } } on login and setup routes.
Risk if Unaddressed: Credential brute-force is technically possible, though mitigated by bcrypt/Argon2 cost and the self-hosted, low-exposure deployment model.
OWASP Category: A05 — Security Misconfiguration Severity: Low Status: Open Date Found: 2026-02-16
Description:
No Content-Security-Policy, Strict-Transport-Security, X-Frame-Options, or X-Content-Type-Options headers are set. These are defense-in-depth headers.
Affected Files:
-
server/src/app.ts— no helmet-equivalent plugin registered
Remediation:
Install @fastify/helmet:
await app.register(import('@fastify/helmet'), {
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", 'data:'],
},
},
});Risk if Unaddressed: Clickjacking and MIME-sniffing risks remain. Low impact given self-hosted deployment.
OWASP Category: A07 — Identification & Authentication Failures Severity: Low Status: Open Date Found: 2026-02-16
Description: No account lockout after repeated failed login attempts. Combined with the rate limiting gap, this allows unlimited password attempts per account.
Affected Files:
-
server/src/services/userService.ts— login function has no failed-attempt counter
Remediation: Track failed login attempts in a server-side store (in-memory Map or DB table). After N failures (e.g., 10), require a cooldown period or admin unlock.
Risk if Unaddressed: Brute-force remains theoretically possible. Argon2id hashing significantly limits attack throughput.
OWASP Category: A04 — Insecure Design Severity: Low Status: Open Date Found: 2026-02-20 PR: #150
Description:
The budget_categories.name TEXT UNIQUE NOT NULL DB constraint is case-sensitive (SQLite default), while the application service enforces case-insensitive uniqueness via a LOWER() comparison. A direct database insert (bypassing the application layer) could create duplicate category names differing only by case.
Affected Files:
-
server/src/db/migrations/0003_budget_categories.sql—name TEXT UNIQUE NOT NULL -
server/src/services/budgetCategoryService.ts—LOWER()uniqueness check
Remediation: Change the DB constraint to use a case-insensitive collation:
name TEXT UNIQUE NOT NULL COLLATE NOCASERisk if Unaddressed: Very low. Only reachable via direct DB access, which requires physical server access.
OWASP Category: A05 — Security Misconfiguration Severity: Low Status: Open Date Found: 2026-02-20 PRs: #150, #151, #152, #187
Description:
The 409 conflict error responses from CATEGORY_IN_USE, VENDOR_IN_USE, BUDGET_SOURCE_IN_USE, and BUDGET_LINE_IN_USE errors include a details field exposing internal relationship counts (e.g., { invoiceCount: 3, budgetLineCount: 1 }). These counts are visible to authenticated clients in the raw API response, though not surfaced in the UI.
Affected Files:
-
server/src/errors/AppError.ts— allInUseerror constructors
Proof of Concept: Attempt to delete a category/vendor/source in use; the response body includes counts.
Remediation:
Remove the details field from InUse error constructors, or strip it before serialization. The details field is intended for debugging but should not expose internal data counts to API consumers.
Risk if Unaddressed: Information disclosure to authenticated users. Low risk given authenticated-only access and self-hosted model.
OWASP Category: A03 — Injection (stored XSS vector, low severity) Severity: Low Status: Open Date Found: 2026-02-20 PR: #151
Description:
The email field on vendor create/update has no format validation — the AJV schema does not set format: 'email' and the service layer has no regex. Any string is accepted and stored. XSS is mitigated by React's auto-escaping, but malformed data could be stored.
Affected Files:
-
server/src/routes/vendors.ts—email: { type: 'string' }lacks format constraint
Remediation:
Add format: 'email' to the AJV schema properties for email:
email: { type: ['string', 'null'], format: 'email', maxLength: 254 },Note: Fastify must have ajv.plugins configured with ajv-formats for format to be enforced.
Risk if Unaddressed: Malformed email data stored; no direct security exploit path.
OWASP Category: A03 — Injection (DoS vector) Severity: Low Status: Open Date Found: 2026-02-20 PRs: #151, #152
Description:
budget_sources.terms and budget_sources.notes text fields lack maxLength constraints in the AJV request schema. Similarly, vendors.notes and invoices.notes have AJV-level constraints but no DB-level TEXT length enforcement (SQLite TEXT is unlimited). These omissions allow very large strings to be stored.
Affected Files:
-
server/src/routes/budgetSources.ts—termsandnoteslack maxLength
Remediation:
Add maxLength to AJV schemas for all free-text fields: terms: maxLength 500, notes: maxLength 2000.
Risk if Unaddressed: Storage DoS if an authenticated user submits very large text. Low risk in self-hosted deployment.
OWASP Category: A01 — Broken Access Control (data integrity variant) Severity: Low Status: Open Date Found: 2026-02-21 PR: #187
Description:
When creating or updating an invoice with a workItemBudgetId, the service validates that the referenced budget line exists but does not verify any relationship between the budget line's work item and the invoice's vendor. An authenticated user can link an invoice from Vendor A to a budget line associated with a completely unrelated work item (with no connection to Vendor A). This produces confusing budget aggregation data.
Affected Files:
-
server/src/services/invoiceService.ts:131-139—createInvoiceFK validation -
server/src/services/invoiceService.ts:236-246—updateInvoiceFK validation
Proof of Concept:
- Create a work item with a budget line (budget line ID =
bl-1) - Create an unrelated vendor (vendor ID =
v-99) - POST
/api/vendors/v-99/invoiceswith{ workItemBudgetId: "bl-1", ... }— succeeds
Remediation: After verifying the budget line exists, optionally verify the budget line belongs to a work item that references the same vendor:
// Check vendor-budget line consistency
if (budgetLine.vendorId && budgetLine.vendorId !== vendorId) {
throw new ValidationError('Budget line belongs to a different vendor');
}Alternatively, accept as a design decision — the budget line's vendorId is optional (a budget line can exist without a vendor) so the cross-link may be intentionally flexible.
Risk if Unaddressed: Data integrity only. No confidentiality breach — all authenticated users can view all work items and invoices. Budget overview aggregation may show unexpected numbers.
OWASP Category: A09 — Security Logging & Monitoring Failures (secondary UX degradation) Severity: Low Status: Open Date Found: 2026-02-22
Description:
In the invoice create and edit modals, the fetchWorkItemBudgets call is fired as a void .then(...) chain with no .catch() handler. If the API call fails (network error, 401 session expiry, 500), budgetLinesLoading remains true indefinitely — permanently disabling the Budget Line dropdown with no user-visible error or recovery path. The user must close and reopen the modal to reset the state. Not a direct security vulnerability, but a silent failure that prevents UI error reporting.
Affected Files:
-
client/src/pages/VendorDetailPage/VendorDetailPage.tsx:1037-1040— create modal budget line fetch, no.catch() -
client/src/pages/VendorDetailPage/VendorDetailPage.tsx:1256-1259— edit modal budget line fetch, no.catch()
Proof of Concept: With an active session, open the Add Invoice or Edit Invoice modal, disconnect the network, then select a work item from the dropdown. The Budget Line field appears but stays in a perpetually disabled state with no error shown.
Remediation:
Add a .catch() handler that resets budgetLinesLoading(false) and sets an inline error state:
void fetchWorkItemBudgets(workItemId)
.then((lines) => {
setBudgetLines(lines);
setBudgetLinesLoading(false);
})
.catch(() => {
setBudgetLinesLoading(false);
// optionally: setBudgetLineError('Failed to load budget lines.');
});Risk if Unaddressed: Degraded UX — users cannot link invoices to budget lines if the network call fails. No confidentiality, integrity, or authorization risk.
OWASP Category: A05 — Security Misconfiguration (client-server contract mismatch) Severity: Low Status: Open Date Found: 2026-02-22
Description:
The VendorDetailPage fetches work items for the invoice modal with listWorkItems({ pageSize: 200 }). The server-side AJV schema for GET /api/work-items enforces maximum: 100 on pageSize (server/src/routes/workItems.ts:44), so Fastify returns a 400 validation error. The void .then(...) call has no .catch() branch, so this rejection is silently swallowed — workItems stays as [] and the "Link to Work Item" dropdown appears empty. The invoice budget-line linking feature is functionally broken: users cannot link any invoice to a work item.
Affected Files:
-
client/src/pages/VendorDetailPage/VendorDetailPage.tsx:127—listWorkItems({ pageSize: 200 }) -
server/src/routes/workItems.ts:44—pageSize: { type: 'integer', minimum: 1, maximum: 100 }
Proof of Concept: Open the Vendor Detail page, click "Add Invoice". The "Link to Work Item" dropdown shows only the "None" option, even with work items present in the project.
Remediation:
Change pageSize: 200 to pageSize: 100 (the server maximum). If more than 100 work items need to be selectable, the server limit should be raised with appropriate pagination support.
void listWorkItems({ pageSize: 100 }).then((res) => setWorkItems(res.items));Risk if Unaddressed: Invoice-to-budget-line linking is entirely non-functional. No security risk, but the feature is broken for all users.
Resolution:
PR #203 fixed the pageSize value to 100 and the new InvoicesPage uses pageSize: 100. Finding closed.
OWASP Category: A05 — Security Misconfiguration (best-practice gap) Severity: Informational Status: Open Date Found: 2026-02-23 PR: #203
Description:
The getInvoiceByIdSchema defined in server/src/routes/standaloneInvoices.ts does not include additionalProperties: false on the params schema. This is inconsistent with the listAllInvoicesSchema in the same file, which correctly sets additionalProperties: false on the querystring schema. While Fastify does not allow unknown path parameters to reach handler code (they are ignored), omitting this constraint is a defense-in-depth gap and an inconsistency in the codebase's validation style.
Affected Files:
-
server/src/routes/standaloneInvoices.ts:3317-3325—getInvoiceByIdSchemaparams lacksadditionalProperties: false
Proof of Concept: No exploitable vector. Path parameters cannot contain unexpected extra fields because they are positionally extracted by the router. This is purely a best-practice gap.
Remediation:
Add additionalProperties: false to the params schema:
const getInvoiceByIdSchema = {
params: {
type: 'object',
required: ['invoiceId'],
properties: {
invoiceId: { type: 'string' },
},
additionalProperties: false,
},
};Risk if Unaddressed: None. Informational best-practice recommendation only.
OWASP Category: A05 — Security Misconfiguration (best-practice gap) Severity: Low Status: Open Date Found: 2026-02-24 PR: #247
Description:
The Fastify JSON schema for the color field in both createMilestoneSchema and updateMilestoneSchema is typed as { type: ['string', 'null'] } with no pattern constraint. Hex color validation only happens inside milestoneService.ts via the HEX_COLOR_RE regex. This is functionally correct (the service throws ValidationError which maps to 400), but the omission means the API contract does not self-document the format constraint at the schema layer. This is consistent with the existing open finding "Missing server-side maxLength on text fields" — the same pattern of relying solely on service-layer validation.
Affected Files:
-
server/src/routes/milestones.ts:1819-1825—createMilestoneSchemabody,colorlackspattern -
server/src/routes/milestones.ts:1833-1840—updateMilestoneSchemabody,colorlackspattern
Proof of Concept:
No direct exploit. A client submitting color: "red" will receive a 400 from the service layer, not from Fastify schema validation. The rejection path is correct; the schema layer is simply less defensive.
Remediation:
Add a pattern constraint to the JSON schema for color:
color: { type: ['string', 'null'], pattern: '^#[0-9A-Fa-f]{6}$' },Risk if Unaddressed: None — service layer correctly validates and rejects invalid colors. Minor defense-in-depth gap.
OWASP Category: A05 — Security Misconfiguration (defense-in-depth gap) Severity: Informational Status: Open Date Found: 2026-02-24 PR: #247
Description:
The leadLagDays integer field on dependency create (POST /api/work-items/:id/dependencies) and update (PATCH /api/work-items/:id/dependencies/:predecessorId) accepts any integer value with no minimum or maximum constraint at the Fastify schema or service layer. A value like 999999999 would be silently stored and later consumed by Gantt scheduling calculations.
Affected Files:
-
server/src/routes/dependencies.ts:597—createDependencySchemabody:leadLagDays: { type: 'integer' } -
server/src/routes/dependencies.ts:614—updateDependencySchemabody:leadLagDays: { type: 'integer' }
Remediation: Add sensible bounds to the JSON schema (±10 years as a reasonable maximum for a construction project):
leadLagDays: { type: 'integer', minimum: -3650, maximum: 3650 },Risk if Unaddressed:
No direct security exploit. If future Gantt scheduling logic performs date arithmetic with an extreme leadLagDays value, integer overflow or unexpected scheduling results could occur. Informational only.
OWASP Category: A05 — Security Misconfiguration (information exposure) Severity: Informational Status: Open Date Found: 2026-02-24 PR: #248
Description:
When a circular dependency is detected in the graph, CircularDependencyError is thrown with a details: { cycle: result.cycleNodes } payload that is included in the 409 error response body. The cycleNodes array contains internal work item IDs (e.g., UUIDs of items involved in the cycle). For an authenticated user, this is not a significant disclosure — they can already fetch all work item IDs via GET /api/work-items. The risk is informational in this single-tenant, self-hosted deployment model.
Affected Files:
-
server/src/routes/schedule.ts:101-104—CircularDependencyErrorconstructed with{ cycle: result.cycleNodes } -
server/src/errors/AppError.ts:110-119—CircularDependencyErroraccepts{ cycle: string[] }details
Proof of Concept:
POST /api/schedule with { "mode": "full" } when a dependency cycle exists. The 409 response body includes:
{
"error": {
"code": "CIRCULAR_DEPENDENCY",
"message": "...",
"details": { "cycle": ["work-item-uuid-a", "work-item-uuid-b"] }
}
}Remediation:
No remediation needed in this context. The work item IDs exposed are already visible to any authenticated user through the standard GET /api/work-items endpoint. The cycle detail is useful for UI diagnostics. If a multi-tenant architecture is ever introduced, the details field should be reconsidered.
Risk if Unaddressed: Negligible in a single-tenant deployment. All authenticated users have equal read access to all work items.
OWASP Category: A05 — Security Misconfiguration (defense-in-depth gap) Severity: Informational Status: Open Date Found: 2026-02-24 PR: #248
Description:
The anchorWorkItemId field in the Fastify JSON schema is typed as { type: ['string', 'null'] } with no minLength constraint. An empty string "" passes JSON schema validation and reaches the route handler, where !anchorWorkItemId evaluates to true (empty string is falsy), so it is rejected with a 400 VALIDATION_ERROR — the correct outcome. However, the rejection happens at the service layer rather than at the schema boundary. This is the same defense-in-depth pattern noted in prior PRs for other fields.
Affected Files:
-
server/src/routes/schedule.ts:22—anchorWorkItemId: { type: ['string', 'null'] }lacksminLength: 1
Remediation:
Add minLength: 1 to the schema to reject empty strings at the Fastify validation layer rather than the handler layer:
anchorWorkItemId: { type: ['string', 'null'], minLength: 1 },Risk if Unaddressed:
No exploitable vector. The handler correctly rejects empty anchorWorkItemId via the !anchorWorkItemId check. Purely a defense-in-depth gap.
OWASP Category: A05 — Security Misconfiguration (defense-in-depth gap) Severity: Informational Status: Open Date Found: 2026-02-25 PR: #263
Description:
The workItemIds array introduced in createMilestoneSchema (POST /api/milestones) has no maxItems bound and no maxLength on individual string items. A caller can submit an arbitrarily large array or arbitrarily long strings. The service-layer loop in milestoneService.createMilestone processes each ID with a separate DB round-trip (SELECT + INSERT), creating server-side processing cost proportional to array length with no upper bound enforced at the schema layer. This is the same defense-in-depth gap documented for other fields in the existing "Missing server-side maxLength on text fields" finding.
All DB operations use Drizzle ORM parameterized queries — there is no SQL injection risk. This is purely a resource exhaustion concern.
Affected Files:
-
server/src/routes/milestones.ts—createMilestoneSchemabody:workItemIdsitems have nomaxLength; array has nomaxItems
Proof of Concept:
Submit POST /api/milestones with workItemIds containing thousands of entries or strings of arbitrary length. The request passes JSON schema validation and the service loop executes one DB query per entry.
Remediation:
Add maxItems and per-item maxLength to the JSON schema:
workItemIds: {
type: 'array',
items: { type: 'string', maxLength: 36 },
maxItems: 200,
},maxLength: 36 matches the UUID format used for work item IDs. maxItems: 200 is a generous but bounded limit well above realistic use.
Risk if Unaddressed:
Authenticated user could cause excessive DB load by submitting a very large workItemIds array. Low risk given the self-hosted, trusted-user deployment model. No confidentiality or integrity risk.
OWASP Category: A10 — Server-Side Request Forgery (SSRF) Severity: Medium Status: Resolved Date Found: 2026-03-01 Date Resolved: 2026-03-01 PR: #362
Description:
PAPERLESS_URL is accepted as a raw string with no URL validation in config.ts. Any string value, including file://, ftp://, or http://169.254.169.254/ (cloud IMDS), is accepted without check and later concatenated directly into all upstream fetch URLs in paperlessService.ts. An operator with .env write access could redirect all proxy requests to internal services, metadata endpoints, or non-HTTP schemes.
Affected Files:
-
server/src/plugins/config.ts:108—paperlessUrlaccepted without URL format/scheme validation -
server/src/services/paperlessService.ts:91—fetch(`${baseUrl}${path}`, ...)— unvalidated base URL
Proof of Concept:
Set PAPERLESS_URL=http://169.254.169.254/latest/meta-data/ in .env. Any authenticated user triggering GET /api/paperless/status causes the server to fetch from the AWS IMDS endpoint.
Remediation:
Add URL validation in loadConfig using the built-in URL constructor:
if (paperlessUrl) {
try {
const parsed = new URL(paperlessUrl);
if (!['http:', 'https:'].includes(parsed.protocol)) {
errors.push(`PAPERLESS_URL must use http or https scheme, got: ${parsed.protocol}`);
}
} catch {
errors.push(`PAPERLESS_URL is not a valid URL: ${paperlessUrl}`);
}
}Risk if Unaddressed:
Operator with .env access can redirect proxy traffic to internal services. Blast radius is limited by the self-hosted deployment model — the attacker must already have server-level access. Should still be fixed for defense-in-depth.
Resolution:
PR #362 (round 2): config.ts now parses PAPERLESS_URL with new URL() at startup and enforces a ['http:', 'https:'] scheme allowlist, collecting validation errors into the existing errors array before throwing. file://, ftp://, and other schemes are rejected at server boot. Seven new tests cover http/https acceptance, file/ftp rejection, invalid URL rejection, and disabled-by-default behavior.
OWASP Category: A05 — Security Misconfiguration (information exposure) Severity: Low Status: Resolved Date Found: 2026-03-01 Date Resolved: 2026-03-01 PR: #362
Description:
When Paperless-ngx is configured but unreachable, getStatus() returns the raw OS-level exception message in the error field of the 200 response body. This can expose internal hostnames, IP addresses, port numbers, or TLS error details (e.g., ECONNREFUSED connect ECONNREFUSED 10.0.0.5:8000) to any authenticated Cornerstone user.
Affected Files:
-
server/src/services/paperlessService.ts:262-263— rawerr.messagereturned in status response
Remediation: Return a sanitized string instead of the raw error message, or document as accepted risk given the self-hosted admin context:
return { configured: true, reachable: false, error: 'Cannot connect to Paperless-ngx' };Risk if Unaddressed: Internal network topology may be disclosed to authenticated users. Low impact in self-hosted single-tenant deployment.
Resolution:
PR #362 (round 2): sanitizeErrorMessage() added to paperlessService.ts. Two regex patterns redact IPv4 addresses (\d{1,3}.\d{1,3}.\d{1,3}.\d{1,3}(?::\d+)?) and hostname:port pairs, replacing with <host>. The error type (e.g., ECONNREFUSED, ENOTFOUND) is preserved. Four new tests verify IP and hostname redaction, non-network errors, and 403 probe failures.
OWASP Category: A05 — Security Misconfiguration Severity: Low Status: Resolved Date Found: 2026-03-01 Date Resolved: 2026-03-01 PR: #362
Description:
The thumb and preview endpoints forward the upstream content-type header from Paperless-ngx without any allowlist validation. If Paperless-ngx returns an unexpected content type such as image/svg+xml (which can contain embedded <script> elements), the browser may execute JavaScript in Cornerstone's origin when the image is rendered inline.
Affected Files:
-
server/src/routes/paperless.ts:165-173— thumb endpoint, unvalidated content-type passthrough -
server/src/routes/paperless.ts:202-210— preview endpoint, unvalidated content-type passthrough
Remediation: Allowlist permitted content types and fall back to a safe default:
const ALLOWED_THUMB_TYPES = new Set(['image/webp', 'image/jpeg', 'image/png', 'image/gif']);
const upstreamType = upstream.headers.get('content-type') ?? '';
const contentType = ALLOWED_THUMB_TYPES.has(upstreamType) ? upstreamType : 'image/webp';Risk if Unaddressed: Low in practice — Paperless-ngx is a trusted backend. A compromised or misconfigured Paperless instance could deliver SVG-based XSS through the Cornerstone proxy.
Resolution:
PR #362 (round 2): sanitizeBinaryContentType() helper added to paperless.ts. A BINARY_CONTENT_TYPE_ALLOWLIST Set (image/webp, image/png, image/jpeg, image/gif, application/pdf) gates all content-type passthrough for thumb and preview endpoints. Types outside the allowlist (including image/svg+xml, text/html, application/javascript) fall back to application/octet-stream. MIME parameters (e.g., image/webp; charset=utf-8) are stripped before matching. Tests verify allowlisted passthrough, disallowed-type fallback, and null-header default-type behavior.
OWASP Category: A03 — Injection (low severity) Severity: Low Status: Resolved Date Found: 2026-03-01 Date Resolved: 2026-03-01 PR: #362
Description:
The tags query parameter on GET /api/paperless/documents is typed as { type: 'string', maxLength: 200 } with no format constraint. Arbitrary strings are forwarded to Paperless-ngx via URLSearchParams (which correctly URL-encodes them, preventing header injection). Non-numeric values will cause Paperless-ngx to return an error surfaced as PAPERLESS_ERROR 502, potentially exposing upstream API error messages to the client.
Affected Files:
-
server/src/routes/paperless.ts:27—tags: { type: 'string', maxLength: 200 }lackspatternconstraint
Remediation: Add a pattern constraint to enforce comma-separated integers:
tags: { type: 'string', maxLength: 200, pattern: '^\\d+(,\\d+)*$' },Risk if Unaddressed:
URLSearchParams encoding prevents HTTP injection. Only risk is upstream error message leakage on invalid input. Low impact.
Resolution:
PR #362 (round 2): pattern: '^\\d+(,\\d+)*$' added to the tags property in listDocumentsSchema. Non-numeric and SQL-injection-style values now return 400 at the Fastify schema validation layer before reaching the service. Tests verify rejection of abc, 1 OR 1=1 (URL-encoded), and acceptance of valid comma-separated integers like 5,12,20.
OWASP Category: A05 — Security Misconfiguration (defense-in-depth gap) Severity: Informational Status: Open Date Found: 2026-02-26 PR: #308
Description:
The actualStartDate and actualEndDate fields added to createWorkItemSchema and updateWorkItemSchema both carry format: 'date' constraints, which correctly enforces ISO date format. However, there is no cross-field validation ensuring actualStartDate <= actualEndDate. A caller can submit actualStartDate: "2026-05-10" and actualEndDate: "2026-03-01" — an end date that precedes the start date — and this passes schema validation and is written to the database without error.
The existing startDate / endDate pair has the same gap (also noted as a carryover from prior PRs), so this is consistent with the project's existing validation style. The scheduling engine uses actualStartDate as ES and actualEndDate as EF, meaning an inverted actual-date pair would produce a negative-duration Gantt bar and potentially confuse CPM calculations for downstream dependencies.
Affected Files:
-
server/src/routes/workItems.ts:24-25—actualStartDate/actualEndDateschema lacks cross-field ordering constraint -
server/src/services/workItemService.ts:445-451— no ordering check before DB write
Proof of Concept:
PATCH /api/work-items/<id>
Content-Type: application/json
{ "actualStartDate": "2026-12-31", "actualEndDate": "2026-01-01" }Returns 200. Both values are persisted. The Gantt bar for this item would have a negative computed width.
Remediation:
Add a cross-field ordering check in workItemService.updateWorkItem and createWorkItem, analogous to the existing validateDateConstraints:
if (actualStartDate && actualEndDate && actualEndDate < actualStartDate) {
throw new ValidationError('actualEndDate must be on or after actualStartDate');
}Optionally, add this as a custom AJV keyword or an if/then JSON Schema constraint for defense-in-depth at the schema layer.
Risk if Unaddressed: No confidentiality or authorization risk. An authenticated user could store an inverted actual-date pair that produces nonsensical Gantt rendering and could push downstream scheduled items to unexpected positions. Low practical risk in this single-tenant, trusted-user deployment model.
OWASP Category: A01 — Broken Access Control (horizontal privilege escalation) Severity: Low Status: Open Date Found: 2026-03-02 PR: #363
Description:
DELETE /api/document-links/:id allows any authenticated user to delete any document link by its ID regardless of who created it. The createdBy field is persisted on the link but is not consulted during deletion. A member-role user who knows or guesses a link ID created by another user can delete it.
This finding requires a design decision. If the intent is that all authenticated users share equal write access to document links (consistent with how work items, budget lines, and invoices are managed in this single-tenant application), this is acceptable as an accepted design decision. If the intent is that only the link creator or an Admin can delete links, an ownership or role check must be added.
Affected Files:
-
server/src/routes/documentLinks.ts— delete handler callsdocumentLinkService.deleteLink(fastify.db, request.params.id)with no ownership or role check -
server/src/services/documentLinkService.ts—deleteLink()performs no ownership validation
Proof of Concept:
- User A creates a document link → receives link ID
link-abc - User B (any authenticated user) sends
DELETE /api/document-links/link-abc→ 204 No Content, link deleted
Remediation: If ownership restriction is desired, add a creator or role check before deletion:
// In the delete handler:
const existing = documentLinkService.getLinkById(fastify.db, request.params.id);
if (!existing) throw new NotFoundError('Document link not found');
if (existing.createdBy?.id !== request.user.id && request.user.role !== 'admin') {
throw new ForbiddenError('Cannot delete a link created by another user');
}
documentLinkService.deleteLink(fastify.db, request.params.id);If the design intent is equal access (consistent with rest of the application), document this in ADR-015 and close this finding as Accepted Risk.
Risk if Unaddressed: Any authenticated user can delete any document link created by any other user. In this single-tenant, trusted-user deployment model the blast radius is low — there is no cross-tenant data leakage, only potential collaborative disruption between users who already share all data.
OWASP Category: A05 — Security Misconfiguration (defense-in-depth gap) Severity: Informational Status: Open Date Found: 2026-03-02 PR: #363
Description:
The deleteLinkSchema for DELETE /api/document-links/:id does not set additionalProperties: false on the params schema, inconsistent with createLinkSchema and listLinksSchema in the same file which correctly set additionalProperties: false. This is the same pattern noted in the existing finding for getInvoiceByIdSchema. Path parameters cannot contain unexpected extra fields in Fastify's positional router so there is no exploitable vector.
Affected Files:
-
server/src/routes/documentLinks.ts—deleteLinkSchema.paramslacksadditionalProperties: false
Remediation:
const deleteLinkSchema = {
params: {
type: 'object',
required: ['id'],
properties: {
id: { type: 'string' },
},
additionalProperties: false,
},
};Risk if Unaddressed: None. Informational best-practice recommendation only.
OWASP Category: A05 — Security Misconfiguration (defense-in-depth gap) Severity: Informational Status: Open Date Found: 2026-03-02 PR: #363
Description:
The entityId field in both createLinkSchema (body) and listLinksSchema (querystring) sets minLength: 1 but no maxLength. Entity IDs are UUIDs (36 characters) in practice. All DB operations use Drizzle ORM parameterized queries so there is no injection risk; SQLite TEXT columns are unbounded. This is consistent with the existing open finding "Missing server-side maxLength on text fields".
Affected Files:
-
server/src/routes/documentLinks.ts—createLinkSchema.body.properties.entityIdandlistLinksSchema.querystring.properties.entityIdlackmaxLength
Remediation:
Add maxLength: 36 to both entityId schema definitions to match the UUID format used throughout the application.
Risk if Unaddressed:
An authenticated user could store an oversized string in the entity_id column. No SQL injection or authorization risk. Low practical impact.
OWASP Category: A03 — Injection (XSS) Severity: Informational Status: Open Date Found: 2026-03-02 PR: #364
Description:
The PaperlessSearchHit.highlights field (type string) contains HTML markup with <em> tags generated by Paperless-ngx's full-text search engine. This field is present in the PaperlessDocumentSearchResult type and is available in the data model flowing through the Document Browser. In PR #364 (Story 8.3), this field is not rendered — no component accesses searchHit.highlights. However, if a future story adds rendering of this field to highlight matched search terms, using dangerouslySetInnerHTML without a strict HTML sanitizer would introduce a Stored/Reflected XSS vulnerability, as Paperless-ngx could return arbitrary HTML depending on document content and the Paperless-ngx version.
Affected Files:
-
shared/src/types/document.ts:70—PaperlessSearchHit.highlightstyped asstring(HTML content) -
client/src/components/documents/DocumentCard.tsx—searchHitfield present in document prop but not rendered (safe) -
client/src/components/documents/DocumentDetailPanel.tsx—searchHitfield present in document prop but not rendered (safe)
Proof of Concept: If a future implementation adds:
<p dangerouslySetInnerHTML={{ __html: document.searchHit.highlights }} />...and Paperless-ngx returns <em onclick="alert(1)">match</em> or a crafted payload, XSS would execute in the user's browser.
Remediation:
If searchHit.highlights is rendered in a future story, one of the following approaches must be used:
-
Server-side stripping (preferred): In the Paperless-ngx proxy service (
server/src/services/paperlessService.ts), strip all HTML tags fromhighlightsbefore forwarding to the client, or allow only<em>with no attributes via a server-side allowlist. The client then renders plain text (or uses CSS-only highlighting). -
Client-side sanitization with DOMPurify: If HTML rendering is required for visual highlighting, use
DOMPurify.sanitize(highlights, { ALLOWED_TAGS: ['em'], ALLOWED_ATTR: [] })before passing todangerouslySetInnerHTML. DOMPurify must be added as a dependency and the configuration must be strictly allowlisted.
Any PR that introduces rendering of this field must receive a security review before merge.
Risk if Unaddressed:
If highlights is rendered without sanitization, a malicious or compromised Paperless-ngx instance could inject arbitrary HTML/JavaScript into the Cornerstone UI, leading to session hijacking, credential theft, or UI redress attacks for all connected users.
OWASP Category: A05 — Security Misconfiguration (defense-in-depth gap) Severity: Informational Status: Open Date Found: 2026-03-03 PR: #402
Description:
The POST body schema for POST /api/household-items/:householdItemId/work-items validates workItemId as { type: 'string' } with no minLength: 1 constraint. An empty string "" passes schema validation and reaches the service layer, where assertWorkItemExists queries the database for a work item with id = "" and correctly returns a NotFoundError (404). There is no exploit path.
This is consistent with the existing codebase pattern — no route in the application specifies minLength on ID body fields. Noted for completeness.
Affected Files:
-
server/src/routes/householdItemWorkItems.ts:21—linkWorkItemSchema.body.properties.workItemIdlacksminLength: 1
Remediation:
workItemId: { type: 'string', minLength: 1 },Risk if Unaddressed: None. The service-layer existence check provides a correct 404 response for empty or non-existent IDs. No injection, authorization, or data integrity risk.
No separate design-phase findings; security was reviewed inline with each implementation PR.
| Date | Scope | Reviewer |
|---|---|---|
| 2026-02-16 | EPIC-01 Auth (PRs #55–#82) | security-engineer |
| 2026-02-17 | EPIC-03 Work Items (PRs #97–#106) | security-engineer |
| 2026-02-20 | EPIC-05 Budget (PRs #150–#158) | security-engineer |
| 2026-02-21 | EPIC-05 Budget Rework (PR #187) | security-engineer |
| 2026-02-22 | EPIC-05 Budget Frontend Rework (PR #193) | security-engineer |
| 2026-02-22 | Budget Hero Bar + Category Filter (PR #195) | security-engineer |
| 2026-02-23 | Standalone Invoices View (PR #203) | security-engineer |
| 2026-02-24 | EPIC-06 Milestones Backend (PR #247) | security-engineer |
| 2026-02-24 | EPIC-06 Scheduling Engine — CPM (PR #248) | security-engineer |
| 2026-02-24 | EPIC-06 Timeline Data API (PR #249) | security-engineer |
| 2026-02-24 | EPIC-06 Gantt Chart Core (PR #250) | security-engineer |
| 2026-02-24 | EPIC-06 Gantt Interactive Features (PR #253) | security-engineer |
| 2026-02-24 | EPIC-06 Milestones Frontend — CRUD Panel & Diamond Markers (PR #254) | security-engineer |
| 2026-02-25 | EPIC-06 UAT Fixes — projected dates, late milestones, WorkItemSelector (PR #263) | security-engineer |
| 2026-02-25 | EPIC-06 UAT Feedback Fixes — column zoom, milestone rows, back-to-timeline nav (PR #267) | security-engineer |
| 2026-02-26 | Gantt dependency highlighting on hover — frontend-only (PR #306) | security-engineer |
| 2026-02-26 | EPIC-07 Actual dates, delay tracking, blocked-status removal (PR #308) | security-engineer |
| 2026-02-27 | Retrospective improvements — dep pinning, shared CSS, formatDate, invoiceService refactor (PR #316) | security-engineer |
| 2026-03-01 | EPIC-08 Paperless-ngx proxy service — backend foundation (PR #362) | security-engineer |
| 2026-03-01 | EPIC-08 PR #362 round 2 — remediation verification (SSRF, content-type allowlist, error sanitization, tags pattern) | security-engineer |
| 2026-03-02 | EPIC-08 Story 8.2 — Document Links Schema & CRUD API (PR #363) | security-engineer |
| 2026-03-02 | EPIC-08 Story 8.3 — Document Browser & Search UI (PR #364) | security-engineer |
| 2026-03-03 | EPIC-04 Stories 4.1–4.7 — Household Items (PRs #396, #397, #398, #399, #400, #401, #402) | security-engineer |