Skip to content
Open
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
17 changes: 5 additions & 12 deletions .github/workflows/update-live-feed.yml
Original file line number Diff line number Diff line change
Expand Up @@ -171,20 +171,13 @@ jobs:
echo 'Initial push failed (likely non-fast-forward). Rebasing and retrying once.'
git fetch origin main
if git pull --rebase origin main; then
git push
git push --force-with-lease
exit 0
fi
echo 'Rebase conflict detected in generated artifacts; resolving in favor of newly generated files.'
git checkout --theirs live-alerts.json || true
git checkout --theirs data/quarantined-sources.json || true
git checkout --theirs source-quarantine.html || true
git checkout --theirs data/source-remediation-sweep.json || true
git checkout --theirs data/top-20-source-remediation.json || true
git checkout --theirs data/brialert.sqlite || true
git add live-alerts.json data/quarantined-sources.json source-quarantine.html \
data/source-remediation-sweep.json data/top-20-source-remediation.json data/brialert.sqlite || true
git rebase --continue
git push
echo 'Rebase conflict on generated artifacts — aborting rebase and using merge strategy.'
git rebase --abort
git merge origin/main -X ours --no-edit
git push --force-with-lease

verify-live-feed-freshness:
needs: commit-live-feed
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,6 @@ node_modules/
.debug-artifacts/
.DS_Store
Thumbs.db
data/*.sqlite

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Stop ignoring the SQLite artifact committed by CI

Adding data/*.sqlite to .gitignore makes git add data/brialert.sqlite fail in the commit-live-feed job (.github/workflows/update-live-feed.yml), so scheduled feed updates will error whenever the builder outputs that file. This is a regression from the previous behavior where the SQLite artifact could be staged and committed; now the workflow exits non-zero with "path is ignored" instead of publishing refreshed data.

Useful? React with 👍 / 👎.

data/*.sqlite-wal
data/*.sqlite-shm
11 changes: 1 addition & 10 deletions 404.html
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title>Brialert redirect</title>
<meta http-equiv="refresh" content="0; url=/index.html">
<script>
const canonicalTarget = location.pathname.toLowerCase().includes('/brialert')
? '/Brialert/index.html'
: '/index.html';
const query = location.search || '';
const hash = location.hash || '';
if (location.pathname.toLowerCase() !== canonicalTarget.toLowerCase()) {
location.replace(canonicalTarget + query + hash);
}
</script>
<script src="app/redirect.js"></script>
Comment on lines 7 to +8

Copilot AI Apr 15, 2026

Copy link

Choose a reason for hiding this comment

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

meta http-equiv="refresh" content="0; url=/index.html" will likely fire before app/redirect.js finishes downloading/executing, so requests under /Brialert/... can be redirected to the wrong canonical target on slower connections. Prefer making the JS redirect the primary mechanism (remove the 0s meta refresh or delay it), and optionally keep a <noscript> fallback to /index.html.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Use a path-safe redirect script reference on 404 page

Switching from inline redirect logic to <script src="app/redirect.js"> introduces a path-resolution bug for deep URLs: on routes like /Brialert/foo/bar, the browser resolves this to /Brialert/foo/app/redirect.js, so the script fails to load and the canonical /Brialert/index.html redirect logic never runs. In that case users fall back to the meta refresh target /index.html, which can send them to the wrong site root.

Useful? React with 👍 / 👎.

<style>
body {
margin: 0;
Expand Down
16 changes: 9 additions & 7 deletions api/_lib/admin-session.js
Original file line number Diff line number Diff line change
Expand Up @@ -95,8 +95,12 @@ function verifySignedToken(rawToken) {
const expectedSignature = signToken(encodedPayload);
const expectedBuffer = Buffer.from(expectedSignature);
const receivedBuffer = Buffer.from(encodedSignature);
if (expectedBuffer.length !== receivedBuffer.length) return null;
if (!crypto.timingSafeEqual(expectedBuffer, receivedBuffer)) return null;
const maxLen = Math.max(expectedBuffer.length, receivedBuffer.length);
const paddedExpected = Buffer.alloc(maxLen);
const paddedReceived = Buffer.alloc(maxLen);
expectedBuffer.copy(paddedExpected);
receivedBuffer.copy(paddedReceived);
if (!crypto.timingSafeEqual(paddedExpected, paddedReceived) || expectedBuffer.length !== receivedBuffer.length) return null;
try {
const payload = JSON.parse(base64UrlDecode(encodedPayload));
if (!payload || typeof payload !== 'object') return null;
Expand Down Expand Up @@ -146,17 +150,15 @@ function serializeCookie(parts, value, maxAgeSeconds) {
export function applyCorsHeaders(request, response, methods) {
const requestOrigin = normaliseOrigin(request?.headers?.origin);
const allowedOrigins = new Set(getAllowedOrigins());
if (requestOrigin && allowedOrigins.has(requestOrigin)) {
const originAllowed = !requestOrigin || allowedOrigins.has(requestOrigin);
if (requestOrigin && originAllowed) {
response.setHeader('Access-Control-Allow-Origin', requestOrigin);
response.setHeader('Access-Control-Allow-Credentials', 'true');
response.setHeader('Vary', 'Origin');
}
response.setHeader('Access-Control-Allow-Methods', methods);
response.setHeader('Access-Control-Allow-Headers', 'Content-Type');
if (request?.method === 'OPTIONS') {
return !requestOrigin || allowedOrigins.has(requestOrigin);
}
return true;
return originAllowed;
Comment on lines 150 to +161

Copilot AI Apr 15, 2026

Copy link

Choose a reason for hiding this comment

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

normaliseOrigin() returns '' for values like Origin: null (opaque origins) or other unparsable Origin headers. With the new “deny when applyCorsHeaders() returns false” gating, those requests will now be treated as having no origin and be allowed. Consider explicitly treating Origin: null / invalid Origin headers as disallowed when an Origin header is present, so the allowlist can’t be bypassed via an opaque origin.

Copilot uses AI. Check for mistakes.
}

export function readAdminSession(request) {
Expand Down
55 changes: 51 additions & 4 deletions api/_lib/github-persistence.js
Original file line number Diff line number Diff line change
Expand Up @@ -66,18 +66,62 @@ function encodeBase64(plainText) {
return Buffer.from(plainText, 'utf8').toString('base64');
}

function parseJsonFile(raw, filePath) {
function parseJsonFile(raw) {
try {
return JSON.parse(raw);
} catch (error) {
} catch {
throw new ApiError(
'persistence-failure',
`Invalid JSON in ${filePath}: ${error instanceof Error ? error.message : String(error)}`,
'Repository data file contains invalid JSON.',
500
);
}
}

/**
* Returns true when a hostname looks like a private, loopback, link-local, or
* otherwise non-routable address that must be rejected to prevent SSRF.
* Checks the raw hostname string — no DNS resolution is performed, so only
* literal IP addresses and well-known internal hostnames are caught.
*/
function isPrivateOrReservedHost(hostname) {
const host = String(hostname || '').toLowerCase();
if (!host) return true;

// Loopback / localhost
if (host === 'localhost' || host === '[::1]') return true;

// Strip IPv6 brackets for numeric checks
const bare = host.startsWith('[') && host.endsWith(']') ? host.slice(1, -1) : host;

// IPv6 loopback & link-local
if (bare === '::1' || bare.startsWith('fe80:') || bare.startsWith('fc00:') || bare.startsWith('fd00:')) return true;

// IPv4 dotted-quad checks
const ipv4Parts = bare.split('.');
if (ipv4Parts.length === 4 && ipv4Parts.every((p) => /^\d{1,3}$/.test(p))) {
const octets = ipv4Parts.map(Number);
if (octets.some((o) => o > 255)) return true; // malformed — block as suspicious
const [a, b] = octets;
if (a === 127) return true; // 127.0.0.0/8 loopback
if (a === 10) return true; // 10.0.0.0/8 private
if (a === 172 && b >= 16 && b <= 31) return true; // 172.16.0.0/12 private
if (a === 192 && b === 168) return true; // 192.168.0.0/16 private
if (a === 169 && b === 254) return true; // 169.254.0.0/16 link-local
if (a === 0) return true; // 0.0.0.0/8
if (a === 100 && b >= 64 && b <= 127) return true; // 100.64.0.0/10 CGN shared
if (a === 198 && (b === 18 || b === 19)) return true; // 198.18.0.0/15 benchmarking
if (a >= 224) return true; // 224+ multicast / reserved
}

// Block common internal / metadata hostnames
if (host.endsWith('.local') || host.endsWith('.internal') || host.endsWith('.localhost')) return true;
// Cloud provider metadata endpoints
if (host === '169.254.169.254' || host === 'metadata.google.internal') return true;

return false;
}

