Skip to content

Commit f9dcce0

Browse files
ajha-devclaude
andcommitted
Add doc ID validation to prevent arbitrary URL document creation
- Add isValidDocId() validation function to generateDocId.ts - Update App.tsx to validate doc IDs and redirect invalid ones to Landing - Update Landing.tsx to show error message for invalid document IDs - Add doc ID format validation to SharingController endpoints This prevents documents from being created when users visit arbitrary URLs like /somedoc. Valid IDs must be exactly 21 characters using URL-safe characters (A-Za-z0-9_-) matching the nanoid format. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 79fd8ff commit f9dcce0

5 files changed

Lines changed: 80 additions & 10 deletions

File tree

frontend/src/App.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,14 @@
22
* App Component
33
*
44
* Root component that extracts doc ID from URL and renders the Editor.
5+
* Validates doc ID format and redirects invalid IDs to landing page.
56
*/
67

78
import { useEffect } from "react";
89
import { Editor } from "./components/Editor";
910
import { Landing } from "./components/Landing";
1011
import { initImportBridge } from "./lib/importBridge";
12+
import { isValidDocId } from "./lib/generateDocId";
1113

1214
function App() {
1315
// Initialize import bridge on mount
@@ -16,7 +18,6 @@ function App() {
1618
}, []);
1719

1820
// Extract doc ID from URL path
19-
// Examples: /abc123 → "abc123", /meeting-notes → "meeting-notes"
2021
const path = window.location.pathname;
2122
const docId = path.slice(1);
2223

@@ -25,6 +26,12 @@ function App() {
2526
return <Landing />;
2627
}
2728

29+
// Validate doc ID format - must be a valid nanoid (21 chars, A-Za-z0-9_-)
30+
// Invalid IDs redirect to landing page to prevent accidental doc creation
31+
if (!isValidDocId(docId)) {
32+
return <Landing invalidDocId={docId} />;
33+
}
34+
2835
return <Editor docId={docId} />;
2936
}
3037

frontend/src/components/Landing.tsx

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,18 @@
11
/**
22
* Landing Component
33
*
4-
* First screen shown when visiting root path '/'.
4+
* First screen shown when visiting root path '/' or an invalid document ID.
55
* Collects username and redirects to a new document with random ID.
66
*/
77

88
import { useState, useEffect, type FormEvent } from "react";
99
import { generateDocId } from "../lib/generateDocId";
1010

