The uploads directory is the only place in this application where content someone else authored is served over HTTP. #95 stopped a dangerous file being stored; this stops a stored file doing damage if one ever gets there anyway — through a gap, a path added later, a restore, or a file written before that validation existed. Two halves, complementary rather than alternative. The app's own origin now serves uploads defensively. An allowlist of the three extensions the upload path can produce, so a `.html` or a `.svg` on disk is simply not a file this application hands out — 404, the same answer as a file that is not there, so the response cannot be used to learn which paths exist. An allowlist rather than a denylist because a denylist has to anticipate every type a browser might execute, which is a moving target across browsers and years, while this only has to know three. The content type is stated explicitly from that same list rather than sniffed or guessed from a name someone else chose, paired with `nosniff`. `default-src 'none'; sandbox` gives a directly-navigated file no capabilities at all, which is the only way one of these can do harm — an `<img>` embed does not execute script. Writes get 405 rather than falling through to a 404 that suggests the path is wrong. The other half is the separate origin, which is the real fix, because the origin is the whole unit of trust in a browser. That needs a hostname and a certificate, which live outside this repository, so what is here is the switch: `UPLOADS_BASE_URL`, sent to the frontend at runtime through `/api/config` and joined onto stored paths by `uploadUrl`. Empty means the app's own origin, which is the default and what local development has, so nothing changes until it is pointed somewhere. Stored paths stay site-relative. A stored value outlives any hostname baked into it, and rewriting them would be a migration to undo the day the hostname changes. Runtime rather than built in, so one image serves every environment — the same reason `paypalClientId` and `demoMode` are already there. `UPLOADS_BASE_URL` has a line in `docker-compose.prod.yml` while still empty, deliberately: a Portainer stack variable with no line there is substituted into the file and never reaches the container, which is exactly how `UPLOADS_DIR` went missing on 2026-08-23. Unset warns at boot, in the same shape as the admin gate — a working configuration with one defence switched off is worth saying out loud. Set without a scheme is refused outright, because a bare hostname joins onto a stored path as if it were relative and breaks every image on the site rather than failing visibly. The compose guard now resolves `${VAR:-default}` to its default, which is what the container actually receives when the stack variable behind it is unset. A bare `${VAR}` is still left opaque, so a required variable referenced that way goes on counting as present — that check is about the line existing, not about the stack being filled in. Closes #103
112 lines
5.3 KiB
TypeScript
Executable File
112 lines
5.3 KiB
TypeScript
Executable File
import express, { Request, Response, NextFunction } from 'express';
|
|
import cookieParser from 'cookie-parser';
|
|
import path from 'path';
|
|
import itemsRouter from './routes/items';
|
|
import { router as cartCheckoutRouter, webhookRouter as cartCheckoutWebhookRouter } from './routes/cartCheckout';
|
|
import adminRouter from './routes/admin';
|
|
import adminCustomersRouter from './routes/adminCustomers';
|
|
import adminSettingsRouter from './routes/adminSettings';
|
|
import adminEmailTemplatesRouter from './routes/adminEmailTemplates';
|
|
import adminCategoriesRouter from './routes/adminCategories';
|
|
import adminTagsRouter from './routes/adminTags';
|
|
import filtersRouter from './routes/filters';
|
|
import customersRouter from './routes/customers';
|
|
import publicRouter from './routes/public';
|
|
import cartRouter from './routes/cart';
|
|
import shippingAddressesRouter from './routes/shippingAddresses';
|
|
import clientErrorsRouter from './routes/clientErrors';
|
|
import { attachCustomer } from './middleware/customerAuth';
|
|
import { requireAdminGate } from './middleware/adminGate';
|
|
import { asyncRoute } from './asyncRoute';
|
|
import { uploadsRouter } from './uploads';
|
|
|
|
const app = express();
|
|
// Express advertises itself in X-Powered-By by default, which hands an
|
|
// attacker the server stack for free when picking exploits to try.
|
|
app.disable('x-powered-by');
|
|
app.set('trust proxy', 1);
|
|
|
|
app.use('/webhooks/paypal', express.json(), cartCheckoutWebhookRouter);
|
|
app.use(express.json());
|
|
app.use(cookieParser());
|
|
// Wrapped like any route: attachCustomer awaits a session lookup, and mounted
|
|
// globally an unforwarded rejection here would hang every request in the app —
|
|
// including the routes that wrap their own handlers correctly.
|
|
app.use(asyncRoute(attachCustomer));
|
|
app.use('/uploads', uploadsRouter(process.env.UPLOADS_DIR || '/app/uploads'));
|
|
|
|
// Trimmed with a loop rather than a `/+$/` regex, which backtracks.
|
|
function trimTrailingSlashes(value: string): string {
|
|
let trimmed = value;
|
|
while (trimmed.endsWith('/')) trimmed = trimmed.slice(0, -1);
|
|
return trimmed;
|
|
}
|
|
|
|
app.get('/api/config', (_req, res) => {
|
|
const clientId = process.env.PAYPAL_CLIENT_ID;
|
|
const isPlaceholder = !clientId || clientId.length < 10 || clientId === 'REPLACE_WITH_PAYPAL_CLIENT_ID';
|
|
res.json({
|
|
paypalClientId: isPlaceholder ? null : clientId,
|
|
demoMode: process.env.DEMO_MODE !== 'false',
|
|
currency: process.env.SITE_CURRENCY || 'USD',
|
|
// Where uploaded images should be fetched from (#103). Empty means the
|
|
// app's own origin, which is both the default and what local development
|
|
// has — there is no second hostname on a laptop. Set it to a hostname of
|
|
// its own in production and user-supplied files stop sharing an origin with
|
|
// the application, which is the whole unit of trust in a browser.
|
|
//
|
|
// Sent at runtime rather than built in, so one image serves every
|
|
// environment, the same reason paypalClientId and demoMode are here.
|
|
//
|
|
// Trailing slash trimmed so callers can join with a stored path, which
|
|
// always begins with one, without producing a double.
|
|
uploadsBaseUrl: trimTrailingSlashes(process.env.UPLOADS_BASE_URL ?? '')
|
|
});
|
|
});
|
|
|
|
app.use('/api/items', itemsRouter);
|
|
app.use('/api/filters', filtersRouter);
|
|
app.use('/api/cart', cartRouter);
|
|
app.use('/api/checkout/cart', cartCheckoutRouter);
|
|
// requireAdminGate is attached to each admin router rather than to a path
|
|
// prefix. Attached to the router, an admin router added later at some other
|
|
// path still inherits it — and since the proxy only injects the header on the
|
|
// paths its regex matches, that router refuses loudly on its first request
|
|
// instead of being quietly public. See middleware/adminGate.ts and #63.
|
|
app.use('/api/admin/customers', requireAdminGate, adminCustomersRouter);
|
|
app.use('/api/admin/settings', requireAdminGate, adminSettingsRouter);
|
|
app.use('/api/admin/email-templates', requireAdminGate, adminEmailTemplatesRouter);
|
|
app.use('/api/admin/categories', requireAdminGate, adminCategoriesRouter);
|
|
app.use('/api/admin/tags', requireAdminGate, adminTagsRouter);
|
|
app.use('/api/admin', requireAdminGate, adminRouter);
|
|
app.use('/api/customers/me/addresses', shippingAddressesRouter);
|
|
app.use('/api/customers', customersRouter);
|
|
app.use('/api/client-errors', clientErrorsRouter);
|
|
app.use('/', publicRouter);
|
|
|
|
if (process.env.NODE_ENV !== 'test') {
|
|
const staticDir = path.join(__dirname, '..', 'public');
|
|
app.use(express.static(staticDir));
|
|
app.get('*', (_req, res) => {
|
|
res.sendFile(path.join(staticDir, 'index.html'));
|
|
});
|
|
}
|
|
|
|
// Mounted last, so it sees errors from every route above. Without it, a route
|
|
// that hands an error to next() falls through to Express's default handler,
|
|
// and — worse — an async route that rejects never responds at all, leaving the
|
|
// client hanging. A hung request is indistinguishable from an empty catalogue
|
|
// in the UI, which is exactly how a schema mismatch once read as "the store
|
|
// has no items". Always answer.
|
|
//
|
|
// The four-argument signature is what marks this as error middleware; `next`
|
|
// is unused but must stay for Express to recognise it.
|
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
app.use((err: Error, _req: Request, res: Response, _next: NextFunction) => {
|
|
console.error(err);
|
|
if (res.headersSent) return;
|
|
res.status(500).json({ error: 'internal error' });
|
|
});
|
|
|
|
export default app;
|