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
2 changes: 1 addition & 1 deletion .github/workflows/smoke-claude.lock.yml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 12 additions & 0 deletions containers/agent/setup-iptables.sh
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,13 @@ fi
echo "[iptables] Allow traffic to Squid proxy (${SQUID_IP}:${SQUID_PORT})..."
iptables -t nat -A OUTPUT -d "$SQUID_IP" -j RETURN

# Allow traffic to API proxy sidecar (when enabled)
# AWF_API_PROXY_IP is set by docker-manager.ts when --enable-api-proxy is used
if [ -n "$AWF_API_PROXY_IP" ]; then
echo "[iptables] Allow traffic to API proxy sidecar (${AWF_API_PROXY_IP})..."
iptables -t nat -A OUTPUT -d "$AWF_API_PROXY_IP" -j RETURN
fi

# Bypass Squid for host.docker.internal when host access is enabled.
# MCP gateway traffic to host.docker.internal gets DNAT'd to Squid,
# where Squid fails with "Invalid URL" because rmcp sends relative URLs.
Expand Down Expand Up @@ -263,6 +270,11 @@ iptables -A OUTPUT -p tcp -d 127.0.0.11 --dport 53 -j ACCEPT
# Allow traffic to Squid proxy (after NAT redirection)
iptables -A OUTPUT -p tcp -d "$SQUID_IP" -j ACCEPT

# Allow traffic to API proxy sidecar (when enabled)
if [ -n "$AWF_API_PROXY_IP" ]; then
iptables -A OUTPUT -p tcp -d "$AWF_API_PROXY_IP" -j ACCEPT
fi

# Drop all other TCP traffic (default deny policy)
# This ensures that only explicitly allowed ports can be accessed
echo "[iptables] Drop all non-redirected TCP traffic (default deny)..."
Expand Down
4 changes: 2 additions & 2 deletions containers/api-proxy/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@ WORKDIR /app
# Copy package files
COPY package*.json ./

# Install dependencies
RUN npm ci --only=production
# Install dependencies from lockfile (deterministic)
RUN npm ci --omit=dev