11-
export function Landing() {
11+
interface LandingProps {
12+
invalidDocId?: string;
13+
}
14+
15+
export function Landing({ invalidDocId }: LandingProps) {
1216
const [name, setName] = useState("");
1317

1418
// Pre-fill name from localStorage if exists
@@ -76,9 +80,23 @@ export function Landing() {
7680
lineHeight: "1.5",
7781
}}
7882
>
79-
A collaborative markdown editor for real-time team collaboration.
80-
<br />
81-
Create a new document to get started.
83+
{invalidDocId ? (
84+
<>
85+
<span style={{ color: "#cf222e", fontWeight: 500 }}>
86+
Invalid document ID: "{invalidDocId}"
87+
</span>
88+
<br />
89+
Document IDs must be exactly 21 characters (A-Z, a-z, 0-9, _, -).
90+
<br />
91+
Create a new document to get started.
92+
</>
93+
) : (
94+
<>
95+
A collaborative markdown editor for real-time team collaboration.
96+
<br />
97+
Create a new document to get started.
98+
</>
99+
)}
82100
</p>
83101

84102
<form onSubmit={handleSubmit}>

frontend/src/lib/generateDocId.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
import { nanoid } from "nanoid";
22

3+
/**
4+
* Valid doc ID pattern: 21 characters, URL-safe (A-Za-z0-9_-)
5+
* This matches the nanoid default output format.
6+
*/
7+
const DOC_ID_PATTERN = /^[A-Za-z0-9_-]{21}$/;
8+
39
/**
410
* Generates a unique document ID using nanoid.
511
*
@@ -12,3 +18,17 @@ import { nanoid } from "nanoid";
1218
export function generateDocId(): string {
1319
return nanoid();
1420
}
21+
22+
/**
23+
* Validates if a string is a valid document ID.
24+
*
25+
* Valid IDs must:
26+
* - Be exactly 21 characters long
27+
* - Only contain URL-safe characters (A-Za-z0-9_-)
28+
*
29+
* @param id - The string to validate
30+
* @returns true if valid, false otherwise
31+
*/
32+
export function isValidDocId(id: string): boolean {
33+
return DOC_ID_PATTERN.test(id);
34+
}

lib/markdoc_web/controllers/sharing_controller.ex

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@ defmodule MarkdocWeb.SharingController do
1111

1212
alias Markdoc.{DocServer, DocSupervisor, DocRegistry}
1313

14+
# Valid doc ID pattern: 21 characters, URL-safe (A-Za-z0-9_-)
15+
@doc_id_pattern ~r/^[A-Za-z0-9_-]{21}$/
16+
1417
@doc """
1518
GET /api/docs/:doc_id/sharing
1619
@@ -19,7 +22,8 @@ defmodule MarkdocWeb.SharingController do
1922
def show(conn, %{"doc_id" => doc_id}) do
2023
auth_user = conn.assigns[:auth_user]
2124

22-
with :ok <- ensure_authenticated(auth_user),
25+
with :ok <- validate_doc_id(doc_id),
26+
:ok <- ensure_authenticated(auth_user),
2327
:ok <- ensure_doc_exists(doc_id),
2428
metadata <- DocServer.get_metadata(doc_id),
2529
:ok <- ensure_owner(auth_user, metadata) do
@@ -30,6 +34,11 @@ defmodule MarkdocWeb.SharingController do
3034
owner_email: metadata.owner_email
3135
})
3236
else
37+
{:error, :invalid_doc_id} ->
38+
conn
39+
|> put_status(:bad_request)
40+
|> json(%{error: "bad_request", reason: "invalid_doc_id"})
41+
3342
{:error, :not_authenticated} ->
3443
conn
3544
|> put_status(:unauthorized)
@@ -59,7 +68,8 @@ defmodule MarkdocWeb.SharingController do
5968
def update(conn, %{"doc_id" => doc_id} = params) do
6069
auth_user = conn.assigns[:auth_user]
6170

62-
with :ok <- ensure_authenticated(auth_user),
71+
with :ok <- validate_doc_id(doc_id),
72+
:ok <- ensure_authenticated(auth_user),
6373
:ok <- ensure_doc_exists(doc_id),
6474
metadata <- DocServer.get_metadata(doc_id),
6575
:ok <- ensure_owner(auth_user, metadata),
@@ -85,6 +95,11 @@ defmodule MarkdocWeb.SharingController do
8595
|> json(%{error: "update_failed", reason: inspect(reason)})
8696
end
8797
else
98+
{:error, :invalid_doc_id} ->
99+
conn
100+
|> put_status(:bad_request)
101+
|> json(%{error: "bad_request", reason: "invalid_doc_id"})
102+
88103
{:error, :not_authenticated} ->
89104
conn
90105
|> put_status(:unauthorized)
@@ -114,6 +129,16 @@ defmodule MarkdocWeb.SharingController do
114129

115130
## Private Functions
116131

132+
defp validate_doc_id(doc_id) when is_binary(doc_id) do
133+
if Regex.match?(@doc_id_pattern, doc_id) do
134+
:ok
135+
else
136+
{:error, :invalid_doc_id}
137+
end
138+
end
139+
140+
defp validate_doc_id(_), do: {:error, :invalid_doc_id}
141+
117142
defp ensure_authenticated(%{authenticated: true}), do: :ok
118143
defp ensure_authenticated(_), do: {:error, :not_authenticated}
119144

priv/static/index.html

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,8 @@
99
<link rel="manifest" href="site.webmanifest" />
1010
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
1111
<title>MarkDoc</title>
12-
<script type="module" crossorigin src="/assets/index-DrGMR03A.js"></script>
13-
<link rel="stylesheet" crossorigin href="/assets/index-BzwhaErm.css">
12+
<script type="module" crossorigin src="/assets/index-mmX9Txq4.js"></script>
13+
<link rel="stylesheet" crossorigin href="/assets/index-mk5zstl2.css">
1414
</head>
1515
<body>
1616
<div id="root"></div>

0 commit comments

Comments
 (0)