Skip to content
Merged
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
7 changes: 7 additions & 0 deletions create-a-container/routers/containers.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ const serviceMap = require('../data/services.json');
const { isApiRequest } = require('../utils/http');
const { parseDockerRef, getImageConfig, extractImageMetadata } = require('../utils/docker-registry');
const { manageDnsRecords } = require('../utils/cloudflare-dns');
const { isValidHostname } = require('../utils');

/**
* Normalize a Docker image reference to full format: host/org/image:tag
Expand Down Expand Up @@ -287,6 +288,12 @@ router.post('/', async (req, res) => {
}
// ---------------------------

// Hostname must be lowercase before validation
if (hostname) hostname = hostname.trim().toLowerCase();
if (!isValidHostname(hostname)) {
throw new Error('Invalid hostname: must be 1–63 characters, only lowercase letters, digits, and hyphens, and must start and end with a letter or digit');
}

const currentUser = req.session?.user || req.user?.username || 'api-user';

let envVarsJson = null;
Expand Down
9 changes: 7 additions & 2 deletions create-a-container/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -87,9 +87,14 @@ async function main() {
next();
});
app.use(express.static('public'));

// We rate limit unsucessful (4xx/5xx statuses) to only 10 per 5 minutes, this
// should allow legitimate users a few tries to login or experiment without
// allowing bad-actors to abuse requests.
app.use(RateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // limit each IP to 100 requests per windowMs
windowMs: 5 * 60 * 1000,
max: 10,
skipSuccessfulRequests: true,
}));

// Set version info once at startup in app.locals
Expand Down
11 changes: 11 additions & 0 deletions create-a-container/utils/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,16 @@ function getVersionInfo() {
}
}

/**
* Validate that a hostname is a legal DNS subdomain label (RFC 1123).
* @param {string} hostname
* @returns {boolean}
*/
function isValidHostname(hostname) {
if (typeof hostname !== 'string') return false;
return /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/.test(hostname);
}