# Copy application files
COPY server.js ./
Expand Down
63 changes: 63 additions & 0 deletions containers/api-proxy/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 1 addition & 2 deletions containers/api-proxy/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,7 @@
"start": "node server.js"
},
"dependencies": {
"express": "^4.18.2",
"http-proxy-middleware": "^2.0.6"
"https-proxy-agent": "^7.0.6"
},
"engines": {
"node": ">=18.0.0"
Expand Down
239 changes: 184 additions & 55 deletions containers/api-proxy/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,19 +10,46 @@
* 4. Respects domain whitelisting enforced by Squid
*/

const express = require('express');
const { createProxyMiddleware } = require('http-proxy-middleware');
const http = require('http');
const https = require('https');
const { URL } = require('url');
const { HttpsProxyAgent } = require('https-proxy-agent');

// Max request body size (10 MB) to prevent DoS via large payloads
const MAX_BODY_SIZE = 10 * 1024 * 1024;

// Headers that must never be forwarded from the client.
// The proxy controls authentication — client-supplied auth/proxy headers are stripped.
const STRIPPED_HEADERS = new Set([
'host',
'authorization',
'proxy-authorization',
'x-api-key',
'forwarded',
'via',
]);

/** Returns true if the header name should be stripped (case-insensitive). */
function shouldStripHeader(name) {
const lower = name.toLowerCase();
return STRIPPED_HEADERS.has(lower) || lower.startsWith('x-forwarded-');
}

/** Sanitize a string for safe logging (strip control chars, limit length). */
function sanitizeForLog(str) {
if (typeof str !== 'string') return '';
// eslint-disable-next-line no-control-regex
return str.replace(/[\x00-\x1f\x7f]/g, '').slice(0, 200);
}

// Read API keys from environment (set by docker-compose)
const OPENAI_API_KEY = process.env.OPENAI_API_KEY;
const ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY;

// Squid proxy configuration (set via HTTP_PROXY/HTTPS_PROXY in docker-compose)
const HTTP_PROXY = process.env.HTTP_PROXY;
const HTTPS_PROXY = process.env.HTTPS_PROXY;
const HTTPS_PROXY = process.env.HTTPS_PROXY || process.env.HTTP_PROXY;

console.log('[API Proxy] Starting AWF API proxy sidecar...');
console.log(`[API Proxy] HTTP_PROXY: ${HTTP_PROXY}`);
console.log(`[API Proxy] HTTPS_PROXY: ${HTTPS_PROXY}`);
if (OPENAI_API_KEY) {
console.log('[API Proxy] OpenAI API key configured');
Expand All @@ -31,72 +58,174 @@
console.log('[API Proxy] Anthropic API key configured');
}

// Create Express app
const app = express();

// Health check endpoint
app.get('/health', (req, res) => {
res.status(200).json({
status: 'healthy',
service: 'awf-api-proxy',
squid_proxy: HTTP_PROXY || 'not configured',
providers: {
openai: !!OPENAI_API_KEY,
anthropic: !!ANTHROPIC_API_KEY
// Create proxy agent for routing through Squid
const proxyAgent = HTTPS_PROXY ? new HttpsProxyAgent(HTTPS_PROXY) : undefined;
if (!proxyAgent) {
console.warn('[API Proxy] WARNING: No HTTPS_PROXY configured, requests will go direct');
}

/**
* Forward a request to the target API, injecting auth headers and routing through Squid.
*/
function proxyRequest(req, res, targetHost, injectHeaders) {
// Validate that req.url is a relative path (prevent open-redirect / SSRF)
if (!req.url || !req.url.startsWith('/')) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Bad Request', message: 'URL must be a relative path' }));
return;
}

// Build target URL
const targetUrl = new URL(req.url, `https://${targetHost}`);
Comment on lines +78 to +79
Copy link

Copilot AI Feb 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The URL construction using new URL(req.url, ...) could be vulnerable to path traversal or open redirect attacks if req.url contains an absolute URL or protocol-relative URL. An attacker could potentially send requests like http://evil.com/path or //evil.com/path to bypass the intended targetHost. Consider validating that req.url is a relative path starting with '/' before constructing the target URL, or use url.parse() to extract only the pathname and search components.

Copilot uses AI. Check for mistakes.

// Handle client-side errors (e.g. aborted connections)
req.on('error', (err) => {
console.error(`[API Proxy] Client request error: ${sanitizeForLog(err.message)}`);
if (!res.headersSent) {
res.writeHead(400, { 'Content-Type': 'application/json' });
}
res.end(JSON.stringify({ error: 'Client error', message: err.message }));
});
});

// Read the request body with size limit
const chunks = [];
let totalBytes = 0;
let rejected = false;

req.on('data', chunk => {
if (rejected) return;
totalBytes += chunk.length;
if (totalBytes > MAX_BODY_SIZE) {
rejected = true;
if (!res.headersSent) {
res.writeHead(413, { 'Content-Type': 'application/json' });
}
res.end(JSON.stringify({ error: 'Payload Too Large', message: 'Request body exceeds 10 MB limit' }));
return;
}
chunks.push(chunk);
});

req.on('end', () => {
if (rejected) return;
const body = Buffer.concat(chunks);

// Copy incoming headers, stripping sensitive/proxy headers, then inject auth
const headers = {};
for (const [name, value] of Object.entries(req.headers)) {
if (!shouldStripHeader(name)) {
headers[name] = value;
}
}
Object.assign(headers, injectHeaders);

const options = {
hostname: targetHost,
port: 443,
path: targetUrl.pathname + targetUrl.search,
method: req.method,
headers,
agent: proxyAgent, // Route through Squid
};

const proxyReq = https.request(options, (proxyRes) => {
// Handle response stream errors
proxyRes.on('error', (err) => {
console.error(`[API Proxy] Response stream error from ${targetHost}: ${sanitizeForLog(err.message)}`);
if (!res.headersSent) {
res.writeHead(502, { 'Content-Type': 'application/json' });
}
res.end(JSON.stringify({ error: 'Response stream error', message: err.message }));
});

res.writeHead(proxyRes.statusCode, proxyRes.headers);
proxyRes.pipe(res);
});

proxyReq.on('error', (err) => {
console.error(`[API Proxy] Error proxying to ${targetHost}: ${sanitizeForLog(err.message)}`);

Check warning

Code scanning / CodeQL

Log injection Medium

Log entry depends on a
user-provided value
.

Copilot Autofix

AI 13 days ago

General approach: Ensure user-influenced strings are strictly normalized before logging. For plain-text logs, remove all control characters (including newlines and carriage returns) and limit the maximum length. Optionally, encode such values so their boundaries are clear in the log entry.

Best fix here: strengthen sanitizeForLog so that it (a) coerces non-strings safely, (b) strips all control characters (including \r/\n), and (c) continues to cap length. This preserves existing behavior (short, printable messages) but makes the sanitization more explicit and robust. We then keep using sanitizeForLog(err.message) in the log statement on line 146. No changes are required to the logging call itself; the fix is entirely within the sanitizer function in containers/api-proxy/server.js around lines 38–43.

Concretely:

  • Update sanitizeForLog to:
    • Convert non-string input to string with String(str) so the function is more general but still safe.
    • Use a control-character-stripping regex (already present) and optionally normalize whitespace, while keeping the 200-character limit.
  • No new imports or external methods are needed; native JavaScript is sufficient.
Suggested changeset 1
containers/api-proxy/server.js

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/containers/api-proxy/server.js b/containers/api-proxy/server.js
--- a/containers/api-proxy/server.js
+++ b/containers/api-proxy/server.js
@@ -37,9 +37,15 @@
 
 /** Sanitize a string for safe logging (strip control chars, limit length). */
 function sanitizeForLog(str) {
-  if (typeof str !== 'string') return '';
+  if (str === undefined || str === null) {
+    return '';
+  }
+  // Coerce to string and remove all control characters (including newlines)
+  const stringValue = String(str);
   // eslint-disable-next-line no-control-regex
-  return str.replace(/[\x00-\x1f\x7f]/g, '').slice(0, 200);
+  const cleaned = stringValue.replace(/[\x00-\x1f\x7f]/g, '');
+  // Limit length to avoid log flooding
+  return cleaned.slice(0, 200);
 }
 
 // Read API keys from environment (set by docker-compose)
EOF
@@ -37,9 +37,15 @@

/** Sanitize a string for safe logging (strip control chars, limit length). */
function sanitizeForLog(str) {
if (typeof str !== 'string') return '';
if (str === undefined || str === null) {
return '';
}
// Coerce to string and remove all control characters (including newlines)
const stringValue = String(str);
// eslint-disable-next-line no-control-regex
return str.replace(/[\x00-\x1f\x7f]/g, '').slice(0, 200);
const cleaned = stringValue.replace(/[\x00-\x1f\x7f]/g, '');
// Limit length to avoid log flooding
return cleaned.slice(0, 200);
}

// Read API keys from environment (set by docker-compose)
Copilot is powered by AI and may make mistakes. Always verify output.
if (!res.headersSent) {
res.writeHead(502, { 'Content-Type': 'application/json' });
}
res.end(JSON.stringify({ error: 'Proxy error', message: err.message }));
});

if (body.length > 0) {
proxyReq.write(body);
}
proxyReq.end();
});
}

// Health port is always 10000 — this is what Docker healthcheck hits
const HEALTH_PORT = 10000;

// OpenAI API proxy (port 10000)
if (OPENAI_API_KEY) {
app.use(createProxyMiddleware({
target: 'https://api.openai.com',
changeOrigin: true,
secure: true,
onProxyReq: (proxyReq, req, res) => {
// Inject Authorization header
proxyReq.setHeader('Authorization', `Bearer ${OPENAI_API_KEY}`);
console.log(`[OpenAI Proxy] ${req.method} ${req.url}`);
},
onError: (err, req, res) => {
console.error(`[OpenAI Proxy] Error: ${err.message}`);
res.status(502).json({ error: 'Proxy error', message: err.message });
const server = http.createServer((req, res) => {
if (req.url === '/health' && req.method === 'GET') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
status: 'healthy',
service: 'awf-api-proxy',
squid_proxy: HTTPS_PROXY || 'not configured',
providers: { openai: true, anthropic: !!ANTHROPIC_API_KEY },
}));
return;
}

console.log(`[OpenAI Proxy] ${sanitizeForLog(req.method)} ${sanitizeForLog(req.url)}`);

Check warning

Code scanning / CodeQL

Log injection Medium

Log entry depends on a
user-provided value
.

Copilot Autofix

AI 13 days ago

In general, to fix log injection you must ensure any user-controlled value is sanitized before logging: remove or neutralize newline and carriage-return characters (and other control characters if desired), limit the length, and clearly delimit user input so forged structure is obvious. For HTML-rendered logs, you would also HTML-encode the content.

For this specific code, the best fix is to improve sanitizeForLog so that it (a) explicitly strips \r and \n as recommended, (b) continues to strip other control characters, (c) trims excessive length, and optionally (d) wraps sanitized values to make clear what portion came from the request. We will implement this entirely inside containers/api-proxy/server.js, modifying only the sanitizeForLog function definition. The logging call at line 177 already uses the sanitizer, so no change there is required.

Concretely:

  • Update sanitizeForLog to:
    • Coerce non-string input to string (for robustness).
    • Remove \r and \n explicitly (even though they’re already in the control-character range).
    • Keep the max length of 200 characters.
  • Optionally, and safely, we can make user-controlled content visually delimited (e.g., leave as-is since the log message already brackets them with spaces and a tag; no functional change needed).

No new imports or external libraries are needed; we simply adjust the existing helper function.

Suggested changeset 1
containers/api-proxy/server.js

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/containers/api-proxy/server.js b/containers/api-proxy/server.js
--- a/containers/api-proxy/server.js
+++ b/containers/api-proxy/server.js
@@ -35,11 +35,13 @@
   return STRIPPED_HEADERS.has(lower) || lower.startsWith('x-forwarded-');
 }
 
-/** Sanitize a string for safe logging (strip control chars, limit length). */
+/** Sanitize a string for safe logging (strip control chars/newlines, limit length). */
 function sanitizeForLog(str) {
-  if (typeof str !== 'string') return '';
+  if (str == null) return '';
+  const s = String(str);
+  // Remove ASCII control characters, including CR/LF, and limit length to 200 chars
   // eslint-disable-next-line no-control-regex
-  return str.replace(/[\x00-\x1f\x7f]/g, '').slice(0, 200);
+  return s.replace(/[\r\n]/g, '').replace(/[\x00-\x1f\x7f]/g, '').slice(0, 200);
 }
 
 // Read API keys from environment (set by docker-compose)
EOF
@@ -35,11 +35,13 @@
return STRIPPED_HEADERS.has(lower) || lower.startsWith('x-forwarded-');
}

/** Sanitize a string for safe logging (strip control chars, limit length). */
/** Sanitize a string for safe logging (strip control chars/newlines, limit length). */
function sanitizeForLog(str) {
if (typeof str !== 'string') return '';
if (str == null) return '';
const s = String(str);
// Remove ASCII control characters, including CR/LF, and limit length to 200 chars
// eslint-disable-next-line no-control-regex
return str.replace(/[\x00-\x1f\x7f]/g, '').slice(0, 200);
return s.replace(/[\r\n]/g, '').replace(/[\x00-\x1f\x7f]/g, '').slice(0, 200);
}

// Read API keys from environment (set by docker-compose)
Copilot is powered by AI and may make mistakes. Always verify output.
proxyRequest(req, res, 'api.openai.com', {
'Authorization': `Bearer ${OPENAI_API_KEY}`,
});
});

server.listen(HEALTH_PORT, '0.0.0.0', () => {
console.log(`[API Proxy] OpenAI proxy listening on port ${HEALTH_PORT}`);
});
} else {
// No OpenAI key — still need a health endpoint on port 10000 for Docker healthcheck
const server = http.createServer((req, res) => {
if (req.url === '/health' && req.method === 'GET') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
status: 'healthy',
service: 'awf-api-proxy',
squid_proxy: HTTPS_PROXY || 'not configured',
providers: { openai: false, anthropic: !!ANTHROPIC_API_KEY },
}));
return;
}
}));

