view release on metacpan or search on metacpan
skill-namespaced web route with static-file containment asserted (DD-416),
and skill installs whose repo name escapes the skills root (DD-426).
- Security: every page-derived value the web layer places inside an HTML
attribute is now escaped for attribute context (DD-421), and saved
bookmark link segments are percent-encoded for URL context (DD-423).
- Security: state-changing requests are rejected when Origin or Referer is
foreign, on every web tier (DD-422).
- Security: the transient-token denial now depends on the token alone, so a
file value riding along can no longer bypass the 403 (DD-425).
- Security: release tarballs no longer carry dogfood QA evidence, ticket
worktrees (DD-414), or the Hermes runtime state root and its credentials
(DD-432).
- Feature: first-class self-upgrade - dashboard upgrade and d2 upgrade run
the canonical platform installer (DD-386).
- Feature: a real /favicon.ico is served instead of a 404 on every page
load (DD-407).
- Collectors: a timed-out command's full process subtree is terminated
(DD-388); blocked Windows command collectors are interrupted at their
timeout (DD-389); the atomic writer uses a per-process pending name so
concurrent workers stop clobbering each other (DD-418); log append and
rotation share a per-log lock (DD-399) and rotation cuts on entry
The same layered runtime config chain and installed-skill config trees can now
ship `config/api.json` files that authorize selected `/ajax/...` routes for
machine-to-machine callers without forcing a helper login form. The schema is a
JSON object keyed by API client name. Each entry must provide a stored SHA-256
hex digest under `secret` plus an `ajax` array of exact saved Ajax route
paths such as `/ajax/stream.txt` or
`/ajax/example-skill/status.json`. When a non-admin remote request targets one
of those registered `/ajax/...` paths, the caller can send
`X-DD-API-Key: NAME` and `X-DD-API-Secret: RAW-SECRET`. Developer Dashboard
hashes the raw secret with SHA-256, compares it to the stored digest, and
executes the saved Ajax handler when they match. Missing or wrong credentials
for a registered API route return `403` with the JSON body
`{"status":"forbidden"}`. Existing helper-session auth still works on the
same saved Ajax routes, so browser workflows and machine callers can coexist on
one handler without adding a second copy of the route. Like the rest of
`DD-OOP-LAYERS`, runtime `config/api.json` files merge from home to the
deepest active child layer, and installed skills contribute their own layered
`config/api.json` fragments for skill-local saved Ajax routes. The built-in
`dashboard api` command is the supported way to inspect or update the writable
runtime layer for that registry.
An entry carrying `"disabled": true` (or the same flag under `_disabled`)
doc/integration-test-plan.md view on Meta::CPAN
- TT-backed collector icons render from stdout JSON and stay rendered through later config-sync reads instead of reverting to raw `[% ... %]` text
- the web service serves the root editor on `127.0.0.1:7890`
- the browser can load both the editor and a saved fake-project bookmark page from the fake project bookmark directory
- the browser sees sorted shared `nav/*.tt` fragments above the main page body on that fake-project bookmark page
- the browser top-right status strip shows configured collector icons and does not leave stale renamed collector indicators behind
- nested `DD-OOP-LAYERS` collector prompts do not let a child-layer placeholder `missing` state override a healthy inherited parent-layer collector indicator when the child config adds no collector override
- under `DD-OOP-LAYERS`, `dashboard path add` writes only the new child-layer alias delta into the deepest child `config/config.json` instead of copying inherited parent config domains into that file
- bookmark pages can use `fetch_value()`, `stream_value()`, and `stream_data()` helpers against saved `/ajax/...` endpoints on first render
- the installed `/ajax/<file>` route streams early output chunks promptly enough to prove browser-visible progress instead of silent buffering
- skill pages that ship `config/routes.json` emit their declared canonical custom ajax paths, while the smart `/ajax/<repo-name>/...` route still works as the parent compatibility resolver and custom paths stay fallback-only before a normal `404`
- layered `config/api.json` files under both runtime roots and installed skills can authorize selected saved `/ajax/...` routes for machine callers, those callers must present matching `X-DD-API-Key` and `X-DD-API-Secret` headers, helper-session auth...
- the built-in `dashboard api` command can list that effective merged registry, hash raw secrets from `--secret` or `--maybe-secret` before saving them, add and remove exact saved ajax routes, and write only to the deepest writable `config/api.json` ...
- non-loopback access produces `401` with an empty body and without a login page until a helper user exists in the active runtime
- under `dashboard serve --ssl`, plain `http://HOST:PORT/...` requests on the public listener return a same-port `307` redirect to `https://HOST:PORT/...`, the generated cert advertises SAN coverage for `localhost`, `127.0.0.1`, and `::1`, and a brow...
- after a helper user exists, non-loopback access produces the helper login page
- helper logout removes both the helper session and the helper account
- `dashboard stop` leaves no active listener on port `7890`
- `dashboard stop` and `dashboard restart` still control the real serving pid
when the web process has renamed itself into a `starman master` listener
shape, so container lifecycle checks stay attached to the active listener
- interactive `dashboard stop` and `dashboard restart` runs print the full lifecycle task board on `stderr` before work begins, so managed shutdown and startup waits stay visible instead of looking hung
doc/internal-operating-checklist.md view on Meta::CPAN
4. Use the full system before claiming a blocker.
5. Treat a fix as incomplete until the result is verified and the lesson is captured.
6. Do not wait for a `yes` when the next action is already justified by the task, the current findings, or the repo rules.
## 2. Before Touching Code
1. Read the governing docs and the task-specific docs fully before making assumptions.
2. Confirm the work stays out of `OLD_CODE`.
3. Remove any legacy/company-specific logic from the solution space:
`Companies House`, `EWF`, `XMLGW`, `CHIPS`, `Tuxedo`, `CHS`, `Grover`,
`CIDEV`, `PBS`, credentials, and sensitive-data flows do not belong in core.
4. Identify the runtime surfaces affected:
CLI, browser, collectors, auth, routing, packaging, Windows, Docker,
layering, prompt, or release workflow.
5. Review related entries in `MISTAKE.md` and `FIXED_BUGS.md` before implementing.
## 3. Design And Implementation Rules
1. Follow TDD.
2. Add or update tests under `t/` first where practical.
3. Keep `dashboard` thin and lazy.
doc/security.md view on Meta::CPAN
- home-runtime directories under `~/.developer-dashboard` are created and tightened to `0700`
- home-runtime files under `~/.developer-dashboard` are written and tightened to `0600`, while owner-executable scripts stay at `0700`
- helper access requires a stored helper account
- helper usernames are restricted to safe filename characters
- helper passwords must be at least 8 characters long
- helper user files and helper session files are written with `0600` permissions
- helper sessions are bound to the originating remote address
- helper sessions expire automatically after 12 hours
- session cookies use `HttpOnly` and `SameSite=Strict`
- HTTP responses add `Content-Security-Policy`, `X-Frame-Options`, `X-Content-Type-Options`, `Referrer-Policy`, and `Cache-Control: no-store`
- state-changing requests (`POST`, `PUT`, `DELETE`, `PATCH`) are refused with an empty-bodied `403` when `Origin` or `Referer` names anything other than this dashboard or a trusted local alias; the check runs before trust-tier classification, so it c...
- that `Origin` check cannot defend a `GET`, so the same choke point additionally refuses, **on every method**, any request the browser labelled `Sec-Fetch-Site: cross-site` or `same-site` unless the accompanying `Origin`/`Referer` names this dashboa...
- every page-derived value placed inside a quoted HTML attribute (the page chrome's play, source, and share URLs, the bookmark editor's form action, nav ids, and the host link) is escaped in attribute context, so both quote characters are neutralised...
- generated saved-bookmark links percent-encode every path segment of the bookmark id (URL-context output encoding, with `/` separators kept raw), so ids carrying `#`, `?`, `%`, spaces, or quote characters stay valid, reachable URLs and reach the att...
## Cross-Site Request Defense
The dashboard's automatic loopback-admin tier authorizes on the remote address
alone. There is no cookie in that decision, so `SameSite=Strict` protects
nothing there: any page the operator happens to visit can aim a request at
`127.0.0.1` and it arrives already authorized. Two independent checks close
doc/security.md view on Meta::CPAN
`actions/checkout` survives and `actions-setup-perl` v1.31.3 does not: it fails
with `Error: unable to get latest version`. The `Setup Perl` step therefore
failed on every CI run for ten days, skipping the suite, the coverage gate and
both dependency audits, while the guardrail test read the comments and certified
the migration as complete. A version floor read from a comment cannot detect a
comment that lies, which is why the resolving check is a separate gate.
Runtime declarations are part of this review. Every pinned action now declares
`node24`, so the jobs no longer set `FORCE_JAVASCRIPT_ACTIONS_TO_NODE24`,
GitHub's transitional shim for rerouting `node20` actions. Steps that handle
credentials â the GHCR login in particular â are held on `node24`-native release
lines, and `t/34-scorecard-guardrails.t` gates those version floors rather than
only the pin format.
## Release Provenance
A `vX.XX` tag push publishes the distribution tarball, its SHA-256 checksum, and
a detached GPG signature. Those three assets prove integrity and authorship, but
they say nothing about *where the artifact came from*, so the release also
carries SLSA build provenance generated by `actions/attest-build-provenance`.
integration/windows/run-qemu-windows-smoke.sh view on Meta::CPAN
# Input: the configured Dockur workdir path.
# Output: deletes the persisted storage, shared, and OEM subdirectories.
rm -rf \
"$WINDOWS_DOCKUR_WORKDIR/storage" \
"$WINDOWS_DOCKUR_WORKDIR/shared" \
"$WINDOWS_DOCKUR_WORKDIR/oem"
}
run_prepared_qemu_smoke() {
# Purpose: boot a prepared Windows image and run the Strawberry smoke over SSH.
# Input: a prepared qcow2 image, SSH credentials, and a built tarball.
# Output: exits zero only when the in-guest Windows smoke passes.
if [[ -z "$WINDOWS_IMAGE" ]]; then
echo "WINDOWS_IMAGE is required and must point to a prepared Windows qcow2 image" >&2
exit 1
fi
if [[ ! -f "$WINDOWS_IMAGE" ]]; then
echo "Windows image does not exist: $WINDOWS_IMAGE" >&2
exit 1
fi
lib/Developer/Dashboard.pm view on Meta::CPAN
The same layered runtime config chain and installed-skill config trees can now
ship C<config/api.json> files that authorize selected C</ajax/...> routes for
machine-to-machine callers without forcing a helper login form. The schema is a
JSON object keyed by API client name. Each entry must provide a stored SHA-256
hex digest under C<secret> plus an C<ajax> array of exact saved Ajax route
paths such as C</ajax/stream.txt> or
C</ajax/example-skill/status.json>. When a non-admin remote request targets one
of those registered C</ajax/...> paths, the caller can send
C<X-DD-API-Key: NAME> and C<X-DD-API-Secret: RAW-SECRET>. Developer Dashboard
hashes the raw secret with SHA-256, compares it to the stored digest, and
executes the saved Ajax handler when they match. Missing or wrong credentials
for a registered API route return C<403> with the JSON body
C<{"status":"forbidden"}>. Existing helper-session auth still works on the
same saved Ajax routes, so browser workflows and machine callers can coexist on
one handler without adding a second copy of the route. Like the rest of
C<DD-OOP-LAYERS>, runtime C<config/api.json> files merge from home to the
deepest active child layer, and installed skills contribute their own layered
C<config/api.json> fragments for skill-local saved Ajax routes. The built-in
C<dashboard api> command is the supported way to inspect or update the writable
runtime layer for that registry.
An entry carrying C<"disabled": true> (or the same flag under C<_disabled>)
lib/Developer/Dashboard/PageDocument.pm view on Meta::CPAN
if (!window.__dashboardAjaxSingletons) window.__dashboardAjaxSingletons = {};
if (window.__dashboardAjaxSingletons[name]) return;
window.__dashboardAjaxSingletons[name] = true;
window.addEventListener('pagehide', function() {
let url = '/ajax/singleton/stop?singleton=' + encodeURIComponent(name);
if (navigator.sendBeacon) {
navigator.sendBeacon(url, '');
return;
}
if (window.fetch) {
fetch(url, { method: 'POST', keepalive: true, credentials: 'same-origin' }).catch(function () {});
}
});
}
function dashboard_target_nodes(target) {
if (!target) return [];
if (typeof target === 'string') return Array.prototype.slice.call(document.querySelectorAll(target));
if (target instanceof Element) return [target];
if (target.length && typeof target !== 'string') return Array.prototype.slice.call(target);
return [];
}
lib/Developer/Dashboard/PageDocument.pm view on Meta::CPAN
if (node.tagName === 'INPUT' || node.tagName === 'TEXTAREA') {
node.value = rendered;
return;
}
node.textContent = rendered;
});
return rendered;
}
function fetch_value(url, target, options, formatter) {
if (!url || !window.fetch) return Promise.resolve('');
let settings = Object.assign({ credentials: 'same-origin' }, (options && options.fetch) || {});
return window.fetch(url, settings).then(function(response) {
if (!response.ok) throw new Error('Request failed with status ' + response.status);
if (options && options.type === 'json') return response.text();
return response.text();
}).then(function(value) {
return dashboard_write_target(target, value, options || {}, formatter);
});
}
function dashboard_stream_settings(options) {
let fetchOptions = (options && options.fetch) || {};
let method = fetchOptions.method || options.method || 'GET';
let body = typeof fetchOptions.body !== 'undefined' ? fetchOptions.body : (typeof options.body !== 'undefined' ? options.body : null);
let headers = fetchOptions.headers || options.headers || {};
let credentials = fetchOptions.credentials || options.credentials || 'same-origin';
return {
method: method,
body: body,
headers: headers,
credentials: credentials
};
}
function stream_data(url, target, options, formatter) {
if (!url) return Promise.resolve('');
if (!window.XMLHttpRequest) return fetch_value(url, target, options, formatter);
let settings = dashboard_stream_settings(options || {});
return new Promise(function(resolve, reject) {
let xhr = new XMLHttpRequest();
xhr.open(settings.method, url, true);
xhr.withCredentials = settings.credentials !== 'omit';
Object.keys(settings.headers || {}).forEach(function(name) {
xhr.setRequestHeader(name, settings.headers[name]);
});
xhr.onprogress = function () {
dashboard_write_target(target, xhr.responseText, options || {}, formatter);
};
xhr.onload = function () {
if (xhr.status < 200 || xhr.status >= 300) {
reject(new Error('Request failed with status ' + xhr.status));
return;
lib/Developer/Dashboard/Pax/StandaloneRuntime.pm view on Meta::CPAN
if (!window.__dashboardAjaxSingletons) window.__dashboardAjaxSingletons = {};
if (window.__dashboardAjaxSingletons[name]) return;
window.__dashboardAjaxSingletons[name] = true;
window.addEventListener('pagehide', function() {
let url = '/ajax/singleton/stop?singleton=' + encodeURIComponent(name);
if (navigator.sendBeacon) {
navigator.sendBeacon(url, '');
return;
}
if (window.fetch) {
fetch(url, { method: 'POST', keepalive: true, credentials: 'same-origin' }).catch(function () {});
}
});
}
function dashboard_target_nodes(target) {
if (!target) return [];
if (typeof target === 'string') return Array.prototype.slice.call(document.querySelectorAll(target));
if (target instanceof Element) return [target];
if (target.length && typeof target !== 'string') return Array.prototype.slice.call(target);
return [];
}
lib/Developer/Dashboard/Pax/StandaloneRuntime.pm view on Meta::CPAN
if (node.tagName === 'INPUT' || node.tagName === 'TEXTAREA') {
node.value = rendered;
return;
}
node.textContent = rendered;
});
return rendered;
}
function fetch_value(url, target, options, formatter) {
if (!url || !window.fetch) return Promise.resolve('');
let settings = Object.assign({ credentials: 'same-origin' }, (options && options.fetch) || {});
return window.fetch(url, settings).then(function(response) {
if (!response.ok) throw new Error('Request failed with status ' + response.status);
if (options && options.type === 'json') return response.text();
return response.text();
}).then(function(value) {
return dashboard_write_target(target, value, options || {}, formatter);
});
}
function dashboard_stream_settings(options) {
let fetchOptions = (options && options.fetch) || {};
let method = fetchOptions.method || options.method || 'GET';
let body = typeof fetchOptions.body !== 'undefined' ? fetchOptions.body : (typeof options.body !== 'undefined' ? options.body : null);
let headers = fetchOptions.headers || options.headers || {};
let credentials = fetchOptions.credentials || options.credentials || 'same-origin';
return {
method: method,
body: body,
headers: headers,
credentials: credentials
};
}
function stream_data(url, target, options, formatter) {
if (!url) return Promise.resolve('');
if (!window.XMLHttpRequest) return fetch_value(url, target, options, formatter);
let settings = dashboard_stream_settings(options || {});
return new Promise(function(resolve, reject) {
let xhr = new XMLHttpRequest();
xhr.open(settings.method, url, true);
xhr.withCredentials = settings.credentials !== 'omit';
Object.keys(settings.headers || {}).forEach(function(name) {
xhr.setRequestHeader(name, settings.headers[name]);
});
xhr.onprogress = function () {
dashboard_write_target(target, xhr.responseText, options || {}, formatter);
};
xhr.onload = function () {
if (xhr.status < 200 || xhr.status >= 300) {
reject(new Error('Request failed with status ' + xhr.status));
return;
lib/Developer/Dashboard/Web/App.pm view on Meta::CPAN
# forbidden header name, so page script can neither forge nor suppress it.
# `same-origin` is the dashboard's own page and `none` is a user-initiated load
# (typed URL, bookmark, browser start-up), so both stay outside the check.
my %FOREIGN_FETCH_SITES = map { $_ => 1 } qw(cross-site same-site);
# _csrf_rejection_response(%args)
# Cross-site request forgery choke point for every request. Browsers
# unconditionally attach an Origin header to cross-site state-changing
# requests (and legacy flows carry a Referer), so a foreign Origin/Referer
# identifies an attack regardless of trust tier: the loopback-admin shortcut,
# an ambient helper session cookie, and even valid machine credentials must all
# refuse to act on it. Requests carrying neither header are allowed because
# non-browser machine clients (curl, registered x-dd-api-key consumers) send
# neither, while a hostile page cannot suppress the Origin header on a
# cross-site state-changing request. A GET needs the separate fetch-metadata
# check, which runs first and on every method.
# Input: normalized request method and headers (host, origin, referer,
# sec-fetch-site).
# Output: 403 empty-body response array reference when the browser context is
# foreign, otherwise undef and the request proceeds to tier dispatch.
sub _csrf_rejection_response {
lib/Developer/Dashboard/Web/App.pm view on Meta::CPAN
Cross-site request forgery defense: every state-changing request (POST, PUT,
DELETE, PATCH) passes one origin check before any trust-tier dispatch. When
the request carries an C<Origin> header (or, absent that, a C<Referer>), its
authority must equal the request's own C<Host> header or name a trusted local
alias â a numeric loopback literal, the localhost hostname family, or a
configured C<web.ssl_subject_alt_names> entry, the same alias semantics the
loopback-admin trust check applies. Anything else, including the opaque
C<Origin: null>, is refused with an empty 403 on every tier: the
loopback-admin shortcut, helper sessions, and the machine API tier alike,
because browsers attach ambient credentials and loopback reachability to
cross-site requests automatically. Requests with neither header keep working,
since non-browser machine clients send neither while browsers always attach
C<Origin> to cross-site state-changing requests. The check also holds behind
the SSL front-proxy, which forwards TLS bytes unmodified, so the backend
compares against the browser's own C<Host> header.
That origin check cannot defend a C<GET>, because browsers omit C<Origin> on
same-origin GETs and a hostile page can drop its C<Referer> with a referrer
policy â yet C</ajax/E<lt>fileE<gt>> runs an operator-written saved handler as
a child process from a plain GET, on a tier that needs no cookie. So the same
t/08-web-update-coverage.t view on Meta::CPAN
print {$skill_api} qq|{"skill-client":{"secret":"@{[ sha256_hex('skill-secret') ]}","ajax":["/ajax/dancer-route-skill/bar"]}}|;
close $skill_api or die "Unable to close skill api.json: $!";
my $api_host = 'dashboard-helper.example:7890';
my ( $registered_no_api_code, $registered_no_api_type, $registered_no_api_body ) = @{ $app->handle(
path => '/ajax/stream.txt',
query => 'type=text',
remote_addr => '127.0.0.1',
headers => { host => $api_host },
) };
is( $registered_no_api_code, 403, 'registered remote ajax route rejects requests that omit API credentials' );
like( $registered_no_api_type, qr/application\/json/, 'registered remote ajax route returns JSON when API credentials are missing' );
is_deeply( json_decode( decode_body_text($registered_no_api_body) ), { status => 'forbidden' }, 'registered remote ajax route returns the explicit forbidden JSON payload when API credentials are missing' );
my ( $registered_bad_api_code, undef, $registered_bad_api_body ) = @{ $app->handle(
path => '/ajax/stream.txt',
query => 'type=text',
remote_addr => '127.0.0.1',
headers => {
host => $api_host,
'x-dd-api-key' => 'runtime-client',
'x-dd-api-secret' => 'wrong-secret',
},
t/08-web-update-coverage.t view on Meta::CPAN
my ( $skill_api_code, $skill_api_type, $skill_api_body ) = @{ $app->handle(
path => '/ajax/dancer-route-skill/bar',
query => '',
remote_addr => '127.0.0.1',
headers => {
host => $api_host,
'x-dd-api-key' => 'skill-client',
'x-dd-api-secret' => 'skill-secret',
},
) };
is( $skill_api_code, 200, 'installed skill config/api.json can authorize its ajax routes through API credentials' );
like( $skill_api_type, qr/application\/json/, 'skill API auth keeps the skill ajax response content type' );
is( decode_body_text( drain_stream_body($skill_api_body) ), qq|{"route":"dancer-top"}\n|, 'installed skill API auth streams the skill ajax response body' );
my $psgi_app = Developer::Dashboard::Web::DancerApp->build_psgi_app( app => $app );
Local::PSGITest::test_psgi $psgi_app, sub {
my ($cb) = @_;
my $res = $cb->(
GET(
'http://127.0.0.1/ajax/stream.txt?type=text',
Host => $api_host,
t/101-web-app-coverage.t view on Meta::CPAN
actions => $actions,
auth => $auth,
config => $api_config,
pages => $store,
prompt => $prompt,
runtime => $runtime,
sessions => $sessions,
);
my $ah = 'helper.example:7890';
# registered ajax route, no credentials -> forbidden (api path, no session)
is( $app_api->handle( path => '/ajax/demo.json', query => 'type=json', remote_addr => '203.0.113.7', headers => { host => $ah } )->[0], 403, 'registered api route without creds -> 403' );
# registered ajax route with valid credentials -> 200
is( $app_api->handle( path => '/ajax/demo.json', query => 'type=json', remote_addr => '203.0.113.7', headers => { host => $ah, 'x-dd-api-key' => 'good', 'x-dd-api-secret' => 'sekret' } )->[0], 200, 'registered api route with creds -> 200' );
# unregistered route, no helper users yet -> helper access disabled (401 empty)
is( $app_api->handle( path => '/app/welcome', query => '', remote_addr => '203.0.113.7', headers => { host => $ah } )->[0], 401, 'outsider before helper users -> 401' );
# create a helper user; now outsiders get a login page
$auth->add_user( username => 'helper', password => 'helper-pass-123', role => 'helper' );
is( $app_api->handle( path => '/app/welcome', query => 'x=1', remote_addr => '203.0.113.7', headers => { host => $ah } )->[0], 401, 'outsider after helper users -> login 401' );
# bad + good login
is( $app_api->handle( path => '/login', method => 'POST', body => 'username=helper&password=wrong', remote_addr => '203.0.113.7', headers => { host => $ah } )->[0], 401, 'bad login -> 401' );
t/105-web-app-coverage-2.t view on Meta::CPAN
actions => $actions,
auth => $auth,
config => $api_config,
pages => $store,
prompt => $prompt,
runtime => $runtime,
sessions => $sessions,
);
my $ah = 'helper.example:7890';
# registered ajax route, no credentials -> forbidden (api path, no session)
is( $app_api->handle( path => '/ajax/demo.json', query => 'type=json', remote_addr => '203.0.113.7', headers => { host => $ah } )->[0], 403, 'registered api route without creds -> 403' );
# registered ajax route with valid credentials -> 200
is( $app_api->handle( path => '/ajax/demo.json', query => 'type=json', remote_addr => '203.0.113.7', headers => { host => $ah, 'x-dd-api-key' => 'good', 'x-dd-api-secret' => 'sekret' } )->[0], 200, 'registered api route with creds -> 200' );
# unregistered route, no helper users yet -> helper access disabled (401 empty)
is( $app_api->handle( path => '/app/welcome', query => '', remote_addr => '203.0.113.7', headers => { host => $ah } )->[0], 401, 'outsider before helper users -> 401' );
# create a helper user; now outsiders get a login page
$auth->add_user( username => 'helper', password => 'helper-pass-123', role => 'helper' );
is( $app_api->handle( path => '/app/welcome', query => 'x=1', remote_addr => '203.0.113.7', headers => { host => $ah } )->[0], 401, 'outsider after helper users -> login 401' );
# bad + good login
is( $app_api->handle( path => '/login', method => 'POST', body => 'username=helper&password=wrong', remote_addr => '203.0.113.7', headers => { host => $ah } )->[0], 401, 'bad login -> 401' );
t/130-csrf-origin-defense.t view on Meta::CPAN
host => $HELPER_HOST,
origin => 'http://evil.example',
);
is( $proxied_foreign_code, 403, 'a foreign-Origin login POST through the SSL front-proxy is rejected' );
}
}
# ---------------------------------------------------------------------------
# 3. Ajax x-dd-api-key machine tier. Machine clients send no Origin/Referer
# and must keep working; a browser-carried foreign Origin must be rejected
# even when the request presents valid API credentials.
# ---------------------------------------------------------------------------
{
wfile(
File::Spec->catfile( $paths->dashboards_root, 'legacy-ajax' ),
"TITLE: LA\n:--------------------------------------------------------------------------------:\nBOOKMARK: legacy-ajax\n:--------------------------------------------------------------------------------:\nHTML: <script>var configs={};</script>\...
0644,
);
request( path => '/app/legacy-ajax', remote_addr => '127.0.0.1', host => $ADMIN_HOST );
my $api_json = sprintf '{"machine":{"secret":"%s","ajax":["/ajax/demo.json"]}}', sha256_hex('sekret');
t/130-csrf-origin-defense.t view on Meta::CPAN
my ($machine_code) = request(
app => $api_app,
path => '/ajax/demo.json',
method => 'POST',
query => 'type=json',
remote_addr => '203.0.113.7',
host => $HELPER_HOST,
api_key => 'machine',
api_secret => 'sekret',
);
is( $machine_code, 200, 'a headerless machine POST with valid api credentials still works' );
my ( $code, $body ) = request(
app => $api_app,
path => '/ajax/demo.json',
method => 'POST',
query => 'type=json',
remote_addr => '203.0.113.7',
host => $HELPER_HOST,
api_key => 'machine',
api_secret => 'sekret',
origin => 'http://evil.example',
);
is( $code, 403, 'foreign-Origin POST is rejected on the api tier even with valid credentials' );
is( $body, '', 'the api-tier cross-site rejection body is empty, not the api-forbidden JSON' );
($code) = request(
app => $api_app,
path => '/ajax/demo.json',
method => 'POST',
query => 'type=json',
remote_addr => '203.0.113.7',
host => $HELPER_HOST,
api_key => 'machine',
t/130-csrf-origin-defense.t view on Meta::CPAN
app => $api_app,
path => '/ajax/demo.json',
method => 'POST',
query => 'type=json',
remote_addr => '203.0.113.7',
host => $HELPER_HOST,
api_key => 'machine',
api_secret => 'sekret',
origin => 'http://helper.example:7890',
);
is( $code, 200, 'a same-origin POST with valid api credentials still works' );
}
# ---------------------------------------------------------------------------
# 4. Configured SAN aliases are permitted origins. web.ssl_subject_alt_names
# entries are the operator's declared alternate names for this dashboard,
# so a POST whose Origin names one of them is same-site, mirroring the
# loopback-alias semantics the trust tier already applies to Host.
# ---------------------------------------------------------------------------
{
$config->save_global_web_settings( ssl_subject_alt_names => ['mybox.local'] );
t/130-csrf-origin-defense.t view on Meta::CPAN
t/130-csrf-origin-defense.t - cross-site requests cannot change dashboard state
=head1 PURPOSE
This test is the cross-site request forgery contract for the web layer. Every
state-changing request (POST, PUT, DELETE, PATCH) that carries a foreign
browser context â an Origin header naming another site, an opaque
C<Origin: null>, or, when Origin is absent, a Referer naming another site â
must be refused with an empty 403 before any tier logic runs. The check must
hold identically on the loopback-admin shortcut, on an authenticated helper
session, and on the ajax machine tier even when valid API credentials are
presented, because a browser attaches cookies and the ambient loopback trust
automatically to cross-site requests.
=head1 WHY IT EXISTS
DD-422 closed the gap DD-421 documented: the loopback-admin tier authorizes
with no cookie at all, so any web page an operator visited could fire
state-changing requests at 127.0.0.1 and they would execute with admin trust.
Browsers always attach an Origin header to cross-site state-changing
requests, so rejecting foreign origins at one choke point blocks that class
t/142-action-pin-provenance.t view on Meta::CPAN
node24. C<actions/checkout> survives that; C<shogo82148/actions-setup-perl>
v1.31.3 fails with C<Error: unable to get latest version>. The C<Setup Perl>
step failed on every CI run for ten days - skipping the suite, the coverage gate
and both dependency audits - while the guardrail test certified the migration as
complete. C<script/audit-action-pins> is the check that resolves the SHA, and
this file is what keeps that check honest.
=head1 WHEN TO USE
Run it whenever C<script/audit-action-pins> changes, and as part of the full
suite. It needs no network and no credentials.
=head1 HOW TO USE
prove -lv t/142-action-pin-provenance.t
prove -lr t
=head1 WHAT USES IT
The full C<prove -lr t> gate, and the CI workflow that runs it.
t/34-scorecard-guardrails.t view on Meta::CPAN
like( $codeql, qr/uses:\s*github\/codeql-action\/analyze\@[0-9a-f]{40}/, 'CodeQL analyze action is pinned by full SHA' );
my $package_workflow = _slurp('.github/workflows/package-ghcr.yml');
unlike( $package_workflow, qr/^permissions:\s*$(?:\n^[^\n]*:\s*write\s*$)+/ms, 'packaging workflow does not use top-level write permissions' );
like( $package_workflow, qr/jobs:\n\s+package:\n(?:.+\n)*?\s+permissions:\n(?:.+\n)*?\s+packages:\s*write\b/ms, 'packaging workflow grants package publish access only at the job level' );
like( $package_workflow, qr/ghcr\.io/i, 'packaging workflow publishes to GHCR' );
like( $package_workflow, qr/uses:\s*docker\/build-push-action\@[0-9a-f]{40}/, 'docker build-push action is pinned by full SHA' );
like( $package_workflow, qr/uses:\s*docker\/login-action\@[0-9a-f]{40}/, 'docker login action is pinned by full SHA' );
# The login step is the only one in the pipeline that handles registry
# credentials, so its execution must not depend on GitHub rerouting a stale
# runtime for it: the docker/login-action v3 line declares node20 while the v4
# line declares node24 natively. Keep the pin on v4+, and keep an auditable
# vX.Y.Z comment beside the SHA so a reviewer can tell which upstream release the
# 40-hex pin resolves to without a network round-trip. DD-449 removed the
# FORCE_JAVASCRIPT_ACTIONS_TO_NODE24 shim these jobs used to set, because it is
# documented as removable once no pinned action still declares node20, and none
# does - script/audit-action-pins is what keeps that true.
my ($login_pin_version) = $package_workflow =~ m{uses:\s*docker/login-action\@[0-9a-f]{40}\s+\#\s*v(\d+(?:\.\d+){2})\b};
ok( defined $login_pin_version, 'docker login action pin carries an auditable vX.Y.Z comment beside its SHA' )
or diag('no "# vX.Y.Z" comment found beside the docker/login-action SHA pin');