Skip to content
Open
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
23 changes: 23 additions & 0 deletions .devcontainer/devcontainer.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
{
"name": "subtrackr-devcontainer",
"dockerComposeFile": ["../../docker-compose.yml"],
"service": "backend",
"workspaceFolder": "/workspace",
"shutdownAction": "stopCompose",
"postCreateCommand": "npm install && npm run prepare",
"customizations": {
"vscode": {
"extensions": [
"dbaeumer.vscode-eslint",
"esbenp.prettier-vscode",
"ms-azuretools.vscode-docker"
],
"settings": {
"editor.formatOnSave": true,
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.codeActionsOnSave": { "source.fixAll.eslint": true },
"typescript.tsdk": "node_modules/typescript/lib"
}
}
}
}
67 changes: 25 additions & 42 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files

# dependencies
# Dependencies
node_modules/
pnpm-lock.yaml
pnpm-lock.yaml/

# Expo
.expo/
Expand All @@ -22,31 +20,32 @@ expo-env.d.ts
# Metro
.metro-health-check*

# debug
# Debug
npm-debug.*
yarn-debug.*
yarn-error.*

# macOS
# macOS / Linux
.DS_Store
*.pem

# local env files
# Local env files
.env*.local
.env

# typescript
# TypeScript
*.tsbuildinfo

# Build outputs
builds/
android/
ios/

.DS_Store
.env

# IDE / Tools
.claude/
.idea/
.vscode/settings.json
!.vscode/extensions.json
_typechain_tmp/
_tc_verify/

Expand Down Expand Up @@ -74,47 +73,41 @@ coverage/
# Test snapshots & generated test/lint artifacts
**/__snapshots__/
*.snap
*.snap.orig
junit.xml
jest-results.json
*_output.txt
*_output_*.txt
test-results/
playwright-report/
e2e/artifacts/
e2e/screenshots/
_jest-cache/
test_output.txt
tsc_output*.txt
lint_output*.txt
lint_final_error.txt
final_lint_check.txt

# Load test reports (generated) — keep the dir so k6 can write into it
# Load test reports
load-tests/reports/*
!load-tests/reports/.gitkeep

# VS Code
.vscode/settings.json
!.vscode/extensions.json

# React Native
.expo-shared/


# Test snapshots
__snapshots__/
*.snap

# Build artifacts
build_errors*.txt
*.log
contracts/migrations/history/*
!contracts/migrations/history/.gitkeep
contracts/migrations/snapshots/*
!contracts/migrations/snapshots/.gitkeep

# Rust / Soroban test snapshots (generated by soroban-sdk test runner)
# Rust / Soroban test snapshots
contracts/**/test_snapshots/
test_snapshots/

# Generated files
# Generated / artifact files
*.orig
SubTrackr
test_output.txt
tsc_output*.txt
lint_output*.txt
lint_final_error.txt
final_lint_check.txt
contracts/clippy_output.txt
issue*.json
issues_summary.json
Expand All @@ -129,19 +122,9 @@ DESIGN_SYSTEM_INTEGRATION.md
DESIGN_SYSTEM_IMPLEMENTATION.md
DESIGN_SYSTEM_SETUP.md
WCAG_COMPLIANCE.md
FORMATTING.md