app.listen(10000, '0.0.0.0', () => {
console.log('[API Proxy] OpenAI proxy listening on port 10000');
console.log('[API Proxy] Routing through Squid to api.openai.com');
res.writeHead(404, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'OpenAI proxy not configured (no OPENAI_API_KEY)' }));
});

server.listen(HEALTH_PORT, '0.0.0.0', () => {
console.log(`[API Proxy] Health endpoint listening on port ${HEALTH_PORT} (OpenAI not configured)`);
});
}

// Anthropic API proxy (port 10001)
if (ANTHROPIC_API_KEY) {
const anthropicApp = express();

anthropicApp.get('/health', (req, res) => {
res.status(200).json({ status: 'healthy', service: 'anthropic-proxy' });
});
const server = http.createServer((req, res) => {
if (req.url === '/health' && req.method === 'GET') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ status: 'healthy', service: 'anthropic-proxy' }));
return;
}

anthropicApp.use(createProxyMiddleware({
target: 'https://api.anthropic.com',
changeOrigin: true,
secure: true,
onProxyReq: (proxyReq, req, res) => {
// Inject Anthropic authentication headers
proxyReq.setHeader('x-api-key', ANTHROPIC_API_KEY);
proxyReq.setHeader('anthropic-version', '2023-06-01');
console.log(`[Anthropic Proxy] ${req.method} ${req.url}`);
},
onError: (err, req, res) => {
console.error(`[Anthropic Proxy] Error: ${err.message}`);
res.status(502).json({ error: 'Proxy error', message: err.message });
console.log(`[Anthropic Proxy] ${sanitizeForLog(req.method)} ${sanitizeForLog(req.url)}`);

Check warning

Code scanning / CodeQL

Log injection Medium

Log entry depends on a
user-provided value
.

Copilot Autofix

AI 13 days ago

In general, to fix log injection, any user-controlled value being logged should be normalized so it cannot introduce line breaks or other control characters, should be length-limited, and should be clearly delimited so it cannot be mistaken for system-generated log content.

For this code, the best minimal fix is to (a) tighten sanitizeForLog so it explicitly strips \r/\n (even though the current control-character regex already covers them) and clearly marks the sanitized value as user input, and (b) keep using that function at the log site. We don’t need to change the logging call itself; we only improve the sanitizer in one place, preserving existing behavior while satisfying stricter expectations about newline removal and clear demarcation of user input.

Concretely, in containers/api-proxy/server.js, modify the implementation of sanitizeForLog (lines 39–42). Keep the same signature and call sites, but:

  • Ensure we strip \r and \n explicitly.
  • Continue stripping other control characters and limiting length, as already done.
  • Wrap the sanitized output to clearly mark it as user-derived (e.g., by surrounding with quotes), which helps log readers distinguish user input from log structure.

No additional imports are needed; we only adjust the existing helper.

Suggested changeset 1
containers/api-proxy/server.js

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/containers/api-proxy/server.js b/containers/api-proxy/server.js
--- a/containers/api-proxy/server.js
+++ b/containers/api-proxy/server.js
@@ -35,11 +35,14 @@
   return STRIPPED_HEADERS.has(lower) || lower.startsWith('x-forwarded-');
 }
 
-/** Sanitize a string for safe logging (strip control chars, limit length). */
+/** Sanitize a string for safe logging (strip control chars, newlines, limit length). */
 function sanitizeForLog(str) {
   if (typeof str !== 'string') return '';
+  // Remove control characters (including CR/LF) and limit length
   // eslint-disable-next-line no-control-regex
-  return str.replace(/[\x00-\x1f\x7f]/g, '').slice(0, 200);
+  const cleaned = str.replace(/[\x00-\x1f\x7f]/g, '').replace(/\r|\n/g, '').slice(0, 200);
+  // Clearly delimit user-controlled input in logs
+  return `"${cleaned}"`;
 }
 
 // Read API keys from environment (set by docker-compose)
EOF
@@ -35,11 +35,14 @@
return STRIPPED_HEADERS.has(lower) || lower.startsWith('x-forwarded-');
}

/** Sanitize a string for safe logging (strip control chars, limit length). */
/** Sanitize a string for safe logging (strip control chars, newlines, limit length). */
function sanitizeForLog(str) {
if (typeof str !== 'string') return '';
// Remove control characters (including CR/LF) and limit length
// eslint-disable-next-line no-control-regex
return str.replace(/[\x00-\x1f\x7f]/g, '').slice(0, 200);
const cleaned = str.replace(/[\x00-\x1f\x7f]/g, '').replace(/\r|\n/g, '').slice(0, 200);
// Clearly delimit user-controlled input in logs
return `"${cleaned}"`;
}

// Read API keys from environment (set by docker-compose)
Copilot is powered by AI and may make mistakes. Always verify output.
// Only set anthropic-version as default; preserve agent-provided version
const anthropicHeaders = { 'x-api-key': ANTHROPIC_API_KEY };
if (!req.headers['anthropic-version']) {
anthropicHeaders['anthropic-version'] = '2023-06-01';
}
}));
proxyRequest(req, res, 'api.anthropic.com', anthropicHeaders);
});

anthropicApp.listen(10001, '0.0.0.0', () => {
server.listen(10001, '0.0.0.0', () => {
console.log('[API Proxy] Anthropic proxy listening on port 10001');
console.log('[API Proxy] Routing through Squid to api.anthropic.com');
});
}

Expand Down
Loading
Loading