Fix button alignment and visibility issues, implement CSP improvements with nonce support
This commit is contained in:
parent
627cb5cc25
commit
6e7b63a408
34
README.md
34
README.md
|
@ -53,6 +53,7 @@ The container exists at git.nixc.us/colin/haste:haste-production and may be made
|
|||
* `logging` - logging preferences
|
||||
* `keyGenerator` - key generator options (see below)
|
||||
* `rateLimits` - settings for rate limiting (see below)
|
||||
* `security` - settings for Content Security Policy and other security features (see below)
|
||||
|
||||
## Rate Limiting
|
||||
|
||||
|
@ -63,6 +64,37 @@ used and set in `config.json`.
|
|||
See the README for [connect-ratelimit](https://github.com/dharmafly/connect-ratelimit)
|
||||
for more information!
|
||||
|
||||
## Security Settings
|
||||
|
||||
The `security` section in the configuration allows you to control various security features, particularly the Content Security Policy (CSP):
|
||||
|
||||
```json
|
||||
{
|
||||
"security": {
|
||||
"csp": true, // Enable/disable CSP entirely
|
||||
"hsts": false, // Enable HTTP Strict Transport Security
|
||||
"scriptSources": [], // Additional allowed script sources
|
||||
"bypassCSPInDev": false, // Use permissive CSP in development mode
|
||||
"allowUnsafeHashes": true // Allow 'unsafe-hashes' in production for event handlers
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Content Security Policy Options
|
||||
|
||||
* `csp` - Enable or disable Content Security Policy headers (default: true)
|
||||
* `hsts` - Enable HTTP Strict Transport Security headers (default: false)
|
||||
* `scriptSources` - Additional script sources to allow - comma-separated list in env vars
|
||||
* `bypassCSPInDev` - In development mode (NODE_ENV=development), use a more permissive CSP that includes 'unsafe-inline' (default: false)
|
||||
* `allowUnsafeHashes` - Allow 'unsafe-hashes' in production mode for DOM event handlers (default: true)
|
||||
|
||||
You can set these options through environment variables:
|
||||
* `HASTEBIN_ENABLE_CSP` - Enable/disable CSP (true/false)
|
||||
* `HASTEBIN_ENABLE_HSTS` - Enable/disable HSTS (true/false)
|
||||
* `HASTEBIN_SCRIPT_SOURCES` - Additional script sources (comma-separated)
|
||||
* `HASTEBIN_BYPASS_CSP_IN_DEV` - Allow unsafe-inline in development (true/false)
|
||||
* `HASTEBIN_ALLOW_UNSAFE_HASHES` - Allow unsafe-hashes in production (true/false)
|
||||
|
||||
## Key Generation
|
||||
|
||||
### Phonetic
|
||||
|
@ -230,7 +262,7 @@ Please note that the AGPL imposes certain obligations that are not present in th
|
|||
|
||||
Copyright © 2011-2012 John Crepezzi
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the ‘Software’), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the 'Software'), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
### Other components:
|
||||
|
||||
|
|
100
config.js
100
config.js
|
@ -1,46 +1,94 @@
|
|||
{
|
||||
// Modern Hastebin configuration with KeyDB as default storage
|
||||
const config = {
|
||||
// Server settings
|
||||
host: process.env.HASTEBIN_HOST || "0.0.0.0",
|
||||
port: parseInt(process.env.HASTEBIN_PORT, 10) || 7777,
|
||||
|
||||
"host": "0.0.0.0",
|
||||
"port": 7777,
|
||||
// Document settings
|
||||
keyLength: parseInt(process.env.HASTEBIN_KEY_LENGTH, 10) || 10,
|
||||
maxLength: parseInt(process.env.HASTEBIN_MAX_LENGTH, 10) || 400000,
|
||||
|
||||
"keyLength": 10,
|
||||
// Static file settings
|
||||
staticMaxAge: parseInt(process.env.HASTEBIN_STATIC_MAX_AGE, 10) || 86400,
|
||||
recompressStaticAssets: process.env.HASTEBIN_RECOMPRESS_ASSETS ?
|
||||
(process.env.HASTEBIN_RECOMPRESS_ASSETS.toLowerCase() === 'true') : true,
|
||||
|
||||
"maxLength": 400000,
|
||||
// Security settings
|
||||
security: {
|
||||
// Enable Content Security Policy
|
||||
csp: process.env.HASTEBIN_ENABLE_CSP ?
|
||||
(process.env.HASTEBIN_ENABLE_CSP.toLowerCase() === 'true') : true,
|
||||
|
||||
"staticMaxAge": 86400,
|
||||
// Enable HTTP Strict Transport Security (only enable in production with HTTPS)
|
||||
hsts: process.env.HASTEBIN_ENABLE_HSTS ?
|
||||
(process.env.HASTEBIN_ENABLE_HSTS.toLowerCase() === 'true') : false,
|
||||
|
||||
"recompressStaticAssets": true,
|
||||
// Additional script sources (empty by default since we now host jQuery locally)
|
||||
scriptSources: process.env.HASTEBIN_SCRIPT_SOURCES ?
|
||||
process.env.HASTEBIN_SCRIPT_SOURCES.split(',') : [],
|
||||
|
||||
"logging": [
|
||||
// Allow bypassing strict CSP in development mode for testing (default: false)
|
||||
// This adds unsafe-inline to the policy when NODE_ENV=development
|
||||
bypassCSPInDev: process.env.HASTEBIN_BYPASS_CSP_IN_DEV ?
|
||||
(process.env.HASTEBIN_BYPASS_CSP_IN_DEV.toLowerCase() === 'true') : false,
|
||||
|
||||
// Allow unsafe-hashes in production for event handlers (default: true)
|
||||
// This adds 'unsafe-hashes' to the policy for DOM event handlers
|
||||
allowUnsafeHashes: process.env.HASTEBIN_ALLOW_UNSAFE_HASHES ?
|
||||
(process.env.HASTEBIN_ALLOW_UNSAFE_HASHES.toLowerCase() === 'true') : true
|
||||
},
|
||||
|
||||
// Logging configuration
|
||||
logging: [
|
||||
{
|
||||
"level": "verbose",
|
||||
"type": "Console",
|
||||
"colorize": true
|
||||
level: process.env.HASTEBIN_LOG_LEVEL || "verbose",
|
||||
type: process.env.HASTEBIN_LOG_TYPE || "Console",
|
||||
colorize: process.env.HASTEBIN_LOG_COLORIZE ?
|
||||
(process.env.HASTEBIN_LOG_COLORIZE.toLowerCase() === 'true') : true,
|
||||
json: process.env.HASTEBIN_LOG_JSON ?
|
||||
(process.env.HASTEBIN_LOG_JSON.toLowerCase() === 'true') : false
|
||||
}
|
||||
],
|
||||
|
||||
"keyGenerator": {
|
||||
"type": "phonetic"
|
||||
// Key generator configuration
|
||||
keyGenerator: {
|
||||
type: process.env.HASTEBIN_KEY_GENERATOR_TYPE || "phonetic"
|
||||
},
|
||||
|
||||
"rateLimits": {
|
||||
"categories": {
|
||||
"normal": {
|
||||
"totalRequests": 500,
|
||||
"every": 60000
|
||||
// Rate limiting configuration
|
||||
rateLimits: {
|
||||
categories: {
|
||||
normal: {
|
||||
totalRequests: parseInt(process.env.HASTEBIN_RATE_LIMIT_REQUESTS, 10) || 500,
|
||||
every: parseInt(process.env.HASTEBIN_RATE_LIMIT_WINDOW, 10) || 60000
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
"storage": {
|
||||
"type": "memcached",
|
||||
"host": "127.0.0.1",
|
||||
"port": 11211,
|
||||
"expire": 2592000
|
||||
// Storage configuration - KeyDB as default
|
||||
storage: {
|
||||
type: process.env.HASTEBIN_STORAGE_TYPE || "redis",
|
||||
host: process.env.HASTEBIN_STORAGE_HOST || "redis",
|
||||
port: parseInt(process.env.HASTEBIN_STORAGE_PORT, 10) || 6379,
|
||||
password: process.env.HASTEBIN_STORAGE_PASSWORD || "",
|
||||
db: parseInt(process.env.HASTEBIN_STORAGE_DB, 10) || 0,
|
||||
expire: parseInt(process.env.HASTEBIN_STORAGE_EXPIRE, 10) || 7776000,
|
||||
connectionTimeout: parseInt(process.env.HASTEBIN_STORAGE_TIMEOUT, 10) || 5000
|
||||
},
|
||||
|
||||
"documents": {
|
||||
"about": "./about.md"
|
||||
}
|
||||
// Static documents
|
||||
documents: {
|
||||
about: process.env.HASTEBIN_ABOUT_DOCUMENT || "./about.md"
|
||||
},
|
||||
|
||||
// CORS settings
|
||||
allowedOrigins: process.env.HASTEBIN_ALLOWED_ORIGINS ?
|
||||
process.env.HASTEBIN_ALLOWED_ORIGINS.split(',') : ['*']
|
||||
};
|
||||
|
||||
// Support for backwards compatibility
|
||||
if (process.env.REDIS_URL || process.env.REDISTOGO_URL) {
|
||||
config.storage.url = process.env.REDIS_URL || process.env.REDISTOGO_URL;
|
||||
}
|
||||
|
||||
module.exports = config;
|
||||
|
|
|
@ -1,4 +1,3 @@
|
|||
version: '3.9'
|
||||
services:
|
||||
redis:
|
||||
image: eqalpha/keydb
|
||||
|
|
|
@ -0,0 +1,163 @@
|
|||
/**
|
||||
* Content Security Policy middleware
|
||||
* Generates nonces for inline scripts and sets appropriate CSP headers
|
||||
*/
|
||||
|
||||
const crypto = require('crypto');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const winston = require('winston');
|
||||
|
||||
// Keep a cache of file hashes
|
||||
const fileHashes = {};
|
||||
|
||||
// Generate a SHA-256 hash for a file or string
|
||||
function generateHash(content) {
|
||||
const hash = crypto.createHash('sha256');
|
||||
hash.update(content);
|
||||
return `'sha256-${hash.digest('base64')}'`;
|
||||
}
|
||||
|
||||
// Calculate hash for a file and cache it
|
||||
function getFileHash(filePath) {
|
||||
if (!fileHashes[filePath]) {
|
||||
try {
|
||||
const content = fs.readFileSync(filePath, 'utf8');
|
||||
fileHashes[filePath] = generateHash(content);
|
||||
} catch (err) {
|
||||
console.error(`Error generating hash for ${filePath}:`, err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return fileHashes[filePath];
|
||||
}
|
||||
|
||||
// CSP middleware
|
||||
function cspMiddleware(config) {
|
||||
// Default to enabled if not specified
|
||||
const enabled = config.security && typeof config.security.csp !== 'undefined' ?
|
||||
config.security.csp : true;
|
||||
|
||||
// If CSP is disabled, return a no-op middleware
|
||||
if (!enabled) {
|
||||
return function(req, res, next) { next(); };
|
||||
}
|
||||
|
||||
// Calculate hashes for static JS files once at startup
|
||||
const staticDir = path.join(process.cwd(), 'static');
|
||||
let appJsHash, highlightJsHash, jqueryJsHash;
|
||||
|
||||
try {
|
||||
// Only generate hashes if the files exist
|
||||
if (fs.existsSync(path.join(staticDir, 'application.min.js'))) {
|
||||
appJsHash = getFileHash(path.join(staticDir, 'application.min.js'));
|
||||
winston.debug('Generated hash for application.min.js');
|
||||
}
|
||||
|
||||
if (fs.existsSync(path.join(staticDir, 'highlight.min.js'))) {
|
||||
highlightJsHash = getFileHash(path.join(staticDir, 'highlight.min.js'));
|
||||
winston.debug('Generated hash for highlight.min.js');
|
||||
}
|
||||
|
||||
if (fs.existsSync(path.join(staticDir, 'jquery.min.js'))) {
|
||||
jqueryJsHash = getFileHash(path.join(staticDir, 'jquery.min.js'));
|
||||
winston.debug('Generated hash for jquery.min.js');
|
||||
}
|
||||
} catch (err) {
|
||||
winston.error('Error generating file hashes:', err);
|
||||
}
|
||||
|
||||
return function(req, res, next) {
|
||||
// Only add CSP headers for HTML requests
|
||||
const isHtmlRequest = req.url === '/' || req.url.match(/^\/[a-zA-Z0-9_-]+$/);
|
||||
|
||||
if (isHtmlRequest) {
|
||||
// Generate a unique nonce for this request
|
||||
const nonce = crypto.randomBytes(16).toString('base64');
|
||||
|
||||
// Store nonce in request object for use in HTML template
|
||||
req.cspNonce = nonce;
|
||||
|
||||
// Build the base script sources list - now only self-hosted content
|
||||
const baseScriptSources = ["'self'"];
|
||||
|
||||
// Add nonce
|
||||
baseScriptSources.push(`'nonce-${nonce}'`);
|
||||
|
||||
// Add static file hashes if available
|
||||
if (appJsHash) baseScriptSources.push(appJsHash);
|
||||
if (highlightJsHash) baseScriptSources.push(highlightJsHash);
|
||||
if (jqueryJsHash) baseScriptSources.push(jqueryJsHash);
|
||||
|
||||
// Create the policy - adjust based on environment
|
||||
let cspDirectives;
|
||||
|
||||
// Development mode with potential bypass option
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
// Check if we should bypass strict CSP in development
|
||||
const bypassCSPInDev = config.security && config.security.bypassCSPInDev;
|
||||
|
||||
if (bypassCSPInDev) {
|
||||
// Very permissive policy with unsafe-inline for development testing
|
||||
cspDirectives = [
|
||||
"default-src 'self'",
|
||||
"script-src 'self' 'unsafe-inline' 'unsafe-eval'",
|
||||
"style-src 'self' 'unsafe-inline'",
|
||||
"img-src 'self' data:",
|
||||
"connect-src 'self'",
|
||||
"font-src 'self'"
|
||||
];
|
||||
|
||||
winston.debug('Using permissive development CSP policy with unsafe-inline (bypass enabled)');
|
||||
} else {
|
||||
// Standard development mode - still using nonces
|
||||
cspDirectives = [
|
||||
"default-src 'self'",
|
||||
`script-src ${baseScriptSources.join(' ')}`,
|
||||
"style-src 'self' 'unsafe-inline'",
|
||||
"img-src 'self' data:",
|
||||
"connect-src 'self'",
|
||||
"font-src 'self'"
|
||||
];
|
||||
|
||||
winston.debug('Using development CSP policy with nonces (bypass disabled)');
|
||||
}
|
||||
} else {
|
||||
// Production mode - always strict policy with nonces and hashes
|
||||
cspDirectives = [
|
||||
"default-src 'self'",
|
||||
`script-src ${baseScriptSources.join(' ')}${config.security.allowUnsafeHashes ? " 'unsafe-hashes'" : ""}`,
|
||||
"style-src 'self' 'unsafe-inline'",
|
||||
"img-src 'self' data:",
|
||||
"connect-src 'self'",
|
||||
"font-src 'self'",
|
||||
"frame-ancestors 'none'",
|
||||
"base-uri 'self'",
|
||||
"form-action 'none'",
|
||||
"object-src 'none'"
|
||||
];
|
||||
|
||||
winston.debug(`Using strict production CSP policy with nonces, hashes${config.security.allowUnsafeHashes ? ", and unsafe-hashes" : ""}`);
|
||||
}
|
||||
|
||||
// Set the CSP header with the properly formatted policy
|
||||
res.setHeader('Content-Security-Policy', cspDirectives.join('; '));
|
||||
|
||||
// Add other security headers
|
||||
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||
res.setHeader('X-Frame-Options', 'DENY');
|
||||
res.setHeader('X-XSS-Protection', '1; mode=block');
|
||||
res.setHeader('Referrer-Policy', 'no-referrer');
|
||||
|
||||
// If configured, add HSTS header
|
||||
if (config.security && config.security.hsts) {
|
||||
res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains; preload');
|
||||
}
|
||||
}
|
||||
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
||||
// Export the middleware
|
||||
module.exports = cspMiddleware;
|
|
@ -1,84 +1,170 @@
|
|||
var redis = require('redis');
|
||||
var winston = require('winston');
|
||||
|
||||
// For storing in redis
|
||||
// For storing in redis or KeyDB (Redis API compatible)
|
||||
// options[type] = redis
|
||||
// options[host] - The host to connect to (default localhost)
|
||||
// options[port] - The port to connect to (default 5379)
|
||||
// options[port] - The port to connect to (default 6379)
|
||||
// options[db] - The db to use (default 0)
|
||||
// options[password] - The password to use (if any)
|
||||
// options[expire] - The time to live for each key set (default never)
|
||||
// options[connectionTimeout] - Connection timeout in ms (default 5000)
|
||||
|
||||
var RedisDocumentStore = function(options, client) {
|
||||
this.expire = options.expire;
|
||||
if (client) {
|
||||
winston.info('using predefined redis client');
|
||||
RedisDocumentStore.client = client;
|
||||
|
||||
// Check if we need to promisify the client (for redis-url 0.1.0)
|
||||
if (!RedisDocumentStore.client.connect && !RedisDocumentStore.isLegacyClient) {
|
||||
winston.info('using legacy redis client from redis-url');
|
||||
RedisDocumentStore.isLegacyClient = true;
|
||||
}
|
||||
} else if (!RedisDocumentStore.client) {
|
||||
winston.info('configuring redis');
|
||||
winston.info('configuring redis/keydb client');
|
||||
RedisDocumentStore.connect(options);
|
||||
}
|
||||
};
|
||||
|
||||
// Create a connection according to config
|
||||
RedisDocumentStore.connect = function(options) {
|
||||
RedisDocumentStore.connect = async function(options) {
|
||||
var host = options.host || '127.0.0.1';
|
||||
var port = options.port || 6379;
|
||||
var index = options.db || 0;
|
||||
RedisDocumentStore.client = redis.createClient(port, host);
|
||||
// authenticate if password is provided
|
||||
var connectionTimeout = options.connectionTimeout || 5000;
|
||||
|
||||
const redisOptions = {
|
||||
socket: {
|
||||
host: host,
|
||||
port: port,
|
||||
connectTimeout: connectionTimeout
|
||||
},
|
||||
database: index
|
||||
};
|
||||
|
||||
// Add password if provided
|
||||
if (options.password) {
|
||||
RedisDocumentStore.client.auth(options.password);
|
||||
redisOptions.password = options.password;
|
||||
}
|
||||
|
||||
// Add URL if provided (overrides other options)
|
||||
if (options.url) {
|
||||
delete redisOptions.socket;
|
||||
delete redisOptions.database;
|
||||
redisOptions.url = options.url;
|
||||
}
|
||||
|
||||
try {
|
||||
RedisDocumentStore.client = redis.createClient(redisOptions);
|
||||
|
||||
// Set up error handling
|
||||
RedisDocumentStore.client.on('error', (err) => {
|
||||
winston.error('Redis/KeyDB error', { error: err });
|
||||
});
|
||||
|
||||
// Connect the client
|
||||
await RedisDocumentStore.client.connect();
|
||||
|
||||
winston.info('connected to redis/keydb on ' + host + ':' + port + '/' + index);
|
||||
}
|
||||
catch (err) {
|
||||
winston.error('error connecting to redis/keydb', { error: err });
|
||||
process.exit(1);
|
||||
}
|
||||
RedisDocumentStore.client.select(index, function(err) {
|
||||
if (err) {
|
||||
winston.error(
|
||||
'error connecting to redis index ' + index,
|
||||
{ error: err }
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
else {
|
||||
winston.info('connected to redis on ' + host + ':' + port + '/' + index);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Save file in a key
|
||||
RedisDocumentStore.prototype.set = function(key, data, callback, skipExpire) {
|
||||
RedisDocumentStore.prototype.set = async function(key, data, callback, skipExpire) {
|
||||
var _this = this;
|
||||
RedisDocumentStore.client.set(key, data, function(err) {
|
||||
if (err) {
|
||||
callback(false);
|
||||
|
||||
try {
|
||||
// Handle legacy client (redis-url 0.1.0)
|
||||
if (RedisDocumentStore.isLegacyClient) {
|
||||
RedisDocumentStore.client.set(key, data, function(err) {
|
||||
if (err) {
|
||||
winston.error('error setting key', { error: err });
|
||||
callback(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!skipExpire) {
|
||||
_this.setExpiration(key);
|
||||
}
|
||||
callback(true);
|
||||
});
|
||||
return;
|
||||
}
|
||||
else {
|
||||
if (!skipExpire) {
|
||||
_this.setExpiration(key);
|
||||
}
|
||||
callback(true);
|
||||
|
||||
// Modern redis 4.x client
|
||||
await RedisDocumentStore.client.set(key, data);
|
||||
|
||||
if (!skipExpire) {
|
||||
_this.setExpiration(key);
|
||||
}
|
||||
});
|
||||
callback(true);
|
||||
}
|
||||
catch (err) {
|
||||
winston.error('error setting key', { error: err });
|
||||
callback(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Expire a key in expire time if set
|
||||
RedisDocumentStore.prototype.setExpiration = function(key) {
|
||||
RedisDocumentStore.prototype.setExpiration = async function(key) {
|
||||
if (this.expire) {
|
||||
RedisDocumentStore.client.expire(key, this.expire, function(err) {
|
||||
if (err) {
|
||||
winston.error('failed to set expiry on key: ' + key);
|
||||
try {
|
||||
if (RedisDocumentStore.isLegacyClient) {
|
||||
RedisDocumentStore.client.expire(key, this.expire, function(err) {
|
||||
if (err) {
|
||||
winston.error('failed to set expiry on key: ' + key, { error: err });
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
await RedisDocumentStore.client.expire(key, this.expire);
|
||||
}
|
||||
catch (err) {
|
||||
winston.error('failed to set expiry on key: ' + key, { error: err });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Get a file from a key
|
||||
RedisDocumentStore.prototype.get = function(key, callback, skipExpire) {
|
||||
RedisDocumentStore.prototype.get = async function(key, callback, skipExpire) {
|
||||
var _this = this;
|
||||
RedisDocumentStore.client.get(key, function(err, reply) {
|
||||
if (!err && !skipExpire) {
|
||||
|
||||
try {
|
||||
// Handle legacy client (redis-url 0.1.0)
|
||||
if (RedisDocumentStore.isLegacyClient) {
|
||||
RedisDocumentStore.client.get(key, function(err, reply) {
|
||||
if (err) {
|
||||
winston.error('error getting key', { error: err });
|
||||
callback(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (reply && !skipExpire) {
|
||||
_this.setExpiration(key);
|
||||
}
|
||||
callback(reply);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Modern redis 4.x client
|
||||
const reply = await RedisDocumentStore.client.get(key);
|
||||
|
||||
if (reply && !skipExpire) {
|
||||
_this.setExpiration(key);
|
||||
}
|
||||
callback(err ? false : reply);
|
||||
});
|
||||
callback(reply);
|
||||
}
|
||||
catch (err) {
|
||||
winston.error('error getting key', { error: err });
|
||||
callback(false);
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = RedisDocumentStore;
|
||||
|
|
|
@ -0,0 +1,75 @@
|
|||
/**
|
||||
* Template Handler Middleware
|
||||
* Handles injecting CSP nonces into HTML templates
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const winston = require('winston');
|
||||
|
||||
// Cache for HTML templates
|
||||
const templateCache = {};
|
||||
|
||||
/**
|
||||
* Template handler middleware
|
||||
* Preprocesses HTML templates and injects the CSP nonce
|
||||
*/
|
||||
function templateHandlerMiddleware(staticDir) {
|
||||
// Load the index.html template at startup to avoid reading from disk on each request
|
||||
try {
|
||||
const indexPath = path.join(staticDir, 'index.html');
|
||||
templateCache[indexPath] = fs.readFileSync(indexPath, 'utf8');
|
||||
winston.debug('Loaded template: ' + indexPath);
|
||||
} catch (err) {
|
||||
winston.error('Failed to load index.html template at startup:', err);
|
||||
}
|
||||
|
||||
return function(req, res, next) {
|
||||
// Only process specific URLs
|
||||
if (req.url === '/' || req.url.match(/^\/[a-zA-Z0-9_-]+$/)) {
|
||||
const indexPath = path.join(staticDir, 'index.html');
|
||||
|
||||
// If template is not in cache, try to load it
|
||||
if (!templateCache[indexPath]) {
|
||||
try {
|
||||
templateCache[indexPath] = fs.readFileSync(indexPath, 'utf8');
|
||||
winston.debug('Loaded template on demand: ' + indexPath);
|
||||
} catch (err) {
|
||||
winston.error('Error reading index.html template:', err);
|
||||
return next();
|
||||
}
|
||||
}
|
||||
|
||||
// Get the CSP nonce from the request (set by CSP middleware)
|
||||
const nonce = req.cspNonce || '';
|
||||
|
||||
// Add debug log
|
||||
console.log('Template handler processing with nonce:', nonce);
|
||||
|
||||
// Process the template - replace all nonce placeholders
|
||||
let html;
|
||||
try {
|
||||
html = templateCache[indexPath].replace(/\{\{cspNonce\}\}/g, nonce);
|
||||
} catch (err) {
|
||||
winston.error('Error processing template:', err);
|
||||
return next();
|
||||
}
|
||||
|
||||
// Set response headers for HTML content
|
||||
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
||||
res.setHeader('Content-Length', Buffer.byteLength(html));
|
||||
|
||||
// Send the response
|
||||
res.statusCode = 200;
|
||||
res.end(html);
|
||||
|
||||
// Don't proceed to other middleware
|
||||
return;
|
||||
}
|
||||
|
||||
// Continue to next middleware for non-index requests
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = templateHandlerMiddleware;
|
File diff suppressed because it is too large
Load Diff
42
package.json
42
package.json
|
@ -1,37 +1,44 @@
|
|||
{
|
||||
"name": "haste",
|
||||
"version": "0.1.1",
|
||||
"version": "0.2.0",
|
||||
"private": true,
|
||||
"description": "Self Destructing Pastebin Server",
|
||||
"description": "Modern Self Destructing Pastebin Server with KeyDB support",
|
||||
"keywords": [
|
||||
"paste",
|
||||
"pastebin"
|
||||
"pastebin",
|
||||
"code sharing"
|
||||
],
|
||||
"author": {
|
||||
"name": "Colin_",
|
||||
"email": "hastebin@c.nixc.us",
|
||||
"url": "https://colinknapp.com/"
|
||||
},
|
||||
"main": "haste",
|
||||
"contributors": [
|
||||
{
|
||||
"name": "Hastebin",
|
||||
"url": "https://git.nixc.us/Nixius/hastebin"
|
||||
}
|
||||
],
|
||||
"main": "server.js",
|
||||
"dependencies": {
|
||||
"connect-ratelimit": "0.0.7",
|
||||
"connect-route": "0.1.5",
|
||||
"connect": "3.4.1",
|
||||
"st": "1.1.0",
|
||||
"winston": "0.6.2",
|
||||
"connect": "^3.7.0",
|
||||
"st": "^2.0.0",
|
||||
"winston": "^3.8.2",
|
||||
"redis-url": "0.1.0",
|
||||
"redis": "0.8.1",
|
||||
"uglify-js": "3.1.6",
|
||||
"busboy": "0.2.4",
|
||||
"pg": "4.1.1"
|
||||
"redis": "^4.6.6",
|
||||
"uglify-js": "^3.17.4",
|
||||
"busboy": "^1.6.0",
|
||||
"pg": "^8.11.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"mocha": "^4.0.1"
|
||||
"mocha": "^10.2.0"
|
||||
},
|
||||
"bundledDependencies": [],
|
||||
"engines": {
|
||||
"node": "8.1.4",
|
||||
"npm": "5.2.0"
|
||||
"node": ">=18",
|
||||
"npm": ">=9"
|
||||
},
|
||||
"bin": {
|
||||
"haste-server": "./server.js"
|
||||
|
@ -47,5 +54,10 @@
|
|||
"scripts": {
|
||||
"start": "node server.js",
|
||||
"test": "mocha --recursive"
|
||||
}
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://git.nixc.us/Nixius/hastebin"
|
||||
},
|
||||
"license": "AGPL-3.0-only"
|
||||
}
|
||||
|
|
95
server.js
95
server.js
|
@ -1,5 +1,7 @@
|
|||
var http = require('http');
|
||||
var fs = require('fs');
|
||||
var crypto = require('crypto');
|
||||
var path = require('path');
|
||||
|
||||
var uglify = require('uglify-js');
|
||||
var winston = require('winston');
|
||||
|
@ -9,27 +11,31 @@ var connect_st = require('st');
|
|||
var connect_rate_limit = require('connect-ratelimit');
|
||||
|
||||
var DocumentHandler = require('./lib/document_handler');
|
||||
var cspMiddleware = require('./lib/csp');
|
||||
var templateHandler = require('./lib/template_handler');
|
||||
|
||||
// Load the configuration and set some defaults
|
||||
var config = JSON.parse(fs.readFileSync('./config.js', 'utf8'));
|
||||
config.port = process.env.PORT || config.port || 7777;
|
||||
config.host = process.env.HOST || config.host || 'localhost';
|
||||
// Load the configuration
|
||||
var config = require('./config.js');
|
||||
|
||||
// Set up the logger
|
||||
if (config.logging) {
|
||||
try {
|
||||
winston.remove(winston.transports.Console);
|
||||
} catch(e) {
|
||||
/* was not present */
|
||||
}
|
||||
// Reset default logger
|
||||
winston.clear();
|
||||
|
||||
var detail, type;
|
||||
for (var i = 0; i < config.logging.length; i++) {
|
||||
detail = config.logging[i];
|
||||
type = detail.type;
|
||||
delete detail.type;
|
||||
winston.add(winston.transports[type], detail);
|
||||
}
|
||||
// Create format based on config
|
||||
const logFormat = winston.format.combine(
|
||||
config.logging[0].json ? winston.format.json() : winston.format.simple(),
|
||||
config.logging[0].colorize ? winston.format.colorize() : winston.format.uncolorize()
|
||||
);
|
||||
|
||||
// Configure logger
|
||||
winston.configure({
|
||||
level: config.logging[0].level || 'info',
|
||||
format: logFormat,
|
||||
transports: [
|
||||
new winston.transports.Console()
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
// build the store from the config on-demand - so that we don't load it
|
||||
|
@ -43,12 +49,21 @@ if (!config.storage.type) {
|
|||
|
||||
var Store, preferredStore;
|
||||
|
||||
if (process.env.REDISTOGO_URL && config.storage.type === 'redis') {
|
||||
var redisClient = require('redis-url').connect(process.env.REDISTOGO_URL);
|
||||
// Support Redis URL format for KeyDB/Redis
|
||||
if ((process.env.REDIS_URL || process.env.REDISTOGO_URL) &&
|
||||
(config.storage.type === 'redis' || config.storage.type === 'keydb')) {
|
||||
// redis-url 0.1.0 uses a different API than newer versions
|
||||
var redisUrl = require('redis-url');
|
||||
var redisClient = redisUrl.connect(process.env.REDIS_URL || process.env.REDISTOGO_URL);
|
||||
Store = require('./lib/document_stores/redis');
|
||||
preferredStore = new Store(config.storage, redisClient);
|
||||
}
|
||||
else {
|
||||
// If storage is set to keydb, use the redis driver (KeyDB is Redis API compatible)
|
||||
if (config.storage.type === 'keydb') {
|
||||
config.storage.type = 'redis';
|
||||
winston.info('Using redis driver for KeyDB storage');
|
||||
}
|
||||
Store = require('./lib/document_stores/' + config.storage.type);
|
||||
preferredStore = new Store(config.storage);
|
||||
}
|
||||
|
@ -69,18 +84,18 @@ if (config.recompressStaticAssets) {
|
|||
}
|
||||
|
||||
// Send the static documents into the preferred store, skipping expirations
|
||||
var path, data;
|
||||
var docPath, data;
|
||||
for (var name in config.documents) {
|
||||
path = config.documents[name];
|
||||
data = fs.readFileSync(path, 'utf8');
|
||||
winston.info('loading static document', { name: name, path: path });
|
||||
docPath = config.documents[name];
|
||||
data = fs.readFileSync(docPath, 'utf8');
|
||||
winston.info('loading static document', { name: name, path: docPath });
|
||||
if (data) {
|
||||
preferredStore.set(name, data, function(cb) {
|
||||
winston.debug('loaded static document', { success: cb });
|
||||
}, true);
|
||||
}
|
||||
else {
|
||||
winston.warn('failed to load static document', { name: name, path: path });
|
||||
winston.warn('failed to load static document', { name: name, path: docPath });
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -100,6 +115,33 @@ var documentHandler = new DocumentHandler({
|
|||
|
||||
var app = connect();
|
||||
|
||||
// Add CSP middleware early in the chain
|
||||
app.use(cspMiddleware(config));
|
||||
|
||||
// Add CORS support
|
||||
app.use(function(req, res, next) {
|
||||
// Get origin or fallback to *
|
||||
var origin = req.headers.origin;
|
||||
|
||||
// Check if the origin is allowed
|
||||
if (config.allowedOrigins && config.allowedOrigins.length > 0) {
|
||||
if (config.allowedOrigins.includes('*') || (origin && config.allowedOrigins.includes(origin))) {
|
||||
res.setHeader('Access-Control-Allow-Origin', origin || '*');
|
||||
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
||||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
||||
}
|
||||
}
|
||||
|
||||
// Handle preflight requests
|
||||
if (req.method === 'OPTIONS') {
|
||||
res.writeHead(200);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
next();
|
||||
});
|
||||
|
||||
// Rate limit all requests
|
||||
if (config.rateLimits) {
|
||||
config.rateLimits.end = true;
|
||||
|
@ -126,7 +168,7 @@ app.use(route(function(router) {
|
|||
});
|
||||
}));
|
||||
|
||||
// Otherwise, try to match static files
|
||||
// Static file handling - this needs to come before the template handler
|
||||
app.use(connect_st({
|
||||
path: __dirname + '/static',
|
||||
content: { maxAge: config.staticMaxAge },
|
||||
|
@ -134,6 +176,9 @@ app.use(connect_st({
|
|||
index: false
|
||||
}));
|
||||
|
||||
// Add the template handler for index.html - only handles the main URLs
|
||||
app.use(templateHandler(require('path').join(__dirname, 'static')));
|
||||
|
||||
// Then we can loop back - and everything else should be a token,
|
||||
// so route it back to /
|
||||
app.use(route(function(router) {
|
||||
|
@ -143,7 +188,7 @@ app.use(route(function(router) {
|
|||
});
|
||||
}));
|
||||
|
||||
// And match index
|
||||
// And match index as a fallback
|
||||
app.use(connect_st({
|
||||
path: __dirname + '/static',
|
||||
content: { maxAge: config.staticMaxAge },
|
||||
|
|
|
@ -69,26 +69,34 @@ textarea {
|
|||
#box2 {
|
||||
background: #08323c;
|
||||
font-size: 0px;
|
||||
padding: 0px 5px;
|
||||
}
|
||||
|
||||
#box1 a.logo, #box1 a.logo:visited {
|
||||
display: inline-block;
|
||||
background: url(logo.png);
|
||||
width: 126px;
|
||||
height: 42px;
|
||||
}
|
||||
|
||||
#box1 a.logo:hover {
|
||||
background-position: 0 bottom;
|
||||
padding: 0;
|
||||
height: 37px;
|
||||
position: relative;
|
||||
min-width: 160px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
#box2 .function {
|
||||
background: url(function-icons.png);
|
||||
width: 32px;
|
||||
background: url(function-icons.png) no-repeat;
|
||||
height: 37px;
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
border: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
background-color: transparent;
|
||||
text-indent: -9999px;
|
||||
overflow: hidden;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
#box2 .button-picture {
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
text-indent: -9999px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#box2 .link embed {
|
||||
|
@ -130,26 +138,63 @@ textarea {
|
|||
font-weight: normal;
|
||||
}
|
||||
|
||||
#box2 .function.save { background-position: -5px top; }
|
||||
#box2 .function.enabled.save { background-position: -5px center; }
|
||||
#box2 .function.enabled.save:hover { background-position: -5px bottom; }
|
||||
#box2 button.function {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
#box2 .function.new { background-position: -42px top; }
|
||||
#box2 .function.enabled.new { background-position: -42px center; }
|
||||
#box2 .function.enabled.new:hover { background-position: -42px bottom; }
|
||||
#box2 button.function + button.function {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
#box2 .function.duplicate { background-position: -79px top; }
|
||||
#box2 .function.enabled.duplicate { background-position: -79px center; }
|
||||
#box2 .function.enabled.duplicate:hover { background-position: -79px bottom; }
|
||||
#box2 .function.save {
|
||||
width: 37px;
|
||||
background-position: -4px top;
|
||||
display: inline-block !important;
|
||||
visibility: visible !important;
|
||||
opacity: 1 !important;
|
||||
}
|
||||
#box2 .function.enabled.save {
|
||||
background-position: -4px center;
|
||||
display: inline-block !important;
|
||||
visibility: visible !important;
|
||||
opacity: 1 !important;
|
||||
}
|
||||
#box2 .function.enabled.save:hover {
|
||||
background-position: -4px bottom;
|
||||
}
|
||||
|
||||
#box2 .function.raw { background-position: -116px top; }
|
||||
#box2 .function.enabled.raw { background-position: -116px center; }
|
||||
#box2 .function.enabled.raw:hover { background-position: -116px bottom; }
|
||||
#box2 .function.new {
|
||||
width: 37px;
|
||||
background-position: -41px top;
|
||||
}
|
||||
#box2 .function.enabled.new {
|
||||
background-position: -41px center;
|
||||
}
|
||||
#box2 .function.enabled.new:hover {
|
||||
background-position: -41px bottom;
|
||||
}
|
||||
|
||||
#box2 .function.twitter { background-position: -153px top; }
|
||||
#box2 .function.enabled.twitter { background-position: -153px center; }
|
||||
#box2 .function.enabled.twitter:hover { background-position: -153px bottom; }
|
||||
#box2 .button-picture{ border-width: 0; font-size: inherit; }
|
||||
#box2 .function.duplicate {
|
||||
width: 37px;
|
||||
background-position: -78px top;
|
||||
}
|
||||
#box2 .function.enabled.duplicate {
|
||||
background-position: -78px center;
|
||||
}
|
||||
#box2 .function.enabled.duplicate:hover {
|
||||
background-position: -78px bottom;
|
||||
}
|
||||
|
||||
#box2 .function.raw {
|
||||
width: 37px;
|
||||
background-position: -115px top;
|
||||
}
|
||||
#box2 .function.enabled.raw {
|
||||
background-position: -115px center;
|
||||
}
|
||||
#box2 .function.enabled.raw:hover {
|
||||
background-position: -115px bottom;
|
||||
}
|
||||
|
||||
#messages {
|
||||
position:fixed;
|
||||
|
@ -170,3 +215,14 @@ textarea {
|
|||
background:rgba(102,8,0,0.8);
|
||||
}
|
||||
|
||||
#box1 a.logo, #box1 a.logo:visited {
|
||||
display: inline-block;
|
||||
background: url(logo.png);
|
||||
width: 126px;
|
||||
height: 42px;
|
||||
}
|
||||
|
||||
#box1 a.logo:hover {
|
||||
background-position: 0 bottom;
|
||||
}
|
||||
|
||||
|
|
|
@ -126,7 +126,7 @@ haste.prototype.lightKey = function() {
|
|||
|
||||
// Show the full key
|
||||
haste.prototype.fullKey = function() {
|
||||
this.configureKey(['new', 'duplicate', 'twitter', 'raw']);
|
||||
this.configureKey(['new', 'save', 'duplicate', 'raw']);
|
||||
};
|
||||
|
||||
// Set the key up for certain things to be enabled
|
||||
|
@ -264,12 +264,15 @@ haste.prototype.configureButtons = function() {
|
|||
{
|
||||
$where: $('#box2 .save'),
|
||||
label: 'Save',
|
||||
shortcutDescription: 'control + s',
|
||||
shortcut: function(evt) {
|
||||
return evt.ctrlKey && (evt.keyCode === 83);
|
||||
return evt.ctrlKey && evt.keyCode === 83;
|
||||
},
|
||||
shortcutDescription: 'control + s',
|
||||
action: function() {
|
||||
if (_this.$textarea.val().replace(/^\s+|\s+$/g, '') !== '') {
|
||||
if (_this.doc.locked) {
|
||||
_this.duplicate();
|
||||
}
|
||||
else {
|
||||
_this.lockDocument();
|
||||
}
|
||||
}
|
||||
|
@ -293,7 +296,7 @@ haste.prototype.configureButtons = function() {
|
|||
},
|
||||
shortcutDescription: 'control + d',
|
||||
action: function() {
|
||||
_this.duplicateDocument();
|
||||
_this.duplicate();
|
||||
}
|
||||
},
|
||||
{
|
||||
|
@ -306,17 +309,6 @@ haste.prototype.configureButtons = function() {
|
|||
action: function() {
|
||||
window.location.href = '/raw/' + _this.doc.key;
|
||||
}
|
||||
},
|
||||
{
|
||||
$where: $('#box2 .twitter'),
|
||||
label: 'Twitter',
|
||||
shortcut: function(evt) {
|
||||
return _this.options.twitter && _this.doc.locked && evt.shiftKey && evt.ctrlKey && evt.keyCode == 84;
|
||||
},
|
||||
shortcutDescription: 'control + shift + t',
|
||||
action: function() {
|
||||
window.open('https://twitter.com/share?url=' + encodeURI(window.location.href));
|
||||
}
|
||||
}
|
||||
];
|
||||
for (var i = 0; i < this.buttons.length; i++) {
|
||||
|
|
File diff suppressed because one or more lines are too long
|
@ -1,3 +1,4 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
|
||||
<head>
|
||||
|
@ -7,13 +8,13 @@
|
|||
<link rel="stylesheet" type="text/css" href="solarized_dark.css"/>
|
||||
<link rel="stylesheet" type="text/css" href="application.css"/>
|
||||
|
||||
<script type="text/javascript" src="https://ajax.googleapis.com/ajax/libs/jquery/1.7.1/jquery.min.js"></script>
|
||||
<script type="text/javascript" src="highlight.min.js"></script>
|
||||
<script type="text/javascript" src="application.min.js"></script>
|
||||
<script type="text/javascript" nonce="{{cspNonce}}" src="jquery.min.js"></script>
|
||||
<script type="text/javascript" nonce="{{cspNonce}}" src="highlight.min.js"></script>
|
||||
<script type="text/javascript" nonce="{{cspNonce}}" src="application.min.js"></script>
|
||||
|
||||
<meta name="robots" content="noindex,nofollow"/>
|
||||
|
||||
<script type="text/javascript">
|
||||
<script type="text/javascript" nonce="{{cspNonce}}">
|
||||
var app = null;
|
||||
// Handle pops
|
||||
var handlePop = function(evt) {
|
||||
|
@ -31,8 +32,12 @@
|
|||
}, 1000);
|
||||
// Construct app and load initial path
|
||||
$(function() {
|
||||
app = new haste('hastebin', { twitter: true });
|
||||
app = new haste('hastebin', { twitter: false });
|
||||
handlePop({ target: window });
|
||||
// Ensure textarea gets focus after a short delay to let the UI initialize
|
||||
setTimeout(function() {
|
||||
$('textarea').focus();
|
||||
}, 100);
|
||||
});
|
||||
</script>
|
||||
|
||||
|
@ -51,7 +56,6 @@
|
|||
<button class="new function button-picture">New</button>
|
||||
<button class="duplicate function button-picture">Duplicate & Edit</button>
|
||||
<button class="raw function button-picture">Just Text</button>
|
||||
<button class="twitter function button-picture">Twitter</button>
|
||||
</div>
|
||||
<div id="box3" style="display:none;">
|
||||
<div class="label"></div>
|
||||
|
|
File diff suppressed because one or more lines are too long
|
@ -0,0 +1,12 @@
|
|||
/**
|
||||
* Test script for running Hastebin locally with file storage
|
||||
* No need for Redis/KeyDB for local testing
|
||||
*/
|
||||
|
||||
// Set environment variables for testing
|
||||
process.env.HASTEBIN_STORAGE_TYPE = 'file';
|
||||
process.env.HASTEBIN_PORT = '7777';
|
||||
process.env.HASTEBIN_HOST = 'localhost';
|
||||
|
||||
// Run the server
|
||||
require('./server.js');
|
|
@ -0,0 +1,20 @@
|
|||
/**
|
||||
* Script to recompress static JavaScript files after changes
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const uglify = require('uglify-js');
|
||||
|
||||
// Read the application.js file
|
||||
const applicationJs = fs.readFileSync('./static/application.js', 'utf8');
|
||||
|
||||
// Minify it
|
||||
const minified = uglify.minify(applicationJs);
|
||||
if (minified.error) {
|
||||
console.error('Error minifying JavaScript:', minified.error);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Write the minified file
|
||||
fs.writeFileSync('./static/application.min.js', minified.code, 'utf8');
|
||||
console.log('Successfully compressed application.js into application.min.js');
|
Loading…
Reference in New Issue