first commit
This commit is contained in:
parent
2f274df5f8
commit
7f02ef91e4
130
server.js
130
server.js
@ -83,6 +83,8 @@ app.post('/admin/save', ensureAdmin, (req, res) => {
|
|||||||
pathPrefix: req.body.pathPrefix || '/',
|
pathPrefix: req.body.pathPrefix || '/',
|
||||||
upstream: req.body.upstream || ''
|
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) {
|
if (req.body.oidc_issuer) {
|
||||||
hostObj.oidc = {
|
hostObj.oidc = {
|
||||||
issuer: req.body.oidc_issuer,
|
issuer: req.body.oidc_issuer,
|
||||||
@ -145,7 +147,7 @@ app.use('/preview-proxy/:id', ensureAdmin, (req, res, next) => {
|
|||||||
const proxy = createProxyMiddleware({
|
const proxy = createProxyMiddleware({
|
||||||
target: host.upstream,
|
target: host.upstream,
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
selfHandleResponse: false,
|
selfHandleResponse: true,
|
||||||
proxyTimeout: 15000,
|
proxyTimeout: 15000,
|
||||||
pathRewrite: (path, req2) => {
|
pathRewrite: (path, req2) => {
|
||||||
// path starts with /preview-proxy/:id
|
// 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');
|
if (req.session && req.session.isAdmin) proxyReq.setHeader('X-Admin-User', req.session.username || 'admin');
|
||||||
},
|
},
|
||||||
onProxyRes(proxyRes, req2, res2) {
|
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
|
// Remove headers that would prevent embedding in an iframe
|
||||||
try {
|
try {
|
||||||
delete proxyRes.headers['x-frame-options'];
|
delete proxyRes.headers['x-frame-options'];
|
||||||
delete proxyRes.headers['x-content-security-policy'];
|
delete proxyRes.headers['x-content-security-policy'];
|
||||||
delete proxyRes.headers['content-security-policy'];
|
delete proxyRes.headers['content-security-policy'];
|
||||||
delete proxyRes.headers['frame-options'];
|
delete proxyRes.headers['frame-options'];
|
||||||
} catch (e) {
|
} catch (e) {}
|
||||||
// ignore
|
|
||||||
|
// 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,
|
changeOrigin: true,
|
||||||
proxyTimeout: 10000,
|
proxyTimeout: 10000,
|
||||||
pathRewrite,
|
pathRewrite,
|
||||||
selfHandleResponse: false,
|
selfHandleResponse: true, // we may rewrite HTML and headers (masking optional)
|
||||||
onProxyReq(proxyReq, req, res) {
|
onProxyReq(proxyReq, req, res) {
|
||||||
// if we have an access token for this host, supply it
|
// 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) {
|
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) {
|
if (req.session && req.session.user) {
|
||||||
proxyReq.setHeader('X-Forwarded-User', req.session.user.username);
|
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);
|
return proxy(req, res, next);
|
||||||
|
|||||||
@ -35,6 +35,9 @@
|
|||||||
<input name="oidc_redirect_uri" value="<%= host && host.oidc ? host.oidc.redirect_uri : '' %>" />
|
<input name="oidc_redirect_uri" value="<%= host && host.oidc ? host.oidc.redirect_uri : '' %>" />
|
||||||
</label>
|
</label>
|
||||||
</fieldset>
|
</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">
|
<div style="margin-top:12px">
|
||||||
<button type="submit">Enregistrer</button>
|
<button type="submit">Enregistrer</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -15,7 +15,7 @@
|
|||||||
<section>
|
<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>
|
<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>
|
<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>
|
<tbody>
|
||||||
<% hosts.forEach(function(h){ %>
|
<% hosts.forEach(function(h){ %>
|
||||||
<tr>
|
<tr>
|
||||||
@ -23,6 +23,7 @@
|
|||||||
<td><%= h.pathPrefix %></td>
|
<td><%= h.pathPrefix %></td>
|
||||||
<td><%= h.upstream %></td>
|
<td><%= h.upstream %></td>
|
||||||
<td><%= h.oidc ? 'yes' : 'no' %></td>
|
<td><%= h.oidc ? 'yes' : 'no' %></td>
|
||||||
|
<td><%= h.maskUpstream ? 'yes' : 'no' %></td>
|
||||||
<td class="actions">
|
<td class="actions">
|
||||||
<a href="/admin/edit/<%= h.id %>">Edit</a>
|
<a href="/admin/edit/<%= h.id %>">Edit</a>
|
||||||
<a href="/admin/preview/<%= h.id %>" target="_blank">Preview</a>
|
<a href="/admin/preview/<%= h.id %>" target="_blank">Preview</a>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user