Adds integration tests for per path access lists, fixes ipv6 jsv,

and enforces host-wide access list when location access list is not set
This commit is contained in:
Jamie Curnow
2026-09-24 12:21:49 +10:00
parent c41ec008bb
commit 2cfd3395cf
21 changed files with 518 additions and 103 deletions
@@ -0,0 +1,301 @@
/// <reference types="cypress" />
describe('Per-path Access Lists', () => {
const domain = 'website3.example.com';
const alpha = {
name: 'Path Alpha',
username: 'alpha-user',
password: 'alpha-pass',
};
const beta = {
name: 'Path Beta',
username: 'beta-user',
password: 'beta-pass',
};
let token;
let alphaListId;
let betaListId;
let hostId;
/**
* Requests a path on the proxy host directly (bypassing squid)
* and yields the HTTP status code and response body
*
* @param {string} path
* @param {object} [creds]
* @param {string} creds.username
* @param {string} creds.password
*/
const request = (path, creds) => {
const auth = creds ? `-u '${creds.username}:${creds.password}'` : '';
return cy.exec(`curl --noproxy '*' -s -w '\n%{http_code}' ${auth} http://${domain}${path}`)
.then((result) => {
expect(result.exitCode).to.eq(0);
const lines = result.stdout.trim().split('\n');
const status = lines.pop();
return { status: status, body: lines.join('\n') };
});
};
// nginx reloads are signalled and return immediately, so the old
// config can still be served for a moment after an API change.
// Retry until the expected response is seen.
const waitForResponse = (path, creds, check, description) => {
// Copy now, the callback runs later and creds may have been changed by then
const credsCopy = creds ? { ...creds } : null;
let last = null;
cy.waitUntil(() => request(path, credsCopy).then((res) => {
last = res;
return check(res);
}), {
timeout: 15000,
interval: 500,
errorMsg: () => `${path} did not return ${description}, last status: ${last?.status}`,
});
};
const expectStatus = (path, creds, status) => {
waitForResponse(path, creds, (res) => res.status === status, status);
};
// Also checks the body so we know the page came from examplesite
// and not the NPM default site
const expectPage = (path, creds, text) => {
waitForResponse(path, creds, (res) => res.status === '200' && res.body.includes(text), `200 with "${text}"`);
};
const createAccessList = (list) => {
return cy.task('backendApiPost', {
token: token,
path: '/api/nginx/access-lists',
data: {
name: list.name,
satisfy_any: false,
pass_auth: false,
items: [
{
username: list.username,
password: list.password,
}
],
clients: [],
}
}).then((data) => {
cy.validateSwaggerSchema('post', 201, '/nginx/access-lists', data);
expect(data).to.have.property('id');
expect(data.id).to.be.greaterThan(0);
return cy.wrap(data.id);
});
};
const location = (path, accessListId) => {
return {
path: path,
forward_scheme: 'http',
forward_host: 'examplesite',
forward_port: 80,
access_list_id: accessListId,
};
};
before(() => {
cy.resetUsers();
cy.getToken().then((tok) => {
token = tok;
});
});
it('Should be able to create multiple access lists with credentials', () => {
createAccessList(alpha).then((id) => {
alphaListId = id;
});
createAccessList(beta).then((id) => {
betaListId = id;
expect(betaListId).to.not.equal(alphaListId);
});
});
it('Should be able to create a proxy host with a different access list per location', () => {
cy.task('backendApiPost', {
token: token,
path: '/api/nginx/proxy-hosts',
data: {
domain_names: [domain],
forward_scheme: 'http',
forward_host: 'examplesite',
forward_port: 80,
access_list_id: 0,
certificate_id: 0,
meta: {
dns_challenge: false
},
advanced_config: '',
locations: [
location('/dashboard', alphaListId),
location('/profile', betaListId),
],
block_exploits: false,
caching_enabled: false,
allow_websocket_upgrade: false,
http2_support: false,
hsts_enabled: false,
hsts_subdomains: false,
ssl_forced: false
}
}).then((data) => {
cy.validateSwaggerSchema('post', 201, '/nginx/proxy-hosts', data);
expect(data).to.have.property('id');
expect(data.id).to.be.greaterThan(0);
hostId = data.id;
expect(data).to.have.property('enabled', true);
expect(data).to.have.property('access_list_id', 0);
expect(data.locations).to.have.length(2);
expect(data.locations[0]).to.have.property('access_list_id', alphaListId);
expect(data.locations[1]).to.have.property('access_list_id', betaListId);
});
});
it('Should persist the location access lists on the proxy host', () => {
cy.task('backendApiGet', {
token: token,
path: `/api/nginx/proxy-hosts/${hostId}`,
}).then((data) => {
cy.validateSwaggerSchema('get', 200, '/nginx/proxy-hosts/{hostID}', data);
expect(data.locations[0]).to.have.property('path', '/dashboard');
expect(data.locations[0]).to.have.property('access_list_id', alphaListId);
expect(data.locations[1]).to.have.property('path', '/profile');
expect(data.locations[1]).to.have.property('access_list_id', betaListId);
});
});
it('Should not require auth for the host root', () => {
expectPage('/', null, 'this is the index page');
});
it('Should only accept the alpha credentials on /dashboard', () => {
expectStatus('/dashboard', null, '401');
expectStatus('/dashboard', beta, '401');
expectPage('/dashboard', alpha, 'this is the dashboard page');
});
it('Should only accept the beta credentials on /profile', () => {
expectStatus('/profile', null, '401');
expectStatus('/profile', alpha, '401');
expectPage('/profile', beta, 'this is the profile page');
});
it('Should apply access list credential changes to locations using it', () => {
const newPassword = 'alpha-pass-changed';
cy.task('backendApiPut', {
token: token,
path: `/api/nginx/access-lists/${alphaListId}`,
data: {
name: alpha.name,
satisfy_any: false,
pass_auth: false,
items: [
{
username: alpha.username,
password: newPassword,
}
],
clients: [],
}
}).then((data) => {
cy.validateSwaggerSchema('put', 200, '/nginx/access-lists/{listID}', data);
expect(data).to.have.property('id', alphaListId);
});
expectStatus('/dashboard', alpha, '401');
expectPage('/dashboard', { username: alpha.username, password: newPassword }, 'this is the dashboard page');
alpha.password = newPassword;
// Other locations are unaffected
expectStatus('/profile', null, '401');
expectPage('/profile', beta, 'this is the profile page');
});
it('Should inherit the host access list on locations without their own', () => {
// Host uses beta, /dashboard overrides with alpha, /missing has none and should inherit beta
cy.task('backendApiPut', {
token: token,
path: `/api/nginx/proxy-hosts/${hostId}`,
data: {
access_list_id: betaListId,
locations: [
location('/dashboard', alphaListId),
location('/profile', betaListId),
location('/missing', 0),
],
}
}).then((data) => {
// No swagger validation here: with a host access list set, the expanded
// access_list in the response has no proxy_host_count, which the schema requires
expect(data).to.have.property('access_list_id', betaListId);
expect(data.locations[2]).to.have.property('access_list_id', 0);
});
expectStatus('/', null, '401');
expectStatus('/', alpha, '401');
expectPage('/', beta, 'this is the index page');
// examplesite returns 404 for this path, once past the access list
expectStatus('/missing', null, '401');
expectStatus('/missing', alpha, '401');
expectStatus('/missing', beta, '404');
expectStatus('/dashboard', beta, '401');
expectPage('/dashboard', alpha, 'this is the dashboard page');
});
it('Should remove a deleted access list from the host and locations using it', () => {
cy.task('backendApiDelete', {
token: token,
path: `/api/nginx/access-lists/${betaListId}`,
}).then((data) => {
cy.validateSwaggerSchema('delete', 200, '/nginx/access-lists/{listID}', data);
expect(data).to.be.equal(true);
});
cy.task('backendApiGet', {
token: token,
path: `/api/nginx/proxy-hosts/${hostId}`,
}).then((data) => {
expect(data).to.have.property('access_list_id', 0);
expect(data.locations[0]).to.have.property('access_list_id', alphaListId);
expect(data.locations[1]).to.have.property('access_list_id', 0);
expect(data.locations[2]).to.have.property('access_list_id', 0);
});
expectPage('/', null, 'this is the index page');
expectPage('/profile', null, 'this is the profile page');
expectStatus('/missing', null, '404');
// Remaining location access list must still be enforced
expectStatus('/dashboard', null, '401');
expectPage('/dashboard', alpha, 'this is the dashboard page');
});
it('Should be able to delete the proxy host and remaining access list', () => {
cy.task('backendApiDelete', {
token: token,
path: `/api/nginx/proxy-hosts/${hostId}`,
}).then((data) => {
cy.validateSwaggerSchema('delete', 200, '/nginx/proxy-hosts/{hostID}', data);
expect(data).to.be.equal(true);
});
cy.task('backendApiDelete', {
token: token,
path: `/api/nginx/access-lists/${alphaListId}`,
}).then((data) => {
cy.validateSwaggerSchema('delete', 200, '/nginx/access-lists/{listID}', data);
expect(data).to.be.equal(true);
});
});
});
+6
View File
@@ -0,0 +1,6 @@
FROM nginx:stable
RUN rm -rf /etc/nginx/conf.d
COPY nginx /etc/nginx
COPY www /www
RUN chown -R nginx:nginx /www
+29
View File
@@ -0,0 +1,29 @@
server {
listen 80;
server_name website1.example.com website2.example.com website3.example.com;
root /www;
index index.html;
# redirect requests for .html URLs to their extensionless form
if ($request_uri ~ ^/index\.html(\?.*)?$) {
return 301 /$1;
}
if ($request_uri ~ ^/(.+)\.html(\?.*)?$) {
return 301 /$1$2;
}
location / {
try_files $uri $uri.html $uri/ =404;
}
error_page 404 /404.html;
location = /404.html {
internal;
}
# deny access to .htaccess files, if Apache's document root
# concurs with nginx's one
location ~ /\.ht {
deny all;
}
}
+30
View File
@@ -0,0 +1,30 @@
user nginx;
worker_processes auto;
error_log /var/log/nginx/error.log notice;
pid /run/nginx.pid;
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
server_tokens off;
log_format custom_combined '$remote_addr - $remote_user [$time_local] "$host" "$request" $status $body_bytes_sent "$http_referer" "$http_user_agent"';
access_log /var/log/nginx/access.log custom_combined;
sendfile on;
tcp_nopush on;
keepalive_timeout 65;
gzip on;
set_real_ip_from 10.0.0.0/8;
set_real_ip_from 172.16.0.0/12; # Includes Docker subnet
set_real_ip_from 192.168.0.0/16;
include include/*.conf;
include conf.d/*.conf;
include /sites/*/nginx.conf;
}
+9
View File
@@ -0,0 +1,9 @@
<html>
<head>
<title>404</title>
</head>
<body>
404 not found
</body>
</html>
+9
View File
@@ -0,0 +1,9 @@
<html>
<head>
<title>Dashboard</title>
</head>
<body>
this is the dashboard page
</body>
</html>
+9
View File
@@ -0,0 +1,9 @@
<html>
<head>
<title>Index</title>
</head>
<body>
this is the index page
</body>
</html>
+9
View File
@@ -0,0 +1,9 @@
<html>
<head>
<title>Profile</title>
</head>
<body>
this is the profile page
</body>
</html>