Skip to content
Merged
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
23 changes: 23 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
name: ci

on:
pull_request:
push:
branches: [main]

permissions:
contents: read

jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm typecheck
- run: pnpm test
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,7 @@ node_modules
.env
/app/locales/*/*.json
/.react-router

# local tooling artifacts (review screenshots, wrangler dev state)
/.ship
/.wrangler
7 changes: 4 additions & 3 deletions app/components/EventDetails.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import LikeButton from "~/components/LikeButton";
import SimpleErrorBoundary from "~/components/SimpleErrorBoundary";
import { LABELER_DID } from "~/config";
import { Profile } from "~/endpoints";
import { safeExternalUrl } from "~/url";
import {
type Event,
useFollowedEventAttendeesDLE,
Expand Down Expand Up @@ -363,7 +364,7 @@ function KeyDatesSection({ event }: { event: Event }) {
<>
{" "}
<Tooltip label={<Trans>Source post</Trans>}>
<Anchor href={k.source} target="_blank">
<Anchor href={safeExternalUrl(k.source)} target="_blank">
<IconBrandBluesky
size={13}
stroke={1.5}
Expand Down Expand Up @@ -395,7 +396,7 @@ function KeyDatesSection({ event }: { event: Event }) {
<Text size="xs" c="dimmed" mt={6}>
<Trans>
Dates are pulled from the convention’s Bluesky. Always confirm on the{" "}
<Anchor href={event.url} target="_blank">
<Anchor href={safeExternalUrl(event.url)} target="_blank">
official site
</Anchor>
.
Expand Down Expand Up @@ -495,7 +496,7 @@ export function Body({ event }: { event: Event }) {
</Box>
<Text size="sm" mb={5}>
<Anchor
href={event.url}
href={safeExternalUrl(event.url)}
target="_blank"
style={{ wordBreak: "break-all" }}
>
Expand Down
3 changes: 2 additions & 1 deletion app/components/EventRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import Avatar from "~/components/Avatar";
import Flag from "~/components/Flag";
import LikeButton from "~/components/LikeButton";
import { type Event, useFollowedEventAttendeesDLE, useNow } from "~/hooks";
import { safeExternalUrl } from "~/url";
import * as classes from "./EventRow.css";
import GuessedEventMarker from "./GuessedEventMarker";
import { getExtendedRequestedLocales } from "./LinguiProvider";
Expand Down Expand Up @@ -208,7 +209,7 @@ export default function EventRow({
</Anchor>{" "}
<Tooltip label={event.url.replace(/https:\/\//, "")}>
<Anchor
href={event.url}
href={safeExternalUrl(event.url)}
target="_blank"
opacity={0.4}
title={t`Website`}
Expand Down
29 changes: 29 additions & 0 deletions app/url.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { describe, expect, it } from "vitest";
import { safeExternalUrl } from "./url";

describe("safeExternalUrl", () => {
it("passes through http and https unchanged", () => {
expect(safeExternalUrl("https://example.com/x")).toBe(
"https://example.com/x",
);
expect(safeExternalUrl("http://example.com")).toBe("http://example.com");
});

it("blocks javascript: and other non-web schemes (the XSS guard)", () => {
expect(safeExternalUrl("javascript:alert(1)")).toBeUndefined();
expect(safeExternalUrl(" javascript:alert(1)")).toBeUndefined();
expect(safeExternalUrl("JavaScript:alert(1)")).toBeUndefined();
expect(safeExternalUrl("java\tscript:alert(1)")).toBeUndefined();
expect(safeExternalUrl("data:text/html,<script>1</script>")).toBeUndefined();
expect(safeExternalUrl("vbscript:msgbox")).toBeUndefined();
});

it("fails closed on empty, nullish, relative and unparseable input", () => {
expect(safeExternalUrl(undefined)).toBeUndefined();
expect(safeExternalUrl(null)).toBeUndefined();
expect(safeExternalUrl("")).toBeUndefined();
expect(safeExternalUrl("not a url")).toBeUndefined();
expect(safeExternalUrl("//protocol-relative.example")).toBeUndefined();
expect(safeExternalUrl("/relative/path")).toBeUndefined();
});
});
24 changes: 24 additions & 0 deletions app/url.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
// Data-sourced URLs (a convention's `url`, a key-date `source` post) come from
// data.cons.fyi and are rendered as anchor `href`s. React does not block
// `javascript:` (or other non-web) URLs, so a poisoned value would become a
// script-execution sink on click. Only pass through http(s) URLs; anything
// else yields `undefined`, leaving the anchor inert.
//
// Only absolute URLs are supported: `new URL(url)` throws on a relative or
// protocol-relative value and we fail closed. That is fine for the current
// sinks (a convention `url` / key-date `source` are always absolute http(s)),
// but a relative URL entering the dataset would silently render inert.
export function safeExternalUrl(
url: string | null | undefined,
): string | undefined {
if (!url) return undefined;
let parsed: URL;
try {
parsed = new URL(url);
} catch {
return undefined;
}
return parsed.protocol === "https:" || parsed.protocol === "http:"
? url
: undefined;
}
5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
"type": "module",
"scripts": {
"build": "react-router build",
"postbuild": "node scripts/inject-csp.mjs",
"test": "vitest run",
"dev": "react-router dev",
"lint": "eslint --ignore-path .gitignore --cache --cache-location ./node_modules/.cache/eslint .",
"start": "react-router-serve ./build/server/index.js",
Expand Down Expand Up @@ -78,7 +80,8 @@
"vite": "^6.0.0",
"vite-plugin-babel": "^1.3.1",
"vite-plugin-babel-macros": "^1.0.6",
"vite-tsconfig-paths": "^4.2.1"
"vite-tsconfig-paths": "^4.2.1",
"vitest": "^4.1.10"
},
"engines": {
"node": ">=20.0.0"
Expand Down
Loading
Loading