/**
* Helper to validate that a redirect URL is a safe relative path.
* @param {string} url - the URL to validate
Expand All @@ -74,6 +84,7 @@ function isSafeRelativeUrl(url) {
module.exports = {
ProxmoxApi,
run,
isValidHostname,
isSafeRelativeUrl,
getVersionInfo
};
13 changes: 12 additions & 1 deletion create-a-container/views/containers/form.ejs
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,10 @@ const breadcrumbLabel = isEdit ? 'Edit' : 'New';
<div style="display: flex; gap: 15px; margin-bottom: 15px;">
<div style="flex: 1;">
<label for="hostname">Container Hostname</label>
<input type="text" id="hostname" name="hostname" value="<%= isEdit ? container.hostname : '' %>" <%= isEdit ? 'readonly' : 'required' %> <%= isEdit ? 'style="background-color: #e9ecef;"' : '' %>>
<input type="text" id="hostname" name="hostname" value="<%= isEdit ? container.hostname : '' %>" <% if (isEdit) { %>readonly style="background-color: #e9ecef;"<% } else { %>required pattern="[a-z0-9]([a-z0-9\-]{0,61}[a-z0-9])?" title="Lowercase letters, digits, and hyphens only. Must start and end with a letter or digit (max 63 chars)."<% } %>>
<% if (!isEdit) { %>
<small style="color: #666; display: block; margin-top: 4px;">Lowercase letters, digits, and hyphens. Must start and end with a letter or digit.</small>
<% } %>
</div>

<div style="flex: 1;">
Expand Down Expand Up @@ -649,6 +652,14 @@ const breadcrumbLabel = isEdit ? 'Edit' : 'New';
addEnvVarRow();
});

// Auto-lowercase hostname input
const hostnameInput = document.getElementById('hostname');
if (!hostnameInput.readOnly) {
hostnameInput.addEventListener('input', () => {
hostnameInput.value = hostnameInput.value.toLowerCase();
});
}

// Initialize existing environment variables
if (existingEnvVars && typeof existingEnvVars === 'object') {
for (const [key, value] of Object.entries(existingEnvVars)) {
Expand Down
105 changes: 83 additions & 22 deletions create-a-container/views/nginx-conf.ejs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,26 @@ http {
modsecurity on;
modsecurity_rules_file /etc/nginx/modsecurity_includes.conf;
modsecurity_transaction_id "$request_id";

# Internal error page server on a unix socket. Named locations proxy here
# with proxy_method GET so that NGINX's static file module will serve the
# HTML regardless of the original request method (POST, PUT, etc.).
# ModSecurity is disabled to prevent re-evaluation of the original request.
upstream error_pages {
server unix:/run/nginx-error-pages.sock;
}

server {
listen unix:/run/nginx-error-pages.sock;
modsecurity off;
ssi on;

root /opt/opensource-server/error-pages;

location /403.html { }
location /404.html { }
location /502.html { }
}

server {
listen 80;
Expand Down Expand Up @@ -75,17 +95,22 @@ http {
error_page 403 @403;

location @403 {
ssi on;
root /opt/opensource-server/error-pages;
try_files /403.html =403;
rewrite ^ /403.html break;
proxy_method GET;
proxy_pass_request_body off;
proxy_set_header Content-Length "";
proxy_pass http://error_pages;
}

<%_ if (httpServices.length === 0) { _%>
error_page 502 @502;

location @502 {
root /opt/opensource-server/error-pages;
try_files /502.html =502;
rewrite ^ /502.html break;
proxy_method GET;
proxy_pass_request_body off;
proxy_set_header Content-Length "";
proxy_pass http://error_pages;
}

location / {
Expand Down Expand Up @@ -117,11 +142,32 @@ http {
client_max_body_size 2G;
}
<%_ } else { _%>
error_page 403 @403;
error_page 404 @404;
error_page 502 @502;

location @403 {
rewrite ^ /403.html break;
proxy_method GET;
proxy_pass_request_body off;
proxy_set_header Content-Length "";
proxy_pass http://error_pages;
}

location @404 {
root /opt/opensource-server/error-pages;
try_files /404.html =404;
rewrite ^ /404.html break;
proxy_method GET;
proxy_pass_request_body off;
proxy_set_header Content-Length "";
proxy_pass http://error_pages;
}

location @502 {
rewrite ^ /502.html break;
proxy_method GET;
proxy_pass_request_body off;
proxy_set_header Content-Length "";
proxy_pass http://error_pages;
}

return 404;
Expand Down Expand Up @@ -163,16 +209,21 @@ http {
error_page 403 @403;

location @403 {
ssi on;
root /opt/opensource-server/error-pages;
try_files /403.html =403;
rewrite ^ /403.html break;
proxy_method GET;
proxy_pass_request_body off;
proxy_set_header Content-Length "";
proxy_pass http://error_pages;
}

error_page 502 @502;

location @502 {
root /opt/opensource-server/error-pages;
try_files /502.html =502;
rewrite ^ /502.html break;
proxy_method GET;
proxy_pass_request_body off;
proxy_set_header Content-Length "";
proxy_pass http://error_pages;
}

# Proxy settings
Expand Down Expand Up @@ -243,16 +294,21 @@ http {
error_page 403 @403;

location @403 {
ssi on;
root /opt/opensource-server/error-pages;
try_files /403.html =403;
rewrite ^ /403.html break;
proxy_method GET;
proxy_pass_request_body off;
proxy_set_header Content-Length "";
proxy_pass http://error_pages;
}

error_page 404 @404;

location @404 {
root /opt/opensource-server/error-pages;
try_files /404.html =404;
rewrite ^ /404.html break;
proxy_method GET;
proxy_pass_request_body off;
proxy_set_header Content-Length "";
proxy_pass http://error_pages;
}

# Return 404 for all requests
Expand Down Expand Up @@ -294,16 +350,21 @@ http {
error_page 403 @403;

location @403 {
ssi on;
root /opt/opensource-server/error-pages;
try_files /403.html =403;
rewrite ^ /403.html break;
proxy_method GET;
proxy_pass_request_body off;
proxy_set_header Content-Length "";
proxy_pass http://error_pages;
}

error_page 502 @502;

location @502 {
root /opt/opensource-server/error-pages;
try_files /502.html =502;
rewrite ^ /502.html break;
proxy_method GET;
proxy_pass_request_body off;
proxy_set_header Content-Length "";
proxy_pass http://error_pages;
}

# Proxy to documentation site
Expand Down
8 changes: 8 additions & 0 deletions images/agent/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,14 @@ RUN sed -i \
&& sed -i -e 's/IncludeOptional/Include/' /usr/share/modsecurity-crs/owasp-crs.load \
&& sed -i -e 's/^SecRuleEngine .*$/SecRuleEngine On/' /etc/nginx/modsecurity.conf

# Logrotate overrides for NGINX and ModSecurity to work around a logrotate
# repoen bug at https://github.com/owasp-modsecurity/ModSecurity-nginx/issues/351
COPY ./images/agent/nginx.logrotate /etc/logrotate.d/nginx

# Apply custom ModSecurity configurations. See the blame on that file for
# details on what's been changed from stock.
COPY ./images/agent/crs-setup.conf /etc/modsecurity/crs/crs-setup.conf

# Install DNSMasq and configure it to only get it's config from our pull-config
RUN sed -i \
-e 's/^CONFIG_DIR=\(.*\)$/#CONFIG_DIR=\1/' \
Expand Down
Loading
Loading