# Backup / duplicate files
*.backup
*\ copy.*
package.json.backup
SubTrackr
FORMATTING.md
package.json.backup
# Test run artifacts (NOT the committed contract insta snapshots under
# contracts/**/test_snapshots/, which are intentional fixtures)
test-results/
playwright-report/
e2e/artifacts/
e2e/screenshots/
*.snap.orig
.jest-cache/
19 changes: 19 additions & 0 deletions .gitpod.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
image: mcr.microsoft.com/devcontainers/typescript-node:20
ports:
- "3000:3000"
- "3001:3001"
tasks:
- name: Install dependencies
command: |
npm install
npm run prepare
vscode:
extensions:
- dbaeumer.vscode-eslint
- esbenp.prettier-vscode
- ms-azuretools.vscode-docker
settings:
editor.formatOnSave: true
editor.defaultFormatter: esbenp.prettier-vscode
editor.codeActionsOnSave:
source.fixAll.eslint: true
1 change: 1 addition & 0 deletions .husky/commit-msg
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
npx --no -- commitlint --edit $1
16 changes: 15 additions & 1 deletion app.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,21 @@
"web": {
"favicon": "./assets/subtrackr-icon.png",
"name": "SubTrackr - Subscription Management with Crypto Payments",
"description": "Manage your subscriptions with Web3 crypto payments"
"description": "Manage your subscriptions with Web3 crypto payments",
"security": {
"hsts": {
"maxAgeSeconds": 63072000,
"includeSubDomains": true,
"preload": true
},
"permissionsPolicy": {
"geolocation": "none",
"microphone": "none",
"camera": "none",
"payment": "none",
"usb": "none"
}
}
},
"extra": {
"eas": {
Expand Down
14 changes: 14 additions & 0 deletions backend/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import { rateLimitingService } from './services/shared/rateLimitingService';
import { createRateLimitMiddleware, RATE_LIMIT_HEADERS } from './services/shared/rateLimitMiddleware';
import { applyCompression, compressionPrometheusMetrics } from './services/shared/compression';
import { wrapWithMonitor, type MonitoredPool } from './services/shared/poolMonitor';
import { applySecurityHeadersToResponse } from './shared/middleware/securityHeaders';
import { SubscriptionTier } from '../src/types/subscription';

export interface StartServerOptions {
Expand Down Expand Up @@ -209,6 +210,19 @@ export async function startServer(options: StartServerOptions = {}): Promise<Run
const { pathname } = url;
const method = req.method ?? 'GET';

applySecurityHeadersToResponse(res);

const originalWriteHead = res.writeHead.bind(res);
res.writeHead = ((statusCode: number, statusMessage?: string | Record<string, string>) => {
if (!res.headersSent) {
applySecurityHeadersToResponse(res);
}
if (typeof statusMessage === 'string') {
return originalWriteHead(statusCode, statusMessage);
}
return originalWriteHead(statusCode, statusMessage);
}) as typeof res.writeHead;

try {
// -----------------------------------------------------------------
// Health (bypass rate limiting)
Expand Down
107 changes: 107 additions & 0 deletions backend/shared/middleware/__tests__/securityHeaders.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
/**
* Tests for security headers middleware.
*
* Issue #1010: HSTS and Permissions-Policy hardening.
*/

import {
buildSecurityHeaders,
buildHsts,
buildPermissionsPolicy,
securityHeadersMiddleware,
applySecurityHeadersToResponse,
type SecurityHeadersOptions,
} from '../securityHeaders';

describe('buildHsts', () => {
it('returns default HSTS header', () => {
expect(buildHsts({})).toBe('max-age=63072000; includeSubDomains; preload');
});

it('respects custom max-age', () => {
expect(buildHsts({ hstsMaxAge: 86400 })).toBe('max-age=86400; includeSubDomains; preload');
});

it('omits includeSubDomains when disabled', () => {
expect(buildHsts({ hstsIncludeSubDomains: false })).toBe('max-age=63072000; preload');
});

it('omits preload when disabled', () => {
expect(buildHsts({ hstsPreload: false })).toBe('max-age=63072000; includeSubDomains');
});
});

describe('buildPermissionsPolicy', () => {
it('returns default hardened policy', () => {
const policy = buildPermissionsPolicy({});
expect(policy).toContain("geolocation='none'");
expect(policy).toContain("microphone='none'");
expect(policy).toContain("camera='none'");
expect(policy).toContain("publickeyCredentialsGet='self'");
});

it('merges custom directives', () => {
const policy = buildPermissionsPolicy({
permissionsPolicy: { geolocation: ["'self'"] },
});
expect(policy).toContain("geolocation='self'");
expect(policy).toContain("microphone='none'");
});
});

describe('buildSecurityHeaders', () => {
it('includes HSTS and Permissions-Policy', () => {
const headers = buildSecurityHeaders({});
expect(headers['Strict-Transport-Security']).toBe('max-age=63072000; includeSubDomains; preload');
expect(headers['Permissions-Policy']).toContain("geolocation='none'");
expect(headers['X-Content-Type-Options']).toBe('nosniff');
expect(headers['X-Frame-Options']).toBe('SAMEORIGIN');
expect(headers['Referrer-Policy']).toBe('strict-origin-when-cross-origin');
});
});

describe('securityHeadersMiddleware', () => {
it('sets headers on the response', () => {
const headersOut: Record<string, string> = {};
const res = {
headersSent: false,
setHeader: (name: string, value: string | number | string[]) => {
headersOut[name] = String(value);
},
} as any;
const next = jest.fn();

securityHeadersMiddleware()(null, res, next);
expect(next).toHaveBeenCalled();
expect(headersOut['Strict-Transport-Security']).toBe('max-age=63072000; includeSubDomains; preload');
expect(headersOut['X-Frame-Options']).toBe('SAMEORIGIN');
});

it('skips setting headers when already sent', () => {
const setHeader = jest.fn();
const res = {
headersSent: true,
setHeader,
} as any;
const next = jest.fn();

securityHeadersMiddleware()(null, res, next);
expect(setHeader).not.toHaveBeenCalled();
expect(next).toHaveBeenCalled();
});
});

describe('applySecurityHeadersToResponse', () => {
it('applies headers to response object', () => {
const headersOut: Record<string, string> = {};
const res = {
headersSent: false,
setHeader: (name: string, value: string | number | string[]) => {
headersOut[name] = String(value);
},
} as any;

applySecurityHeadersToResponse(res, { hstsMaxAge: 100 });
expect(headersOut['Strict-Transport-Security']).toBe('max-age=100; includeSubDomains; preload');
});
});
6 changes: 6 additions & 0 deletions backend/shared/middleware/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,9 @@ export {
applyETagToRawHandler,
} from './etagMiddleware';
export type { ETagMiddlewareOptions } from './etagMiddleware';
export {
buildSecurityHeaders,
securityHeadersMiddleware,
applySecurityHeadersToResponse,
} from './securityHeaders';
export type { SecurityHeadersOptions } from './securityHeaders';
Loading