@harshankur/viewcounter 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +115 -0
- package/LICENSE +21 -0
- package/README.md +797 -0
- package/allowed.sample.json +24 -0
- package/config/index.js +362 -0
- package/constants.js +229 -0
- package/db/DatabaseManager.js +704 -0
- package/db/schema.sql +71 -0
- package/dbInfo.sample.json +8 -0
- package/index.js +184 -0
- package/middleware/auth.js +194 -0
- package/middleware/security.js +109 -0
- package/middleware/validation.js +212 -0
- package/package.json +81 -0
- package/routes/analytics.js +456 -0
- package/scripts/setup.js +191 -0
- package/utils/appIdUtils.js +38 -0
- package/utils/errorUtils.js +157 -0
- package/utils/ipUtils.js +63 -0
- package/utils/logger.js +138 -0
- package/utils/privacyUtils.js +107 -0
- package/utils/referrerParser.js +137 -0
- package/utils/secretStore.js +57 -0
- package/utils/stringUtils.js +36 -0
- package/utils/userAgentParser.js +68 -0
package/db/schema.sql
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
-- Schema for view counter database
|
|
2
|
+
-- This file is used when DB_MODE=create to auto-initialize the database
|
|
3
|
+
|
|
4
|
+
-- Create database if it doesn't exist
|
|
5
|
+
CREATE DATABASE IF NOT EXISTS `{{DB_NAME}}` DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
|
6
|
+
|
|
7
|
+
USE `{{DB_NAME}}`;
|
|
8
|
+
|
|
9
|
+
-- Migration tracking table
|
|
10
|
+
CREATE TABLE IF NOT EXISTS `_migrations` (
|
|
11
|
+
`id` INT AUTO_INCREMENT PRIMARY KEY,
|
|
12
|
+
`version` VARCHAR(50) NOT NULL UNIQUE,
|
|
13
|
+
`applied_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
14
|
+
INDEX `idx_version` (`version`)
|
|
15
|
+
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
|
16
|
+
|
|
17
|
+
-- Template for app-specific view tables
|
|
18
|
+
-- Actual tables will be created dynamically based on allowed.json appIds
|
|
19
|
+
-- Table name format: {{APP_ID}}
|
|
20
|
+
CREATE TABLE IF NOT EXISTS `{{APP_ID}}` (
|
|
21
|
+
`id` BIGINT AUTO_INCREMENT PRIMARY KEY,
|
|
22
|
+
|
|
23
|
+
-- Identifiers (Anonymized)
|
|
24
|
+
`masked_ip` VARCHAR(45) NOT NULL,
|
|
25
|
+
`visitor_hash` VARCHAR(64) NOT NULL,
|
|
26
|
+
`country` VARCHAR(2) DEFAULT NULL,
|
|
27
|
+
`timestamp` DATETIME NOT NULL,
|
|
28
|
+
`devicesize` VARCHAR(20) NOT NULL,
|
|
29
|
+
|
|
30
|
+
-- Page tracking
|
|
31
|
+
`page_path` VARCHAR(500) DEFAULT NULL,
|
|
32
|
+
`page_title` VARCHAR(200) DEFAULT NULL,
|
|
33
|
+
|
|
34
|
+
-- Referrer tracking
|
|
35
|
+
`referrer` VARCHAR(500) DEFAULT NULL,
|
|
36
|
+
`referrer_domain` VARCHAR(200) DEFAULT NULL,
|
|
37
|
+
`source_type` VARCHAR(20) DEFAULT NULL, -- direct, search, social, email, campaign, referral
|
|
38
|
+
|
|
39
|
+
-- User agent parsing
|
|
40
|
+
`browser` VARCHAR(50) DEFAULT NULL,
|
|
41
|
+
`browser_version` VARCHAR(20) DEFAULT NULL,
|
|
42
|
+
`os` VARCHAR(50) DEFAULT NULL,
|
|
43
|
+
`os_version` VARCHAR(20) DEFAULT NULL,
|
|
44
|
+
`device_type` VARCHAR(20) DEFAULT NULL, -- mobile, tablet, desktop
|
|
45
|
+
|
|
46
|
+
-- Session tracking
|
|
47
|
+
`session_id` VARCHAR(64) DEFAULT NULL,
|
|
48
|
+
|
|
49
|
+
-- Custom events
|
|
50
|
+
`event_type` VARCHAR(50) DEFAULT 'pageview', -- pageview, click, submit, etc.
|
|
51
|
+
`event_data` JSON DEFAULT NULL,
|
|
52
|
+
|
|
53
|
+
-- Uniqueness tracking
|
|
54
|
+
`is_unique` TINYINT(1) DEFAULT 1, -- 1 if first view in window, 0 otherwise
|
|
55
|
+
|
|
56
|
+
-- Indexes
|
|
57
|
+
INDEX `idx_timestamp` (`timestamp`),
|
|
58
|
+
INDEX `idx_visitor_timestamp` (`visitor_hash`, `timestamp`),
|
|
59
|
+
INDEX `idx_masked_ip` (`masked_ip`),
|
|
60
|
+
INDEX `idx_country` (`country`),
|
|
61
|
+
INDEX `idx_devicesize` (`devicesize`),
|
|
62
|
+
INDEX `idx_page_path` (`page_path`(255)),
|
|
63
|
+
INDEX `idx_referrer_domain` (`referrer_domain`),
|
|
64
|
+
INDEX `idx_source_type` (`source_type`),
|
|
65
|
+
INDEX `idx_browser` (`browser`),
|
|
66
|
+
INDEX `idx_os` (`os`),
|
|
67
|
+
INDEX `idx_device_type` (`device_type`),
|
|
68
|
+
INDEX `idx_session_id` (`session_id`),
|
|
69
|
+
INDEX `idx_event_type` (`event_type`),
|
|
70
|
+
INDEX `idx_is_unique` (`is_unique`)
|
|
71
|
+
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
package/index.js
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
const express = require('express');
|
|
2
|
+
const cors = require('cors');
|
|
3
|
+
const helmet = require('helmet');
|
|
4
|
+
const rateLimit = require('express-rate-limit');
|
|
5
|
+
|
|
6
|
+
const { APP_NAME, HTTP_STATUS, PAYLOAD_LIMITS, SERVER } = require('./constants');
|
|
7
|
+
const config = require('./config');
|
|
8
|
+
const DatabaseManager = require('./db/DatabaseManager');
|
|
9
|
+
const logger = require('./utils/logger');
|
|
10
|
+
const { buildCorsOptions } = require('./middleware/security');
|
|
11
|
+
const { createAnalyticsRouter } = require('./routes/analytics');
|
|
12
|
+
|
|
13
|
+
logger.configure({ level: config.server.logLevel });
|
|
14
|
+
|
|
15
|
+
const dbManager = new DatabaseManager(config.dbInfo);
|
|
16
|
+
let isServerReady = false;
|
|
17
|
+
let httpServer = null;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Build the Express application.
|
|
21
|
+
*
|
|
22
|
+
* Kept separate from the bootstrap below so the same wiring can be exercised
|
|
23
|
+
* by tests and reused by embedders.
|
|
24
|
+
*/
|
|
25
|
+
function createApp() {
|
|
26
|
+
const app = express();
|
|
27
|
+
|
|
28
|
+
app.disable('x-powered-by');
|
|
29
|
+
app.use(helmet());
|
|
30
|
+
app.use(cors(buildCorsOptions(config.server.corsOrigins)));
|
|
31
|
+
|
|
32
|
+
// Bounded well below body-parser's 100kb default; /event is the only
|
|
33
|
+
// endpoint taking a body and its payload is small.
|
|
34
|
+
app.use(express.json({ limit: PAYLOAD_LIMITS.MAX_BODY_BYTES }));
|
|
35
|
+
|
|
36
|
+
// Never bare `true`. Trusting every hop lets any caller set
|
|
37
|
+
// X-Forwarded-For and be believed, which forges geolocation and rotates
|
|
38
|
+
// the rate-limiter key at will.
|
|
39
|
+
app.set('trust proxy', config.server.trustProxy);
|
|
40
|
+
|
|
41
|
+
app.use(rateLimit({
|
|
42
|
+
windowMs: config.server.rateLimit.windowMs,
|
|
43
|
+
limit: config.server.rateLimit.max,
|
|
44
|
+
message: { message: 'Too many requests, please try again later.' },
|
|
45
|
+
standardHeaders: true,
|
|
46
|
+
legacyHeaders: false,
|
|
47
|
+
}));
|
|
48
|
+
|
|
49
|
+
app.use(createAnalyticsRouter({
|
|
50
|
+
config,
|
|
51
|
+
dbManager,
|
|
52
|
+
isReady: () => isServerReady,
|
|
53
|
+
}));
|
|
54
|
+
|
|
55
|
+
// Malformed JSON and payloads over the limit surface here.
|
|
56
|
+
// eslint-disable-next-line no-unused-vars
|
|
57
|
+
app.use((err, req, res, next) => {
|
|
58
|
+
const status = err.status || err.statusCode || HTTP_STATUS.INTERNAL_SERVER_ERROR;
|
|
59
|
+
logger.warn(`Request rejected: ${err.message}`, { requestId: req.id });
|
|
60
|
+
res.status(status === HTTP_STATUS.INTERNAL_SERVER_ERROR ? HTTP_STATUS.BAD_REQUEST : status)
|
|
61
|
+
.json({ message: 'Malformed or oversized request' });
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
return app;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const app = createApp();
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Fold the database-backed app registry into the in-memory allowlist.
|
|
71
|
+
*
|
|
72
|
+
* Config-declared apps and registry-declared apps are unioned: the file stays
|
|
73
|
+
* authoritative for a fixed single-operator deployment, while the registry
|
|
74
|
+
* carries tenants provisioned at runtime. A registry that cannot be read is a
|
|
75
|
+
* warning rather than a startup failure — config-declared apps still work.
|
|
76
|
+
*/
|
|
77
|
+
async function mergeRegisteredApps() {
|
|
78
|
+
try {
|
|
79
|
+
const registered = await dbManager.listRegisteredApps();
|
|
80
|
+
const merged = new Set([...config.allowed.appId, ...registered]);
|
|
81
|
+
config.allowed.appId = [...merged];
|
|
82
|
+
|
|
83
|
+
const origins = await dbManager.loadRegisteredOrigins();
|
|
84
|
+
for (const [appId, list] of Object.entries(origins)) {
|
|
85
|
+
// allowed.json wins, so an operator can override a tenant's own
|
|
86
|
+
// origin list without editing the database.
|
|
87
|
+
if (!config.allowed.origins[appId]) config.allowed.origins[appId] = list;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (registered.length) {
|
|
91
|
+
logger.info(`Loaded ${registered.length} registered app(s) from the database`);
|
|
92
|
+
}
|
|
93
|
+
} catch (error) {
|
|
94
|
+
logger.warn(`Could not read the app registry: ${error.message}`);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Validate config, connect the database, and start listening.
|
|
100
|
+
*/
|
|
101
|
+
const initializeServer = async () => {
|
|
102
|
+
try {
|
|
103
|
+
config.validate();
|
|
104
|
+
await dbManager.initialize(config.allowed.appId);
|
|
105
|
+
|
|
106
|
+
// Merge dynamically registered tenants into the live allowlist, so
|
|
107
|
+
// apps provisioned through the admin API survive a restart without
|
|
108
|
+
// anyone editing allowed.json.
|
|
109
|
+
await mergeRegisteredApps();
|
|
110
|
+
|
|
111
|
+
isServerReady = true;
|
|
112
|
+
|
|
113
|
+
if (require.main === module) {
|
|
114
|
+
httpServer = app.listen(config.server.port, () => {
|
|
115
|
+
logger.info(`${APP_NAME} listening on port ${config.server.port}`);
|
|
116
|
+
logger.info(`Database mode: ${config.dbInfo.mode}`);
|
|
117
|
+
logger.info(`Allowed apps: ${config.allowed.appId.join(', ')}`);
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
} catch (error) {
|
|
121
|
+
logger.error(`Failed to start: ${error.message}`);
|
|
122
|
+
if (require.main === module) process.exit(1);
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
// Only when run directly. Requiring this module as a library — to mount
|
|
127
|
+
// createAnalyticsRouter into an existing app — must not validate config,
|
|
128
|
+
// connect to a database, or bind a port as a side effect of the import.
|
|
129
|
+
if (require.main === module) {
|
|
130
|
+
initializeServer();
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Graceful shutdown.
|
|
135
|
+
*
|
|
136
|
+
* Closes the HTTP listener first so in-flight requests can finish; previously
|
|
137
|
+
* the listener was never closed at all, so "graceful" covered only the
|
|
138
|
+
* database pool while active requests were severed.
|
|
139
|
+
*/
|
|
140
|
+
const shutdown = async (signal, exitCode = 0) => {
|
|
141
|
+
logger.info(`${signal} received, shutting down gracefully...`);
|
|
142
|
+
|
|
143
|
+
const forceExit = setTimeout(() => {
|
|
144
|
+
logger.error('Shutdown timed out, exiting');
|
|
145
|
+
process.exit(1);
|
|
146
|
+
}, SERVER.SHUTDOWN_TIMEOUT_MS);
|
|
147
|
+
forceExit.unref();
|
|
148
|
+
|
|
149
|
+
try {
|
|
150
|
+
if (httpServer) {
|
|
151
|
+
await new Promise((resolve) => httpServer.close(resolve));
|
|
152
|
+
}
|
|
153
|
+
await dbManager.close();
|
|
154
|
+
// Preserve the caller's code: a crash-triggered shutdown must not
|
|
155
|
+
// report success, or a process manager sees a clean exit and may
|
|
156
|
+
// decline to restart the service.
|
|
157
|
+
process.exit(exitCode);
|
|
158
|
+
} catch (error) {
|
|
159
|
+
logger.error(`Error during shutdown: ${error.message}`);
|
|
160
|
+
process.exit(1);
|
|
161
|
+
}
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
|
165
|
+
process.on('SIGINT', () => shutdown('SIGINT'));
|
|
166
|
+
|
|
167
|
+
// A rejected promise with no handler would otherwise terminate the process on
|
|
168
|
+
// modern Node with no log line explaining why.
|
|
169
|
+
process.on('unhandledRejection', (reason) => {
|
|
170
|
+
logger.error(`Unhandled promise rejection: ${reason instanceof Error ? reason.message : reason}`);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
process.on('uncaughtException', (error) => {
|
|
174
|
+
logger.error(`Uncaught exception: ${error.message}`);
|
|
175
|
+
logger.error(error.stack || '(no stack)');
|
|
176
|
+
shutdown('uncaughtException', 1);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
module.exports = app;
|
|
180
|
+
module.exports.createApp = createApp;
|
|
181
|
+
module.exports.createAnalyticsRouter = createAnalyticsRouter;
|
|
182
|
+
module.exports.DatabaseManager = DatabaseManager;
|
|
183
|
+
module.exports.dbManager = dbManager;
|
|
184
|
+
module.exports.initializeServer = initializeServer;
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Authentication and authorization for the analytics API.
|
|
3
|
+
*
|
|
4
|
+
* Two distinct steps, deliberately separate:
|
|
5
|
+
*
|
|
6
|
+
* requireReadApiKey — *authentication*: is this a key we issued?
|
|
7
|
+
* requireAppScope — *authorization*: may THIS key read THIS app?
|
|
8
|
+
*
|
|
9
|
+
* The second step is what makes the service multi-tenant. Without it a valid
|
|
10
|
+
* key read every tenant's analytics, because the appId allowlist only ever
|
|
11
|
+
* constrained which table was queried, never who was entitled to query it.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const crypto = require('crypto');
|
|
15
|
+
|
|
16
|
+
const { API_KEY_HEADER, HTTP_STATUS, SCOPE_ALL } = require('../constants');
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Constant-time string comparison (agent-instructions SECURITY.md §2).
|
|
20
|
+
*
|
|
21
|
+
* A plain `===` returns as soon as two bytes differ, so response timing leaks
|
|
22
|
+
* how many leading characters were correct. The mismatched-length path still
|
|
23
|
+
* performs an equal-cost dummy comparison so "wrong length" and "right length,
|
|
24
|
+
* wrong value" are not distinguishable by timing either.
|
|
25
|
+
*
|
|
26
|
+
* @param {string} a
|
|
27
|
+
* @param {string} b
|
|
28
|
+
* @returns {boolean}
|
|
29
|
+
*/
|
|
30
|
+
function safeEqual(a, b) {
|
|
31
|
+
const ab = Buffer.from(String(a));
|
|
32
|
+
const bb = Buffer.from(String(b));
|
|
33
|
+
if (ab.length !== bb.length) {
|
|
34
|
+
crypto.timingSafeEqual(ab, ab);
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
return crypto.timingSafeEqual(ab, bb);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Check a presented key against every configured key.
|
|
42
|
+
*
|
|
43
|
+
* Deliberately does not short-circuit on the first match: returning early
|
|
44
|
+
* would make the response time depend on the key's position in the list.
|
|
45
|
+
*
|
|
46
|
+
* @param {string} presented
|
|
47
|
+
* @param {string[]} configured
|
|
48
|
+
* @returns {boolean}
|
|
49
|
+
*/
|
|
50
|
+
function matchesAnyKey(presented, configured) {
|
|
51
|
+
let matched = false;
|
|
52
|
+
for (const key of configured) {
|
|
53
|
+
if (safeEqual(presented, key)) matched = true;
|
|
54
|
+
}
|
|
55
|
+
return matched;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Resolve a presented key to its scope, without short-circuiting.
|
|
60
|
+
*
|
|
61
|
+
* @param {string} presented
|
|
62
|
+
* @param {Record<string, string|string[]>} keyScopes key -> '*' | [appId]
|
|
63
|
+
* @returns {string|string[]|null} the scope, or null when no key matched
|
|
64
|
+
*/
|
|
65
|
+
function resolveScope(presented, keyScopes) {
|
|
66
|
+
let scope = null;
|
|
67
|
+
for (const [key, keyScope] of Object.entries(keyScopes)) {
|
|
68
|
+
if (safeEqual(presented, key)) scope = keyScope;
|
|
69
|
+
}
|
|
70
|
+
return scope;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Does a resolved scope permit this app?
|
|
75
|
+
* @param {string|string[]} scope
|
|
76
|
+
* @param {string} appId
|
|
77
|
+
* @returns {boolean}
|
|
78
|
+
*/
|
|
79
|
+
function scopeAllows(scope, appId) {
|
|
80
|
+
if (scope === SCOPE_ALL) return true;
|
|
81
|
+
return Array.isArray(scope) && scope.includes(appId);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Expand a scope into the concrete list of apps it can see.
|
|
86
|
+
* @param {string|string[]} scope
|
|
87
|
+
* @param {string[]} allApps
|
|
88
|
+
* @returns {string[]}
|
|
89
|
+
*/
|
|
90
|
+
function appsInScope(scope, allApps) {
|
|
91
|
+
if (scope === SCOPE_ALL) return [...allApps];
|
|
92
|
+
if (!Array.isArray(scope)) return [];
|
|
93
|
+
return allApps.filter((appId) => scope.includes(appId));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Authenticate a read request and attach its scope as `req.auth`.
|
|
98
|
+
*
|
|
99
|
+
* Fails closed: with no keys configured the read API is unavailable rather
|
|
100
|
+
* than unprotected, so a deployment that forgets to set them cannot silently
|
|
101
|
+
* serve another party's analytics to the internet.
|
|
102
|
+
*
|
|
103
|
+
* @param {{ readKeyScopes: Record<string, string|string[]> }} authConfig
|
|
104
|
+
* @returns {import('express').RequestHandler}
|
|
105
|
+
*/
|
|
106
|
+
function requireReadApiKey(authConfig) {
|
|
107
|
+
const keyScopes = authConfig?.readKeyScopes || {};
|
|
108
|
+
|
|
109
|
+
return (req, res, next) => {
|
|
110
|
+
if (Object.keys(keyScopes).length === 0) {
|
|
111
|
+
return res.status(HTTP_STATUS.SERVICE_UNAVAILABLE).json({
|
|
112
|
+
message: 'Read API is not configured on this server',
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const presented = req.get(API_KEY_HEADER);
|
|
117
|
+
const scope = presented ? resolveScope(presented, keyScopes) : null;
|
|
118
|
+
|
|
119
|
+
if (scope === null) {
|
|
120
|
+
return res.status(HTTP_STATUS.UNAUTHORIZED).json({
|
|
121
|
+
message: 'Missing or invalid API key',
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
req.auth = { scope };
|
|
126
|
+
return next();
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Authorize the requested `:appId` against the authenticated key's scope.
|
|
132
|
+
*
|
|
133
|
+
* Runs after requireReadApiKey. Returns 403 rather than 404 for an app that
|
|
134
|
+
* exists but is out of scope, and the same 403 for one that does not exist, so
|
|
135
|
+
* the response does not disclose which tenants are registered.
|
|
136
|
+
*
|
|
137
|
+
* @returns {import('express').RequestHandler}
|
|
138
|
+
*/
|
|
139
|
+
function requireAppScope() {
|
|
140
|
+
return (req, res, next) => {
|
|
141
|
+
const { appId } = req.params;
|
|
142
|
+
if (!appId) return next();
|
|
143
|
+
|
|
144
|
+
if (!req.auth || !scopeAllows(req.auth.scope, appId)) {
|
|
145
|
+
return res.status(HTTP_STATUS.FORBIDDEN).json({
|
|
146
|
+
message: 'This API key is not authorized for that appId',
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return next();
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Require an admin credential.
|
|
156
|
+
*
|
|
157
|
+
* A separate tier from read keys (SECURITY.md §3): provisioning apps is a
|
|
158
|
+
* different privilege from reading them, so leaking a tenant's read key must
|
|
159
|
+
* not confer it, and revoking one tier must not force rotating the other.
|
|
160
|
+
*
|
|
161
|
+
* @param {{ adminApiKeys: string[] }} authConfig
|
|
162
|
+
* @returns {import('express').RequestHandler}
|
|
163
|
+
*/
|
|
164
|
+
function requireAdminApiKey(authConfig) {
|
|
165
|
+
const keys = Array.isArray(authConfig?.adminApiKeys) ? authConfig.adminApiKeys : [];
|
|
166
|
+
|
|
167
|
+
return (req, res, next) => {
|
|
168
|
+
if (keys.length === 0) {
|
|
169
|
+
return res.status(HTTP_STATUS.SERVICE_UNAVAILABLE).json({
|
|
170
|
+
message: 'Admin API is not configured on this server',
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const presented = req.get(API_KEY_HEADER);
|
|
175
|
+
if (!presented || !matchesAnyKey(presented, keys)) {
|
|
176
|
+
return res.status(HTTP_STATUS.UNAUTHORIZED).json({
|
|
177
|
+
message: 'Missing or invalid admin key',
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return next();
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
module.exports = {
|
|
186
|
+
requireReadApiKey,
|
|
187
|
+
requireAppScope,
|
|
188
|
+
requireAdminApiKey,
|
|
189
|
+
safeEqual,
|
|
190
|
+
matchesAnyKey,
|
|
191
|
+
resolveScope,
|
|
192
|
+
scopeAllows,
|
|
193
|
+
appsInScope,
|
|
194
|
+
};
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Transport-level security middleware: CORS, origin binding, cache policy.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
const { HTTP_STATUS } = require('../constants');
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Build CORS options from an explicit allowlist.
|
|
9
|
+
*
|
|
10
|
+
* agent-instructions SECURITY.md §9: `cors()` with no options is never the
|
|
11
|
+
* default. The wildcard mattered more here than the usual "no credentials, so
|
|
12
|
+
* it's harmless" reasoning suggests, because the read endpoints served real
|
|
13
|
+
* data — `*` made them script-readable from any origin, not merely reachable.
|
|
14
|
+
*
|
|
15
|
+
* @param {string[]} allowedOrigins
|
|
16
|
+
* @returns {import('cors').CorsOptions}
|
|
17
|
+
*/
|
|
18
|
+
function buildCorsOptions(allowedOrigins) {
|
|
19
|
+
const allowlist = new Set(allowedOrigins);
|
|
20
|
+
|
|
21
|
+
return {
|
|
22
|
+
origin(origin, callback) {
|
|
23
|
+
// No Origin header: a same-origin request, a server-side caller, or
|
|
24
|
+
// curl. There is no browser to protect in that case.
|
|
25
|
+
if (!origin) return callback(null, true);
|
|
26
|
+
return callback(null, allowlist.has(origin));
|
|
27
|
+
},
|
|
28
|
+
methods: ['GET', 'POST'],
|
|
29
|
+
allowedHeaders: ['Content-Type', 'x-api-key'],
|
|
30
|
+
credentials: false,
|
|
31
|
+
maxAge: 600,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Extract the requesting origin, falling back to the referrer's origin.
|
|
37
|
+
* @returns {string|null}
|
|
38
|
+
*/
|
|
39
|
+
function requestOrigin(req) {
|
|
40
|
+
const origin = req.get('origin');
|
|
41
|
+
if (origin) return origin;
|
|
42
|
+
|
|
43
|
+
const referer = req.get('referer') || req.get('referrer');
|
|
44
|
+
if (!referer) return null;
|
|
45
|
+
|
|
46
|
+
try {
|
|
47
|
+
return new URL(referer).origin;
|
|
48
|
+
} catch {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Bind writes for an appId to the site origins registered for it.
|
|
55
|
+
*
|
|
56
|
+
* Without this, any page anywhere could embed
|
|
57
|
+
* `<img src="…/registerView?appId=victim&deviceSize=large">` and inject
|
|
58
|
+
* traffic into someone else's analytics. Being a GET, no CORS preflight is
|
|
59
|
+
* involved, so the browser's same-origin policy never came into it.
|
|
60
|
+
*
|
|
61
|
+
* Enforced only for appIds that declare an `origins` list in allowed.json; an
|
|
62
|
+
* appId with no list keeps accepting writes from anywhere, so adding this
|
|
63
|
+
* cannot silently break a running deployment. Startup validation warns about
|
|
64
|
+
* every appId still in that state.
|
|
65
|
+
*
|
|
66
|
+
* @param {{ origins: Record<string, string[]> }} allowed
|
|
67
|
+
* @returns {import('express').RequestHandler}
|
|
68
|
+
*/
|
|
69
|
+
function requireRegisteredOrigin(allowed) {
|
|
70
|
+
const origins = allowed?.origins || {};
|
|
71
|
+
|
|
72
|
+
return (req, res, next) => {
|
|
73
|
+
const appId = req.query.appId || req.body?.appId;
|
|
74
|
+
const registered = origins[appId];
|
|
75
|
+
|
|
76
|
+
if (!Array.isArray(registered) || registered.length === 0) {
|
|
77
|
+
return next();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const origin = requestOrigin(req);
|
|
81
|
+
if (origin && registered.includes(origin)) {
|
|
82
|
+
return next();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return res.status(HTTP_STATUS.FORBIDDEN).json({
|
|
86
|
+
message: 'Request origin is not registered for this appId',
|
|
87
|
+
});
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Mark a response as uncacheable.
|
|
93
|
+
*
|
|
94
|
+
* Analytics responses are per-caller and must never be served from a shared
|
|
95
|
+
* proxy cache to a different caller. Neither express nor helmet sets this.
|
|
96
|
+
*
|
|
97
|
+
* @type {import('express').RequestHandler}
|
|
98
|
+
*/
|
|
99
|
+
function noStore(req, res, next) {
|
|
100
|
+
res.set('Cache-Control', 'no-store, max-age=0');
|
|
101
|
+
next();
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
module.exports = {
|
|
105
|
+
buildCorsOptions,
|
|
106
|
+
requireRegisteredOrigin,
|
|
107
|
+
requestOrigin,
|
|
108
|
+
noStore,
|
|
109
|
+
};
|