export function validateAbsoluteHttpUrl(value, code = 'invalid-url') {
const trimmed = String(value || '').trim();
if (!trimmed) {
Expand All @@ -92,6 +136,9 @@ export function validateAbsoluteHttpUrl(value, code = 'invalid-url') {
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
throw new ApiError(code, 'Replacement URL must use http or https.', 400);
}
if (isPrivateOrReservedHost(parsed.hostname)) {
throw new ApiError(code, 'URL must not point to a private or internal network address.', 400);
}
return parsed.toString();
}

Expand Down Expand Up @@ -120,7 +167,7 @@ export async function loadJsonFile(pathName) {
config,
path: pathName,
sha: payload.sha,
data: parseJsonFile(content, pathName)
data: parseJsonFile(content)
};
}

Expand Down
8 changes: 5 additions & 3 deletions api/approve-source.js
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,9 @@ async function resolveShardPath(config, approvedSource, shardPaths) {
}

export default async function handler(request, response) {
applyCorsHeaders(request, response, 'POST,OPTIONS');
if (!applyCorsHeaders(request, response, 'POST,OPTIONS')) {
return response.status(403).json({ ok: false, error: 'origin-not-allowed', detail: 'Cross-origin request from disallowed origin.' });
}
if (request.method === 'OPTIONS') {
response.setHeader('Allow', 'POST,OPTIONS');
return response.status(204).end();
Expand Down Expand Up @@ -251,8 +253,8 @@ export default async function handler(request, response) {
try {
await dispatchWorkflow(requestsFile.config, FEED_WORKFLOW_FILENAME);
workflowTriggered = true;
} catch (error) {
workflowMessage = error instanceof Error ? error.message : String(error);
} catch {
workflowMessage = 'Feed refresh workflow could not be triggered.';
}
}

Expand Down
4 changes: 3 additions & 1 deletion api/auth/logout.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ import {
} from '../_lib/admin-session.js';

export default async function handler(request, response) {
applyCorsHeaders(request, response, 'POST,OPTIONS');
if (!applyCorsHeaders(request, response, 'POST,OPTIONS')) {
return response.status(403).json({ ok: false, error: 'origin-not-allowed', detail: 'Cross-origin request from disallowed origin.' });
}
if (request.method === 'OPTIONS') {
return response.status(204).end();
}
Expand Down
4 changes: 3 additions & 1 deletion api/auth/session.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,9 @@ function startLoginUrlFor(originHint) {
}

export default async function handler(request, response) {
applyCorsHeaders(request, response, 'GET,OPTIONS');
if (!applyCorsHeaders(request, response, 'GET,OPTIONS')) {
return response.status(403).json({ ok: false, error: 'origin-not-allowed', detail: 'Cross-origin request from disallowed origin.' });
}
if (request.method === 'OPTIONS') {
return response.status(204).end();
}
Expand Down
4 changes: 3 additions & 1 deletion api/quarantined-sources.js
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,9 @@ function sanitiseRequests(rawRequests) {
}

export default async function handler(request, response) {
applyCorsHeaders(request, response, 'GET,OPTIONS');
if (!applyCorsHeaders(request, response, 'GET,OPTIONS')) {
return response.status(403).json({ ok: false, error: 'origin-not-allowed', detail: 'Cross-origin request from disallowed origin.' });
}
if (request.method === 'OPTIONS') {
response.setHeader('Allow', 'GET,OPTIONS');
return response.status(204).end();
Expand Down
4 changes: 3 additions & 1 deletion api/request-source.js
Original file line number Diff line number Diff line change
Expand Up @@ -235,7 +235,9 @@ function sortNewestFirst(items) {
}

export default async function handler(request, response) {
applyCorsHeaders(request, response, 'GET,POST,OPTIONS');
if (!applyCorsHeaders(request, response, 'GET,POST,OPTIONS')) {
return response.status(403).json({ ok: false, error: 'origin-not-allowed', detail: 'Cross-origin request from disallowed origin.' });
}
if (request.method === 'OPTIONS') {
response.setHeader('Allow', 'GET,POST,OPTIONS');
return response.status(204).end();
Expand Down
8 changes: 5 additions & 3 deletions api/restore-source.js
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,9 @@ async function resolveShardPath(config, restoredSource, shardPaths) {
}

export default async function handler(request, response) {
applyCorsHeaders(request, response, 'POST,OPTIONS');
if (!applyCorsHeaders(request, response, 'POST,OPTIONS')) {
return response.status(403).json({ ok: false, error: 'origin-not-allowed', detail: 'Cross-origin request from disallowed origin.' });
}
if (request.method === 'OPTIONS') {
response.setHeader('Allow', 'POST,OPTIONS');
return response.status(204).end();
Expand Down Expand Up @@ -274,8 +276,8 @@ export default async function handler(request, response) {
try {
await dispatchWorkflow(quarantinedFile.config, FEED_WORKFLOW_FILENAME);
workflowTriggered = true;
} catch (error) {
workflowMessage = error instanceof Error ? error.message : String(error);
} catch {
workflowMessage = 'Feed refresh workflow could not be triggered.';
}
}

Expand Down
24 changes: 12 additions & 12 deletions api/trigger-live-feed.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,9 @@ function sendError(response, error) {
}

export default async function handler(request, response) {
applyCorsHeaders(request, response, 'POST,OPTIONS');
if (!applyCorsHeaders(request, response, 'POST,OPTIONS')) {
return response.status(403).json({ ok: false, error: 'origin-not-allowed', detail: 'Cross-origin request from disallowed origin.' });
}
if (request.method === 'OPTIONS') {
response.setHeader('Allow', 'POST,OPTIONS');
return response.status(204).end();
Expand All @@ -60,6 +62,11 @@ export default async function handler(request, response) {
});
}

// Claim the slot before the async dispatch so concurrent requests see
// the lock immediately (avoids check-then-act race).
const previousTriggerTime = lastTriggerTime;
lastTriggerTime = now;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Roll back trigger lock when dispatch throws

Setting lastTriggerTime = now before dispatch is fine for race prevention, but this path only rolls it back for non-OK HTTP responses; exceptions (e.g., network failure or early config error) leave the lock set and cause subsequent requests to return 429 for 60 seconds even though no workflow was triggered. That blocks legitimate retries after transient failures and makes manual recovery unnecessarily slower.

Useful? React with 👍 / 👎.


const config = getRepoConfig();

const dispatchUrl = `${GITHUB_API_BASE}/repos/${config.owner}/${config.repo}/actions/workflows/${WORKFLOW_FILENAME}/dispatches`;
Expand All @@ -79,26 +86,19 @@ export default async function handler(request, response) {
});

if (!dispatchResponse.ok) {
const errorText = await dispatchResponse.text().catch(() => '');
// Roll back so the next request can retry after a real failure.
lastTriggerTime = previousTriggerTime;
let errorMessage = 'Failed to trigger workflow dispatch.';
try {
const errorPayload = JSON.parse(errorText);
errorMessage = errorPayload.message || errorMessage;
} catch {
// Use default error message
}

if (dispatchResponse.status === 401 || dispatchResponse.status === 403) {
throw new ApiError('unauthorized', errorMessage, 503);
throw new ApiError('unauthorized', 'GitHub API authentication failed.', 503);
}
if (dispatchResponse.status === 404) {
throw new ApiError('workflow-not-found', `Workflow ${WORKFLOW_FILENAME} not found.`, 404);
throw new ApiError('workflow-not-found', 'Workflow not found.', 404);
}
throw new ApiError('trigger-failed', errorMessage, 500);
}

lastTriggerTime = now;

return response.status(200).json({
ok: true,
detail: 'Live feed workflow triggered successfully. New feed data should appear within 2-5 minutes.',
Expand Down
1 change: 0 additions & 1 deletion app/boot/index.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ import {
import { MAP_VIEW_MODES } from '../../shared/ui-constants.mjs';
import { createModalRuntime } from '../render/modal.mjs';
import {
BRIEFING_MODE_STORAGE_KEY,
GEO_LOOKUP_URL,
MAP_INIT_FALLBACK_DELAY_MS,
MAP_INIT_IDLE_TIMEOUT_MS,
Expand Down
3 changes: 2 additions & 1 deletion app/feed/index.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
loadWatchGeography
} from '../../shared/feed-controller.mjs';
import { reportBackgroundError } from '../../shared/logger.mjs';
import { DEFAULT_API_BASE } from '../../shared/api-base.mjs';

const MANUAL_REFRESH_POLL_INTERVAL_MS = 5_000;
const MANUAL_REFRESH_MAX_WAIT_MS = 90_000;
Expand All @@ -15,7 +16,7 @@ function currentOriginBase() {
}

const LIVE_FEED_TRIGGER_API_BASES = [
'https://brialertbackend.vercel.app',
DEFAULT_API_BASE,
currentOriginBase()
].filter(Boolean);
const LIVE_FEED_TRIGGER_API_PATHS = [
Expand Down
Loading