first commit

This commit is contained in:
KIENTZ Alexandre 2025-12-03 21:19:04 +01:00
parent 2f274df5f8
commit 7f02ef91e4
3 changed files with 131 additions and 5 deletions

130
server.js
View File

@ -83,6 +83,8 @@ app.post('/admin/save', ensureAdmin, (req, res) => {
pathPrefix: req.body.pathPrefix || '/',
upstream: req.body.upstream || ''
};
// mask upstream option: rewrite absolute URLs and cookies so proxy origin is used
hostObj.maskUpstream = req.body.mask_upstream === 'on';
if (req.body.oidc_issuer) {
hostObj.oidc = {
issuer: req.body.oidc_issuer,
@ -145,7 +147,7 @@ app.use('/preview-proxy/:id', ensureAdmin, (req, res, next) => {
const proxy = createProxyMiddleware({
target: host.upstream,
changeOrigin: true,
selfHandleResponse: false,
selfHandleResponse: true,
proxyTimeout: 15000,
pathRewrite: (path, req2) => {
// path starts with /preview-proxy/:id
@ -163,14 +165,67 @@ app.use('/preview-proxy/:id', ensureAdmin, (req, res, next) => {
if (req.session && req.session.isAdmin) proxyReq.setHeader('X-Admin-User', req.session.username || 'admin');
},
onProxyRes(proxyRes, req2, res2) {
// helper: compute proxy origin used by clients
const proto = (req2.headers['x-forwarded-proto'] || req2.protocol || 'http');
const proxyOrigin = `${proto}://${req2.get('host')}`;
// Remove headers that would prevent embedding in an iframe
try {
delete proxyRes.headers['x-frame-options'];
delete proxyRes.headers['x-content-security-policy'];
delete proxyRes.headers['content-security-policy'];
delete proxyRes.headers['frame-options'];
} catch (e) {
// ignore
} catch (e) {}
// If masking is enabled for this host, perform stronger rewrites
if (host.maskUpstream) {
// rewrite Location header
if (proxyRes.headers.location) {
try {
const loc = proxyRes.headers.location;
if (loc.startsWith(host.upstream)) {
const newLoc = loc.replace(host.upstream, proxyOrigin + (host.pathPrefix && host.pathPrefix !== '/' ? host.pathPrefix : ''));
proxyRes.headers.location = newLoc;
}
} catch (e) {}
}
// rewrite Set-Cookie: remove Domain attribute
if (proxyRes.headers['set-cookie']) {
try {
const cookies = proxyRes.headers['set-cookie'].map(cookie => cookie.replace(/;\s*Domain=[^;]+/i, ''));
proxyRes.headers['set-cookie'] = cookies;
} catch (e) {}
}
const contentType = (proxyRes.headers['content-type'] || '').toLowerCase();
if (contentType.includes('text/html')) {
const chunks = [];
proxyRes.on('data', chunk => chunks.push(chunk));
proxyRes.on('end', () => {
try {
const body = Buffer.concat(chunks).toString('utf8');
const upstreamOrigin = host.upstream.replace(/\/$/, '');
const from = new RegExp(upstreamOrigin.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g');
const prefix = (host.pathPrefix && host.pathPrefix !== '/') ? host.pathPrefix.replace(/\/$/, '') : '';
const replaced = body.replace(from, proxyOrigin + prefix);
Object.keys(proxyRes.headers).forEach(h => {
if (h.toLowerCase() === 'content-length') delete proxyRes.headers[h];
});
res2.writeHead(proxyRes.statusCode, proxyRes.headers);
res2.end(replaced);
} catch (e) {
res2.writeHead(proxyRes.statusCode, proxyRes.headers);
res2.end();
}
});
} else {
res2.writeHead(proxyRes.statusCode, proxyRes.headers);
proxyRes.pipe(res2);
}
} else {
// Not masking: just forward the response with frame headers removed
res2.writeHead(proxyRes.statusCode, proxyRes.headers);
proxyRes.pipe(res2);
}
}
});
@ -362,7 +417,7 @@ app.use('/', requireLoginForHost, (req, res, next) => {
changeOrigin: true,
proxyTimeout: 10000,
pathRewrite,
selfHandleResponse: false,
selfHandleResponse: true, // we may rewrite HTML and headers (masking optional)
onProxyReq(proxyReq, req, res) {
// if we have an access token for this host, supply it
if (req.session && req.session.tokens && req.session.tokens[host.id] && req.session.tokens[host.id].access_token) {
@ -372,6 +427,73 @@ app.use('/', requireLoginForHost, (req, res, next) => {
if (req.session && req.session.user) {
proxyReq.setHeader('X-Forwarded-User', req.session.user.username);
}
},
onProxyRes(proxyRes, req2, res2) {
// helper: compute proxy origin used by clients
const proto = (req2.headers['x-forwarded-proto'] || req2.protocol || 'http');
const proxyOrigin = `${proto}://${req2.get('host')}`;
// Always remove frame-blocking headers for embedding/preview
try {
delete proxyRes.headers['x-frame-options'];
delete proxyRes.headers['x-content-security-policy'];
delete proxyRes.headers['content-security-policy'];
delete proxyRes.headers['frame-options'];
} catch (e) {}
// If masking is enabled for this host, perform stronger rewrites
if (host.maskUpstream) {
// rewrite Location header to point to proxy origin
if (proxyRes.headers.location) {
try {
const loc = proxyRes.headers.location;
if (loc.startsWith(host.upstream)) {
const newLoc = loc.replace(host.upstream, proxyOrigin + (host.pathPrefix && host.pathPrefix !== '/' ? host.pathPrefix : ''));
proxyRes.headers.location = newLoc;
}
} catch (e) {}
}
// rewrite Set-Cookie: remove Domain attribute so cookie is set for proxy host
if (proxyRes.headers['set-cookie']) {
try {
const cookies = proxyRes.headers['set-cookie'].map(cookie => cookie.replace(/;\s*Domain=[^;]+/i, ''));
proxyRes.headers['set-cookie'] = cookies;
} catch (e) {}
}
// For HTML responses, buffer and rewrite absolute URLs from upstream to proxy
const contentType = (proxyRes.headers['content-type'] || '').toLowerCase();
if (contentType.includes('text/html')) {
const chunks = [];
proxyRes.on('data', chunk => chunks.push(chunk));
proxyRes.on('end', () => {
try {
const body = Buffer.concat(chunks).toString('utf8');
const upstreamOrigin = host.upstream.replace(/\/$/, '');
const from = new RegExp(upstreamOrigin.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g');
const prefix = (host.pathPrefix && host.pathPrefix !== '/') ? host.pathPrefix.replace(/\/$/, '') : '';
const replaced = body.replace(from, proxyOrigin + prefix);
// set headers and send
Object.keys(proxyRes.headers).forEach(h => {
if (h.toLowerCase() === 'content-length') delete proxyRes.headers[h];
});
res2.writeHead(proxyRes.statusCode, proxyRes.headers);
res2.end(replaced);
} catch (e) {
res2.writeHead(proxyRes.statusCode, proxyRes.headers);
res2.end();
}
});
} else {
// non-html: pipe straight through but ensure headers updated
res2.writeHead(proxyRes.statusCode, proxyRes.headers);
proxyRes.pipe(res2);
}
} else {
// Not masking: just forward the response with frame headers removed
res2.writeHead(proxyRes.statusCode, proxyRes.headers);
proxyRes.pipe(res2);
}
}
});
return proxy(req, res, next);

View File

@ -35,6 +35,9 @@
<input name="oidc_redirect_uri" value="<%= host && host.oidc ? host.oidc.redirect_uri : '' %>" />
</label>
</fieldset>
<label style="margin-top:8px;">
<input type="checkbox" name="mask_upstream" <%= host && host.maskUpstream ? 'checked' : '' %> /> Masquer l'upstream (réécrire URLs/ cookies)
</label>
<div style="margin-top:12px">
<button type="submit">Enregistrer</button>
</div>

View File

@ -15,7 +15,7 @@
<section>
<p><a href="/admin/new">Créer un nouvel hôte</a> · <form style="display:inline" method="post" action="/admin/reload"><button type="submit">Reload OIDC</button></form></p>
<table>
<thead><tr><th>ID</th><th>Prefix</th><th>Upstream</th><th>OIDC</th><th>Actions</th></tr></thead>
<thead><tr><th>ID</th><th>Prefix</th><th>Upstream</th><th>OIDC</th><th>Masked</th><th>Actions</th></tr></thead>
<tbody>
<% hosts.forEach(function(h){ %>
<tr>
@ -23,6 +23,7 @@
<td><%= h.pathPrefix %></td>
<td><%= h.upstream %></td>
<td><%= h.oidc ? 'yes' : 'no' %></td>
<td><%= h.maskUpstream ? 'yes' : 'no' %></td>
<td class="actions">
<a href="/admin/edit/<%= h.id %>">Edit</a>
<a href="/admin/preview/<%= h.id %>" target="_blank">Preview</a>