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
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# Required by docker-compose.yml for production deployments.
POSTGRES_PASSWORD=
ADMIN_SECRET_KEY=
ADMIN_SESSION_SECRET=
ADMIN_USERNAME=
ADMIN_PASSWORD=
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
# dependencies
**/node_modules/
**/.npm-cache/

# env / secrets
.env
Expand Down Expand Up @@ -29,6 +30,10 @@
**/tmp/
.DS_Store
.codex-tmp/

# Accidental local shell output files
document.documentElement.clientWidth
document.documentElement.clientWidth\}\)
.claude/
.expo/
.expo-shared/
Expand Down
37 changes: 37 additions & 0 deletions backend/__tests__/adminAuth.middleware.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
process.env.NODE_ENV = 'test';
process.env.ADMIN_SECRET_KEY = 'test-admin-secret-long-enough';

const { adminAuthMiddleware } = require('../src/middleware/adminAuth.middleware');

function invoke(key) {
const req = { headers: { 'x-admin-key': key } };
const res = {
statusCode: 200,
body: null,
status(code) {
this.statusCode = code;
return this;
},
json(body) {
this.body = body;
return this;
},
};
const next = jest.fn();
adminAuthMiddleware(req, res, next);
return { res, next };
}

describe('adminAuthMiddleware', () => {
test('accepts the configured admin key', () => {
const { next } = invoke(process.env.ADMIN_SECRET_KEY);
expect(next).toHaveBeenCalledTimes(1);
});

test('rejects an invalid admin key', () => {
const { res, next } = invoke('wrong-key');
expect(next).not.toHaveBeenCalled();
expect(res.statusCode).toBe(403);
expect(res.body).toEqual({ success: false, message: 'Forbidden' });
});
});
33 changes: 33 additions & 0 deletions backend/__tests__/dataImage.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
const { deleteMaterializedImage, hasValidSignature, materializeDataImage } = require('../src/utils/dataImage');

describe('data image validation', () => {
test('recognizes supported image signatures', () => {
expect(hasValidSignature(Buffer.from([0xff, 0xd8, 0xff, 0x00]), 'image/jpeg')).toBe(true);
expect(
hasValidSignature(
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
'image/png'
)
).toBe(true);
expect(hasValidSignature(Buffer.from('GIF89a', 'ascii'), 'image/gif')).toBe(true);
expect(hasValidSignature(Buffer.from('RIFFxxxxWEBP', 'ascii'), 'image/webp')).toBe(true);
});

test('rejects content that is only labeled as an image', async () => {
const fakeImage = Buffer.from('<script>alert(1)</script>').toString('base64');
await expect(
materializeDataImage(`data:image/png;base64,${fakeImage}`, 'test')
).rejects.toThrow('Rasm tarkibi tanlangan formatga mos emas');
});
});

test('deleteMaterializedImage only deletes managed agency uploads', async () => {
const pngBuffer = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
const image = `data:image/png;base64,${pngBuffer.toString('base64')}`;
const storedPath = await materializeDataImage(image, 'agency');

await expect(deleteMaterializedImage(storedPath, 'agency')).resolves.toBe(true);
await expect(deleteMaterializedImage(storedPath, 'agency')).resolves.toBe(false);
await expect(deleteMaterializedImage('https://example.com/image.png', 'agency')).resolves.toBe(false);
await expect(deleteMaterializedImage('/uploads/agency/../secret.txt', 'agency')).resolves.toBe(false);
});
72 changes: 72 additions & 0 deletions backend/__tests__/security.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
const request = require('supertest');
const jwt = require('jsonwebtoken');

process.env.NODE_ENV = 'test';
process.env.JWT_SECRET = 'test-jwt-secret-long-enough';
process.env.AGENCY_JWT_SECRET = 'test-agency-secret-long-enough';
process.env.ADMIN_SECRET_KEY = 'test-admin-secret-long-enough';

jest.mock('../src/config/database', () => ({
prisma: {
landingInteraction: {
create: jest.fn().mockResolvedValue({ id: 'interaction-1' }),
},
},
}));

const app = require('../app');
const { prisma } = require('../src/config/database');

describe('security boundaries', () => {
test('rejects unauthenticated POI writes', async () => {
const response = await request(app)
.post('/api/v1/poi')
.send({ name: 'Unauthorized place' });

expect(response.status).toBe(403);
expect(response.body.success).toBe(false);
});

test('rejects booking history lookup without a bearer token', async () => {
const response = await request(app)
.get('/api/v1/bookings/mine')
.query({ email: 'victim@example.com' });

expect(response.status).toBe(401);
expect(response.body.success).toBe(false);
});

test('does not trust a client-supplied interaction userId', async () => {
const response = await request(app)
.post('/api/v1/home/interactions')
.send({
entityType: 'place',
entityId: 'place-1',
eventType: 'view',
userId: 'spoofed-user',
});

expect(response.status).toBe(200);
expect(prisma.landingInteraction.create).toHaveBeenCalledWith({
data: expect.objectContaining({ userId: null }),
});
});

test('uses the authenticated identity for interactions', async () => {
const token = jwt.sign({ id: 'real-user' }, process.env.JWT_SECRET);
const response = await request(app)
.post('/api/v1/home/interactions')
.set('authorization', `Bearer ${token}`)
.send({
entityType: 'place',
entityId: 'place-1',
eventType: 'view',
userId: 'spoofed-user',
});

expect(response.status).toBe(200);
expect(prisma.landingInteraction.create).toHaveBeenLastCalledWith({
data: expect.objectContaining({ userId: 'real-user' }),
});
});
});
22 changes: 19 additions & 3 deletions backend/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ const helmet = require('helmet');
const path = require('path');
const { loggerMiddleware } = require('./src/middleware/logger.middleware');
const { rateLimiter } = require('./src/middleware/rateLimit.middleware');
const { logger } = require('./src/config/logger');
const routes = require('./src/routes/index');

const app = express();
Expand Down Expand Up @@ -286,11 +287,16 @@ if (process.env.NODE_ENV === 'production') {
app.use(helmet({
crossOriginResourcePolicy: { policy: 'cross-origin' },
}));
app.use(cors({ origin: process.env.ALLOWED_ORIGINS?.split(',') || '*' }));
const allowedOrigins = process.env.ALLOWED_ORIGINS
?.split(',')
.map((origin) => origin.trim())
.filter(Boolean);
app.use(cors({ origin: allowedOrigins?.length ? allowedOrigins : '*' }));
app.use('/uploads', express.static(path.join(__dirname, 'uploads'), {
fallthrough: false,
maxAge: process.env.NODE_ENV === 'production' ? '30d' : 0,
}));
app.use('/api/v1', rateLimiter);
app.use(express.json({ limit: '12mb' }));
app.use(loggerMiddleware);

Expand All @@ -307,11 +313,21 @@ app.get(['/privacy-policy', '/privacy'], (req, res) => {
res.status(200).send(renderPrivacyPolicyPage(baseUrl));
});

app.use('/api/v1', rateLimiter, routes);
app.use('/api/v1', routes);

app.use((err, req, res, next) => {
const status = err.status || 500;
res.status(status).json({ success: false, message: err.message || 'Internal Server Error' });
if (status >= 500) {
logger.error('Unhandled request error', {
message: err.message,
method: req.method,
path: req.originalUrl,
});
}
const message = status >= 500 && process.env.NODE_ENV === 'production'
? 'Internal Server Error'
: err.message || 'Internal Server Error';
res.status(status).json({ success: false, message });
});

module.exports = app;
Loading